diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 287e5da..ec15925 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,38 +6,35 @@ jobs: specs: name: Run specs with Ruby ${{matrix.ruby}} runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental || false }} strategy: fail-fast: false matrix: include: - - ruby: '2.6' - bundler: 2.2.30 - - ruby: '2.7' - bundler: 2.2.30 - - ruby: '3.0' - bundler: 2.2.30 - - ruby: '3.1' + - ruby: "3.2" bundler: 2.6.3 - - ruby: '3.2' + - ruby: "3.3" bundler: 2.6.3 - - ruby: '3.3' + - ruby: "3.4" bundler: 2.6.3 - - ruby: '3.4' + - ruby: "4.0" + bundler: 2.6.3 + - ruby: "4.0.1" bundler: 2.6.3 - ruby: head bundler: 2.6.3 experimental: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{matrix.ruby}} - bundler: ${{matrix.bundler}} - bundler-cache: true + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{matrix.ruby}} + bundler: ${{matrix.bundler}} + bundler-cache: true - - name: Install gems - run: bundle _${{matrix.bundler}}_ install + - name: Install gems + run: bundle _${{matrix.bundler}}_ install - - name: Run specs - run: bundle _${{matrix.bundler}}_ exec rspec + - name: Run specs + run: bundle _${{matrix.bundler}}_ exec rspec diff --git a/.gitignore b/.gitignore index 9106b2a..0c7e0a9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ /pkg/ /spec/reports/ /tmp/ +Gemfile.lock +LOCAL_TODO* diff --git a/.rubocop.yml b/.rubocop.yml index ec088e6..4a0abc5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,209 +1,40 @@ AllCops: - DefaultFormatter: progress - TargetRubyVersion: 2.5 - -Layout/AlignParameters: - EnforcedStyle: with_first_parameter - -Layout/BlockEndNewline: + TargetRubyVersion: 3.2 + NewCops: enable + SuggestExtensions: false Exclude: - - spec/**/*_spec.rb - -Layout/ClassStructure: - ExpectedOrder: - - constants - - module_inclusion - - initializer - - public_methods - - public_class_methods - - protected_methods - - private_methods - -Layout/EmptyLineAfterGuardClause: - Enabled: false - -Layout/EndAlignment: - EnforcedStyleAlignWith: start_of_line - -Layout/FirstArrayElementLineBreak: - Enabled: true - -Layout/FirstHashElementLineBreak: - Enabled: true - -Layout/FirstMethodParameterLineBreak: - Enabled: true - -Layout/IndentArray: - EnforcedStyle: consistent + - 'spec/**/*' + - 'vendor/**/*' -Layout/IndentHash: - EnforcedStyle: consistent - -Layout/MultilineBlockLayout: - Exclude: - - spec/**/*_spec.rb - -Layout/MultilineMethodCallBraceLayout: - EnforcedStyle: same_line - -Layout/MultilineMethodDefinitionBraceLayout: - EnforcedStyle: same_line - -Layout/MultilineOperationIndentation: - EnforcedStyle: indented - IndentationWidth: 2 - -Layout/RescueEnsureAlignment: +Style/Documentation: Enabled: false -Layout/SpaceInsideArrayPercentLiteral: - Exclude: - - 'lib/tasks/*_permissions.rake' - -Layout/SpaceInsideBlockBraces: - Exclude: - - spec/**/*_spec.rb - -Lint/AmbiguousBlockAssociation: +Metrics/BlockLength: Exclude: - - 'spec/**/*_spec.rb' - -Lint/NestedMethodDefinition: - Enabled: false + - '*.gemspec' -Lint/AmbiguousOperator: +# Dev dependencies in gemspec is standard for gems +Gemspec/DevelopmentDependencies: Enabled: false -Lint/RescueException: - Exclude: - - 'lib/business_pipeline/lib/business_pipeline/interceptors/error_handling.rb' - - 'lib/business_pipeline/lib/business_pipeline/interceptors/error_reporting.rb' - -Metrics/AbcSize: - Exclude: - - 'app/services/retro_compatibility/*_backporter.rb' - - 'lib/tasks/*.rake' - - 'spec/support/payloads/**/*.rb' - Max: 18 - -Metrics/BlockLength: - Exclude: - - '**/*.builder' - - '**/*.rake' - - 'app/views/api/v3/**/_*.jb' - - 'config/environments/production.rb' - - 'config/routes.rb' - - 'Gemfile' - - 'Guardfile' - - 'spec/**/*.rb' - +# Adapter and Document are cohesive orchestration classes Metrics/ClassLength: Exclude: - - 'app/forms/**/*.rb' - - 'app/services/retro_compatibility/*_backporter.rb' - - 'app/services/tracking/*tracking_handler.rb' + - 'lib/pdfmonkey/adapter.rb' + - 'lib/pdfmonkey/document.rb' -Metrics/CyclomaticComplexity: - Exclude: - - 'lib/tasks/*.rake' - -Metrics/LineLength: - Exclude: - - 'config/routes.rb' - - 'lib/**/*.gemspec' - - 'lib/tasks/*.rake' - - 'spec/**/*_spec.rb' - - 'spec/support/factories/*.rb' - Max: 90 +# Some methods are inherently complex (API orchestration, polling, data extraction) +Metrics/AbcSize: + Max: 23 Metrics/MethodLength: - Exclude: - - 'app/services/retro_compatibility/*_backporter.rb' - - 'app/services/tracking/**/*.rb' - - 'app/web_strategies/**/*.rb' - - 'spec/support/payloads/**/*.rb' - -Naming/PredicateName: - NamePrefixBlacklist: - - is_ - -Naming/UncommunicativeMethodParamName: - Enabled: false - -Performance/RedundantBlockCall: - Enabled: false - -Rails: - Enabled: true - -# FIXME: https://github.com/bbatsov/rubocop/issues/4751 -# Rails/HasManyOrHasOneDependent: -# Enabled: false - -Rails/OutputSafety: - Enabled: false - -Rails/SkipsModelValidations: - Enabled: false + Max: 25 +# Inline private is intentional style choice for this codebase Style/AccessModifierDeclarations: - Enabled: false + EnforcedStyle: inline -Style/Alias: - EnforcedStyle: prefer_alias_method - -Style/AsciiComments: - Enabled: false - -Style/BlockDelimiters: +# TemplateCard.list enforces workspace_id: as required — not useless +Lint/UselessMethodDefinition: Exclude: - - spec/**/*_spec.rb - -Style/Documentation: - Enabled: false - -Style/DoubleNegation: - Enabled: false - -Style/FormatStringToken: - EnforcedStyle: template - -Style/FrozenStringLiteralComment: - Enabled: false - -Style/IfUnlessModifier: - Enabled: false - -Style/MultilineBlockChain: - Enabled: false - -Style/PercentLiteralDelimiters: - PreferredDelimiters: - '%': '{}' - '%i': '[]' - '%q': '{}' - '%Q': '{}' - '%r': '{}' - '%s': '{}' - '%w': '[]' - '%W': '[]' - '%x': '{}' - -Style/RegexpLiteral: - EnforcedStyle: mixed - -Style/SignalException: - EnforcedStyle: semantic - -Style/SingleLineBlockParams: - Enabled: false - -Style/StringLiterals: - EnforcedStyle: single_quotes - -Style/StringLiteralsInInterpolation: - EnforcedStyle: single_quotes - -Style/YodaCondition: - Enabled: false + - 'lib/pdfmonkey/template_card.rb' diff --git a/CHANGELOG.md b/CHANGELOG.md index 86a9adf..1f9c207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ ## Unreleased +### Breaking changes + +* **Requiring Ruby >= 3.2** — The gem now requires Ruby 3.2 or later. +* **Exception-based error handling** — API errors now raise `Pdfmonkey::ApiError` (with `errors` and `status_code` attributes) and network errors raise `Pdfmonkey::ConnectionError` instead of returning error hashes. Rescue `Pdfmonkey::Error` for a catch-all. +* **`Document.generate!` / `Document.generate` signature change** — These methods now use keyword arguments (`document_template_id:`, `payload:`, `meta:`). Positional arguments still work but emit a deprecation warning. +* **`Document.generate!` raises on failure** — `Document.generate!` now raises `Pdfmonkey::GenerationError` when the document ends with `error` or `failure` status, instead of returning the failed document. +* **`Document.generate!` polling** — `Document.generate!` now sleeps `poll_interval` seconds (default 0.5s) between status polls instead of busy-looping. +* **`document_template_id` validation** — `Document.generate!`, `Document.generate`, and `Document.create_draft` now raise `ArgumentError` when `document_template_id` is missing or blank. +* **Removing `ostruct` dependency** — Attributes are now backed by `Struct` instead of `OpenStruct`. This removes the runtime dependency on the `ostruct` gem. +* **`Document#attributes` is no longer public** — Access individual attributes through their accessor methods instead. +* **`User-Agent` is no longer configurable** — The `user_agent` configuration option has been removed. The header is now always `pdfmonkey-ruby/`. +* **`to_json` omits nil attributes** — `Resource#to_json` now compacts nil values and strips the `errors` attribute from the serialized output. +* **Resource base class** — All resource classes now inherit from `Pdfmonkey::Resource` which provides shared CRUD operations, attribute management and JSON serialization. + +### New features + +* Adding `Document#generate` and `Document#generate!` instance methods for triggering generation on draft documents +* Adding `Document#save` as a public method (was private in 0.9.0) +* Adding `Document.create_draft` for creating draft documents with preview support +* Adding `Document#update!` for updating document attributes via PUT +* Adding `Document.list_cards`, `Document.fetch_card`, and `Document.fetch_full` for accessing documents through the `Document` class +* Adding `output_type` to `Document` attributes +* Adding resources for: + * `Engine` + * `Snippet` + * `TemplateCard` + * `TemplateFolder` + * `Template` + * `Webhook` + * `Workspace` (read-only) +* Adding `CurrentUser.fetch` for retrieving authenticated user info +* Adding `Pdfmonkey.with_adapter` for per-request adapter scoping (e.g. multi-tenant credentials) +* Adding persistent HTTP connections with configurable timeouts (`open_timeout`, `read_timeout`, `keep_alive_timeout`) +* Adding API key validation at request time (raises `Pdfmonkey::Error` if unconfigured) + ## 0.9.0 * Testing against Ruby 3.2, 3.3 and 3.4 diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index c778aa5..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,58 +0,0 @@ -PATH - remote: . - specs: - pdfmonkey (0.9.0) - ostruct (>= 0.6.0) - -GEM - remote: https://rubygems.org/ - specs: - ast (2.4.2) - diff-lcs (1.5.1) - ostruct (0.6.1) - parallel (1.22.0) - parser (3.1.1.0) - ast (~> 2.4.1) - rainbow (3.1.1) - rake (13.2.1) - regexp_parser (2.2.1) - rexml (3.3.9) - rspec (3.13.0) - rspec-core (~> 3.13.0) - rspec-expectations (~> 3.13.0) - rspec-mocks (~> 3.13.0) - rspec-core (3.13.2) - rspec-support (~> 3.13.0) - rspec-expectations (3.13.3) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.2) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-support (3.13.2) - rubocop (1.26.0) - parallel (~> 1.10) - parser (>= 3.1.0.0) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml - rubocop-ast (>= 1.16.0, < 2.0) - ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.16.0) - parser (>= 3.1.1.0) - ruby-progressbar (1.11.0) - unicode-display_width (2.1.0) - -PLATFORMS - ruby - -DEPENDENCIES - bundler (~> 2.2) - pdfmonkey! - rake (~> 13.0) - rspec (~> 3.8) - rubocop (~> 1.26) - -BUNDLED WITH - 2.6.3 diff --git a/README.md b/README.md index cb9dd47..0ca5f5e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This gem is the quickest way to use the [PDFMonkey](https://www.pdfmonkey.io) AP ## Installation -Add this line to your application’s Gemfile: +Add this line to your application's Gemfile: ```ruby gem 'pdfmonkey' @@ -32,7 +32,7 @@ PDFMonkey will look for the `PDFMONKEY_PRIVATE_KEY` environment variable. This v #### Setting credentials manually -You can choose to set up your credentials explicitely in your application: +You can choose to set up your credentials explicitly in your application: ```ruby Pdfmonkey.configure do |config| @@ -40,25 +40,41 @@ Pdfmonkey.configure do |config| end ``` -### Generating a document +**Note:** Configuration is global. If you need per-request credentials (e.g. multi-tenant), use `with_adapter`: -Once your App is created in the [PDFMonkey Dashboard](https://dashboard.pdfmonkey.io), create a Document Template and add the content you want to use. Every Document Template has a unique identifier (UUID), get your template’s identifier. Your ready to generate a Document. +```ruby +config = Pdfmonkey::Configuration.new +config.private_key = 'tenant-specific-key' + +adapter = Pdfmonkey::Adapter.new(config: config) + +Pdfmonkey.with_adapter(adapter) do + Pdfmonkey::Document.generate!( + document_template_id: 'b13ebd75-…', + payload: { name: 'John Doe' } + ) +end +``` + +All operations inside the block use the given adapter. Calls outside the block continue using the global configuration. + +### Documents #### Synchronous generation -If your want to wait for a Document’s generation before continuing with your workflow, use the `generate!` method, it will request a document generation and wait for it to succeed or fail before giving you any answer. +If you want to wait for a Document's generation before continuing with your workflow, use the `generate!` method, it will request a document generation and wait for it to succeed or fail before giving you any answer. ```ruby -template_id = 'b13ebd75-d290-409b-9cac-8f597ae3e785' -data = { name: 'John Doe' } - -document = Pdfmonkey::Document.generate!(template_id, data) +document = Pdfmonkey::Document.generate!( + document_template_id: 'b13ebd75-d290-409b-9cac-8f597ae3e785', + payload: { name: 'John Doe' } +) document.status # => 'success' document.download_url # => 'https://…' ``` -:warning: The download URL of a document is only valid **for 1 hour**. Passed this delay, reload the document to obtain a new one: +**Warning:** The download URL of a document is only valid **for 1 hour**. Past this delay, reload the document to obtain a new one: ```ruby document.reload! @@ -66,49 +82,79 @@ document.reload! #### Asynchronous generation -PDFMonkey was created with an asynchronous workflow in mind. It provides webhooks to inform you of a Document’s generation success or failure. +PDFMonkey was created with an asynchronous workflow in mind. It provides webhooks to inform you of a Document's generation success or failure. -To leverage this behavior and continue working while your Document is generated, use the `generate` method: +To leverage this behavior and continue working while your Document is being generated, use the `generate` method: ```ruby -template_id = 'b13ebd75-d290-409b-9cac-8f597ae3e785' -data = { name: 'John Doe' } - -document = Pdfmonkey::Document.generate(template_id, data) +document = Pdfmonkey::Document.generate( + document_template_id: 'b13ebd75-d290-409b-9cac-8f597ae3e785', + payload: { name: 'John Doe' } +) document.status # => 'pending' document.download_url # => nil ``` -If you have a webhook URL set up it will be called with you document once the generation is complete. You can simulate it for testing with the following cURL command: +If you have a webhook URL set up it will be called with your document once the generation is complete. You can simulate it for testing with the following cURL command: -```ruby +```bash curl \ -X POST \ -H 'Content-Type: application/json' \ -d '{ "document": { + "id": "76bebeb9-9eb1-481a-bc3c-faf43dc3ac81", "app_id": "d9ec8249-65ae-4d50-8aee-7c12c1f9683a", - "checksum": "ac0c2b6bcc77e2b01dc6ca6a9f656b2d", "created_at": "2020-01-02T03:04:05.000+01:00", "document_template_id": "f7fbe2b4-a57c-46ee-8422-5ae8cc37daac", - "download_url": "https://example.com/76bebeb9-9eb1-481a-bc3c-faf43dc3ac81.pdf", - "filename": "76bebeb9-9eb1-481a-bc3c-faf43dc3ac81.pdf", - "id": "76bebeb9-9eb1-481a-bc3c-faf43dc3ac81", - "meta": null, - "payload": "{\"name\": \"John Doe\"}", - "preview_url": null, + "meta": "{\"_filename\":\"my-doc.pdf\"}", + "output_type": "pdf", "status": "success", - "updated_at": "2020-01-02T03:04:15.000+01:00" + "updated_at": "2020-01-02T03:04:15.000+01:00", + "xml_data": null, + "payload": "{\"name\": \"John Doe\"}", + "download_url": "https://example.com/76bebeb9-9eb1-481a-bc3c-faf43dc3ac81.pdf", + "checksum": "ac0c2b6bcc77e2b01dc6ca6a9f656b2d", + "failure_cause": null, + "filename": "my-doc.pdf", + "generation_logs": [], + "preview_url": "https://preview.pdfmonkey.io/pdf/web/viewer.html?file=https%3A%2F%2Fpreview.pdfmonkey.io%2Fdocument-render%2F76bebeb9-9eb1-481a-bc3c-faf43dc3ac81%2Fac0c2b6bcc77e2b01dc6ca6a9f656b2d", + "public_share_link": "https://example.com/76bebeb9-9eb1-481a-bc3c-faf43dc3ac81.pdf" } }' ``` +#### Draft documents + +You can create a draft document that won't be queued for generation. + +> [!TIP] +> This is useful for embedding a preview via `preview_url` before triggering generation. That what we do in the PDFMonkey dashboard to show you a preview of your document before generating it, using an `iframe`. + +```ruby +draft = Pdfmonkey::Document.create_draft( + document_template_id: 'b13ebd75-d290-409b-9cac-8f597ae3e785', + payload: { name: 'John Doe' } +) +draft.status # => 'draft' +draft.preview_url # => 'https://…' + +# When ready, trigger generation and wait for completion: +draft.generate! +draft.status # => 'success' +draft.download_url # => 'https://…' + +# Or trigger generation without waiting: +draft.generate +draft.status # => 'pending' +``` + #### Attaching meta data to the document -In addition to the Document’s payload you can add meta data when generating a Document. +In addition to the Document's payload you can add meta data when generating a Document. -This can be done by passing a third argument to the `generate!` and `generate` methods: +This can be done by passing the `meta:` keyword argument to the `generate!` and `generate` methods: ```ruby meta = { @@ -116,69 +162,85 @@ meta = { client_id: '123xxx123' } -document = Pdfmonkey::Document.generate!(template_id, payload, meta) +document = Pdfmonkey::Document.generate!( + document_template_id: template_id, + payload: payload, + meta: meta +) document.meta # => '{"_filename":"john-doe-contract.pdf","client_id":"123xxx123"}' -document = Pdfmonkey::Document.generate(template_id, payload, meta) +document = Pdfmonkey::Document.generate( + document_template_id: template_id, + payload: payload, + meta: meta +) document.meta # => '{"_filename":"john-doe-contract.pdf","client_id":"123xxx123"}' ``` -#### Error handling +#### Image generation -In case of error, be it an HTTP layer error or an API error, `document.status` will be set to `'error'` and `document.errors` will contain the error message. +Image generation uses the same API flow as PDF generation. The template's `output_type` attribute indicates whether it produces `'pdf'` or `'image'` output. Image-specific options are passed through the `meta` parameter: ```ruby -# Using an unknown template +doc = Pdfmonkey::Document.generate!( + document_template_id: template_id, + payload: payload, + meta: { + _type: 'png', # webp (default), png, or jpg + _width: 800, # pixels + _height: 600, # pixels + _quality: 80 # webp only, default 100 + } +) +doc.download_url # => URL to the generated image +``` -template_id = 'unknown' -data = { name: 'John Doe' } +#### Updating a document -document = Pdfmonkey::Document.generate(template_id, data) +```ruby +document.update!(status: 'pending') +``` -document.status # => 'error' -document.errors # => ["Document template must exist"] +#### Listing documents -# If your quota is depleted -document = Pdfmonkey::Document.generate(template_id, data) +```ruby +cards = Pdfmonkey::Document.list_cards(page: 1, status: 'success') -document.status # => 'error' -document.errors # => { "status" => ["You’ve reached your quota for th..."] } +cards.each do |card| + puts card.id + puts card.status +end -# If the network is down -document = Pdfmonkey::Document.generate(template_id, data) +cards.current_page # => 1 +cards.total_pages # => 5 -document.status # => 'error' -document.errors # => ["Failed to open TCP connection to api.pdfmonkey.io:443 (getaddrinfo: nodename nor servname provided, or not known)"] +# Navigate pages +next_cards = cards.next_page +prev_cards = cards.prev_page ``` -### Fetching a document +You can filter by `document_template_id:`, `status:`, `workspace_id:`, and `updated_since:`. + +#### Fetching a document + +> [!CAUTION] +> Fetching a full document includes its payload, meaning it could be large depending on the data you provided. We **strongly** recommend using only `fetch_card` unless you have a specific reason to fetch the full document. -You can fetch an existing document using the `.fetch` method: +You can fetch an existing document using `.fetch` (or its explicit alias `.fetch_full`): ```ruby document = Pdfmonkey::Document.fetch('76bebeb9-9eb1-481a-bc3c-faf43dc3ac81') ``` -#### Error handling - -In case of error, be it an HTTP layer error or an API error, `document.status` will be set to `'error'` and `document.error` will contain the error message. +To fetch just the lightweight card representation (recommended): ```ruby -document = Pdfmonkey::Document.fetch('unknown') - -document.status # => 'error' -document.errors # => ["We couldn't find any Document with ID \"unknown\"..."] - -# If the network is down -document = Pdfmonkey::Document.fetch('95eb0b6e-090b-4195-9b7c-cc3d50099867') - -document.status # => 'error' -document.errors # => ["Failed to open TCP connection to api.pdfmonkey.io:443 (getaddrinfo: nodename nor servname provided, or not known)"] +card = Pdfmonkey::Document.fetch_card('76bebeb9-9eb1-481a-bc3c-faf43dc3ac81') ``` -### Deleting a document +#### Deleting a document You can delete an existing document using the `.delete` method: @@ -196,26 +258,306 @@ document.delete! #### Error handling -In case of error, be it an HTTP layer error or an API error, an error Hash will be returned. +API errors and network errors raise exceptions: ```ruby -document.delete! -#=> true +begin + document = Pdfmonkey::Document.generate( + document_template_id: template_id, + payload: data + ) +rescue Pdfmonkey::ApiError => e + e.message # => "Document template must exist" + e.errors # => ["Document template must exist"] + e.status_code # => 422 +rescue Pdfmonkey::ConnectionError => e + e.message # => "Failed to open TCP connection to api.pdfmonkey.io:443 ..." +end +``` -document.delete! -# { -# errors: ["We couldn't find any Document with ID \"11111111-2222-3333-4444-555555555555\". If ..."], -# status: "error" -# } +When using `generate!`, an additional exception may be raised if the document's status is `'error'` or `'failure'`: -# If the network is down -document.delete! -# { -# errors: ["Failed to open TCP connection to api.pdfmonkey.io:443 (getaddrinfo: nodename nor servname provided, or not known)"], -# status: "error" -# } +```ruby +begin + document = Pdfmonkey::Document.generate!( + document_template_id: template_id, + payload: data + ) +rescue Pdfmonkey::GenerationError => e + e.message # => "Document generation failed: Template error" + e.document # => # (the failed document) +end +``` + +All exception classes inherit from `Pdfmonkey::Error`, so you can rescue broadly: + +```ruby +begin + document = Pdfmonkey::Document.generate!( + document_template_id: template_id, + payload: data + ) +rescue Pdfmonkey::Error => e + puts "Something went wrong: #{e.message}" +end +``` + +### Templates + +#### Fetching a template + +> [!CAUTION] +> Fetching a full template includes its body and settings, which can be large. Use `list_cards` when you only need metadata. + +```ruby +template = Pdfmonkey::Template.fetch('b13ebd75-d290-409b-9cac-8f597ae3e785') +template.identifier # => 'my-invoice' +template.body # => '

Invoice

…' (published version) +template.body_draft # => '

Invoice v2

…' (draft version) +``` + +You can also use the explicit alias `.fetch_full`: + +```ruby +template = Pdfmonkey::Template.fetch_full('b13ebd75-d290-409b-9cac-8f597ae3e785') +``` + +#### Creating a template + +When creating a template, attributes like `body`, `scss_style`, `settings`, `sample_data`, and `pdf_engine_id` are automatically written to their draft counterparts (`body_draft`, `scss_style_draft`, etc.): + +```ruby +template = Pdfmonkey::Template.create( + identifier: 'my-invoice', + body: '

Invoice

' +) +template.body_draft # => '

Invoice

' +``` + +#### Updating a template + +Like `create`, `update!` writes to the draft fields: + +```ruby +template.update!(body: '

Updated Invoice

') +template.body_draft # => '

Updated Invoice

' +``` + +#### Publishing a template + +Once you're happy with the draft, publish it to copy all draft fields to their published counterparts: + +```ruby +template.publish! +template.body # => '

Updated Invoice

' ``` +#### Listing templates + +```ruby +cards = Pdfmonkey::Template.list_cards(workspace_id: 'f4ab650c-…') +``` + +#### Deleting a template + +```ruby +Pdfmonkey::Template.delete('b13ebd75-…') +# or +template.delete! +``` + +### Template Folders + +```ruby +# List folders +folders = Pdfmonkey::TemplateFolder.list + +# Create a folder +folder = Pdfmonkey::TemplateFolder.create(identifier: 'invoices') + +# Fetch a folder +folder = Pdfmonkey::TemplateFolder.fetch('folder-id') + +# Update a folder +folder.update!(identifier: 'receipts') + +# Delete a folder +Pdfmonkey::TemplateFolder.delete('folder-id') +# or +folder.delete! +``` + +To create a template inside a specific folder, pass the `template_folder_id`: + +```ruby +folder = Pdfmonkey::TemplateFolder.create(identifier: 'invoices') + +template = Pdfmonkey::Template.create( + identifier: 'monthly-invoice', + body: '

Invoice

', + template_folder_id: folder.id +) +``` + +### Snippets + +Snippets are reusable HTML components that can be included in templates. + +```ruby +# List snippets +snippets = Pdfmonkey::Snippet.list + +# Create a snippet +snippet = Pdfmonkey::Snippet.create( + identifier: 'header', + code: '
', + workspace_id: 'f4ab650c-…' +) + +# Fetch a snippet +snippet = Pdfmonkey::Snippet.fetch('snippet-id') + +# Update a snippet +snippet.update!(code: '
Updated
') + +# Delete a snippet +Pdfmonkey::Snippet.delete('snippet-id') +# or +snippet.delete! +``` + +### Workspaces + +Workspaces are read-only resources. They can be listed and fetched but not created, updated, or deleted through the API. + +```ruby +# List workspaces +workspaces = Pdfmonkey::Workspace.list_cards + +workspaces.each do |workspace| + puts workspace.identifier +end + +# Fetch a workspace +workspace = Pdfmonkey::Workspace.fetch('workspace-id') +workspace.identifier # => 'my-app' +``` + +### Webhooks + +Webhooks allow you to receive notifications when documents are generated. + +```ruby +# Create a webhook for all templates in a workspace +webhook = Pdfmonkey::Webhook.create( + url: 'https://example.com/webhooks/pdfmonkey', + event: 'document.generation.completed', + workspace_id: 'f4ab650c-…' +) + +# Optionally restrict to specific templates +webhook = Pdfmonkey::Webhook.create( + url: 'https://example.com/webhooks/pdfmonkey', + event: 'document.generation.completed', + workspace_id: 'f4ab650c-…', + document_template_ids: ['tpl-1', 'tpl-2'] +) + +# You can also specify a custom channel for routing +webhook = Pdfmonkey::Webhook.create( + url: 'https://example.com/webhooks/pdfmonkey', + event: 'document.generation.completed', + workspace_id: 'f4ab650c-…', + custom_channel: 'invoices' +) + +# Delete a webhook +Pdfmonkey::Webhook.delete('webhook-id') +# or +webhook.delete! +``` + +### Engines + +List available PDF rendering engines: + +```ruby +engines = Pdfmonkey::Engine.list + +engines.each do |engine| + puts "#{engine.name} (deprecated: #{engine.deprecated_on || 'no'})" +end +``` + +You can use an engine when creating a template: + +```ruby +engines = Pdfmonkey::Engine.list +v4 = engines.find { |e| e.name == 'v4' } + +template = Pdfmonkey::Template.create( + identifier: 'my-template', + body: '

Hello

', + pdf_engine_id: v4.id +) +``` + +### Current User + +Retrieve information about the authenticated user: + +```ruby +user = Pdfmonkey::CurrentUser.fetch + +user.email # => 'user@example.com' +user.current_plan # => 'pro' +user.available_documents # => 1000 +``` + +### Pagination + +All list methods return `Pdfmonkey::Collection` objects that support pagination: + +```ruby +collection = Pdfmonkey::Document.list_cards(page: 1) + +collection.current_page # => 1 +collection.total_pages # => 5 +collection.next_page_number # => 2 +collection.prev_page_number # => nil + +# Navigate to next/previous pages +next_page = collection.next_page # => Collection or nil +prev_page = collection.prev_page # => Collection or nil +``` + +Collections are `Enumerable`. Methods like `.each`, `.map`, and `.select` operate on the items **of the current page only** — they do not automatically fetch subsequent pages: + +```ruby +# These all act on the current page's items +collection.each { |item| puts item.id } +collection.map(&:status) +collection.select { |item| item.status == 'success' } + +# To process all pages, navigate manually +page = Pdfmonkey::Template.list_cards(page: 1) +while page + page.each { |item| process(item) } + page = page.next_page +end +``` + +### Serialization + +All resources support `to_json` and `to_h`: + +```ruby +document.to_json # => '{"document":{"id":"…","status":"success",…}}' +document.to_h # => {id: "…", status: "success", errors: nil, …} +``` + +`to_json` wraps attributes under the resource's API member key, omits `nil` values and strips the `errors` attribute (it is intended for API requests). Use `to_h` when you need the full attribute hash for logging or caching. + ## Development After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment. @@ -224,7 +566,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/simonc/pdfmonkey. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. +Bug reports and pull requests are welcome on GitHub at https://github.com/pdfmonkeyio/pdfmonkey-ruby. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. ## License @@ -232,4 +574,4 @@ The gem is available as open source under the terms of the [MIT License](https:/ ## Code of Conduct -Everyone interacting in the Pdfmonkey project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/simonc/pdfmonkey/blob/master/CODE_OF_CONDUCT.md). +Everyone interacting in the Pdfmonkey project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/pdfmonkeyio/pdfmonkey-ruby/blob/master/CODE_OF_CONDUCT.md). diff --git a/Rakefile b/Rakefile index 001f192..a9c21f1 100644 --- a/Rakefile +++ b/Rakefile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rubygems' begin @@ -12,7 +14,7 @@ Bundler::GemHelper.install_tasks require 'rake' require 'rspec/core/rake_task' -desc "Run all examples" +desc 'Run all examples' RSpec::Core::RakeTask.new(:spec) do |t| t.rspec_opts = %w[--color --require spec_helper] end diff --git a/bin/console b/bin/console index d9968b6..67370d7 100755 --- a/bin/console +++ b/bin/console @@ -1,4 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true require 'bundler/setup' require 'pdfmonkey' diff --git a/lib/pdfmonkey.rb b/lib/pdfmonkey.rb index e420b79..d430181 100644 --- a/lib/pdfmonkey.rb +++ b/lib/pdfmonkey.rb @@ -1,11 +1,57 @@ # frozen_string_literal: true -require 'pdfmonkey/adapter' require 'pdfmonkey/configuration' -require 'pdfmonkey/document' require 'pdfmonkey/version' module Pdfmonkey class Error < StandardError; end - # Your code goes here... + + class ConnectionError < Error; end + class GenerationError < Error + attr_reader :document + + def initialize(message, document:) + @document = document + super(message) + end + end + + class ApiError < Error + attr_reader :errors, :status_code + + def initialize(message, errors:, status_code:) + @errors = errors + @status_code = status_code + super(message) + end + end + + ADAPTER_KEY = :pdfmonkey_adapter + private_constant :ADAPTER_KEY + + def self.current_adapter + Thread.current[ADAPTER_KEY] + end + + def self.with_adapter(adapter) + previous = Thread.current[ADAPTER_KEY] + Thread.current[ADAPTER_KEY] = adapter + yield + ensure + Thread.current[ADAPTER_KEY] = previous + end end + +require 'pdfmonkey/adapter' +require 'pdfmonkey/collection' +require 'pdfmonkey/resource' +require 'pdfmonkey/current_user' +require 'pdfmonkey/document' +require 'pdfmonkey/document_card' +require 'pdfmonkey/engine' +require 'pdfmonkey/snippet' +require 'pdfmonkey/template' +require 'pdfmonkey/template_card' +require 'pdfmonkey/template_folder' +require 'pdfmonkey/webhook' +require 'pdfmonkey/workspace' diff --git a/lib/pdfmonkey/adapter.rb b/lib/pdfmonkey/adapter.rb index 450cd94..b18fcf0 100644 --- a/lib/pdfmonkey/adapter.rb +++ b/lib/pdfmonkey/adapter.rb @@ -1,25 +1,54 @@ # frozen_string_literal: true +require 'json' require 'net/http' module Pdfmonkey class Adapter + HTTP_METHODS = %i[delete get post put].freeze + + NETWORK_ERRORS = [ + IOError, + SocketError, + Net::OpenTimeout, + Net::ReadTimeout, + Net::WriteTimeout, + Errno::ECONNREFUSED, + Errno::ECONNRESET, + Errno::EHOSTUNREACH, + Errno::EPIPE, + Errno::ETIMEDOUT, + OpenSSL::SSL::SSLError + ].freeze + def initialize(config: Pdfmonkey.configuration) @config = config + @connection = nil end - def call(method, resource) - response = send_request(method, resource) + def call(method, resource, params: {}, path: nil, extract: :member) + response = send_request(method, resource, params: params, path: path) case response when Net::HTTPNoContent then true - when Net::HTTPSuccess then extract_attributes(response, resource) - else extract_errors(response) + when Net::HTTPSuccess then extract_data(response, resource, extract) + else raise_api_error(response) end - rescue StandardError => e - { errors: [e.message], status: 'error' } + rescue *NETWORK_ERRORS => e + reset_connection + raise ConnectionError, e.message + end + + def close + close_connection + end + + def inspect + "#<#{self.class}>" end + private attr_reader :config + private def build_delete_request(uri, _resource) Net::HTTP::Delete.new(uri, headers) end @@ -34,26 +63,78 @@ def call(method, resource) request end - private def extract_attributes(response, resource) - member = resource.class.const_get('MEMBER') - JSON.parse(response.body).fetch(member) + private def build_put_request(uri, resource) + request = Net::HTTP::Put.new(uri, headers) + request.body = resource.to_json + request + end + + private def close_connection + @connection&.finish + rescue StandardError + nil + ensure + @connection = nil + end + + private def connection + return @connection if @connection&.started? + + close_connection + + host_uri = URI(config.host) + http = Net::HTTP.new(host_uri.host, host_uri.port) + http.use_ssl = host_uri.scheme == 'https' + http.open_timeout = config.open_timeout + http.read_timeout = config.read_timeout + http.keep_alive_timeout = config.keep_alive_timeout + http.start + @connection = http end - private def extract_errors(response) - payload = JSON.parse(response.body) - errors = - if payload['error'] - [payload['error']] - elsif payload['errors'].is_a?(Array) - payload['errors'].map { |error| error['detail'] } - elsif payload['errors'].is_a?(Hash) - payload['errors'] + private def extract_data(response, resource, extract) + body = JSON.parse(response.body.to_s) + + case extract + when :member + resource_class = resource.is_a?(Class) ? resource : resource.class + member = resource_class::MEMBER + body.fetch(member) do + raise ApiError.new( + "Missing '#{member}' key in response", + errors: [response.body], + status_code: response.code.to_i + ) end + when :collection + body + else + raise ArgumentError, "Unknown extract mode: #{extract.inspect}" + end + rescue JSON::ParserError + raise ApiError.new( + 'Invalid JSON in response body', + errors: [response.body], + status_code: response.code.to_i + ) + end - { errors: errors, status: 'error' } + private def format_error_message(errors) + case errors + when Array then errors.join(', ') + when Hash + errors.map { |field, messages| "#{field}: #{Array(messages).join(', ')}" }.join('; ') + else errors.to_s + end end private def headers + if config.private_key.nil? || config.private_key.to_s.strip.empty? + raise Pdfmonkey::Error, + 'No API key configured. Set ENV["PDFMONKEY_PRIVATE_KEY"] or ' \ + 'use Pdfmonkey.configure { |c| c.private_key = "..." }' + end + { 'Authorization' => "Bearer #{config.private_key}", 'Content-Type' => 'application/json', @@ -61,23 +142,65 @@ def call(method, resource) } end - private def send_request(method, resource) - uri = URI(url_for(resource)) - request = send("build_#{method}_request", uri, resource) - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = (uri.scheme == 'https') - http.request(request) + private def parse_error_body(response) + payload = JSON.parse(response.body.to_s) + + if payload['error'] + [payload['error']] + elsif payload['errors'].is_a?(Array) + extract_error_messages(payload['errors']) + elsif payload['errors'].is_a?(Hash) + payload['errors'] + else + [response.body] + end + rescue JSON::ParserError + [response.body] end - private def url_for(resource) - collection = resource.class.const_get('COLLECTION') - endpoint = "#{config.host}/#{config.namespace}/#{collection}" - endpoint += "/#{resource.id}" if resource.id - endpoint + private def extract_error_messages(errors) + errors.filter_map do |error| + next error.to_s unless error.is_a?(Hash) + + error['detail'] || error['message'] || error.to_json + end + end + + private def raise_api_error(response) + errors = parse_error_body(response) + + raise ApiError.new( + format_error_message(errors), + errors: errors, + status_code: response.code.to_i + ) end - private + private def reset_connection + close_connection + end + + private def send_request(method, resource, params: {}, path: nil) + raise ArgumentError, "Unsupported HTTP method: #{method.inspect}" unless HTTP_METHODS.include?(method) + + uri = URI(url_for(resource, params: params, path: path)) + request = send("build_#{method}_request", uri, resource) + connection.request(request) + end - attr_reader :config + private def url_for(resource, params: {}, path: nil) + if path + endpoint = "#{config.host}/#{config.namespace}/#{path}" + else + resource_class = resource.is_a?(Class) ? resource : resource.class + collection = resource_class::COLLECTION + endpoint = "#{config.host}/#{config.namespace}/#{collection}" + endpoint += "/#{resource.id}" if !resource.is_a?(Class) && resource.id + end + + endpoint += "?#{URI.encode_www_form(params)}" unless params.empty? + + endpoint + end end end diff --git a/lib/pdfmonkey/collection.rb b/lib/pdfmonkey/collection.rb new file mode 100644 index 0000000..ed78fc5 --- /dev/null +++ b/lib/pdfmonkey/collection.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Pdfmonkey + class Collection + include Enumerable + + attr_reader :items, :current_page, :total_pages, + :next_page_number, :prev_page_number + + def initialize(items:, meta:, page_fetcher:) + @items = items.dup.freeze + @current_page = meta['current_page'] + @total_pages = meta['total_pages'] + @next_page_number = meta['next_page'] + @prev_page_number = meta['prev_page'] + @page_fetcher = page_fetcher + end + + def each(&) + items.each(&) + end + + def next_page + return unless next_page_number + + page_fetcher.call(next_page_number) + end + + def prev_page + return unless prev_page_number + + page_fetcher.call(prev_page_number) + end + + private attr_reader :page_fetcher + end +end diff --git a/lib/pdfmonkey/configuration.rb b/lib/pdfmonkey/configuration.rb index fcf6189..b0c846f 100644 --- a/lib/pdfmonkey/configuration.rb +++ b/lib/pdfmonkey/configuration.rb @@ -2,16 +2,25 @@ module Pdfmonkey class Configuration - attr_accessor :host - attr_accessor :namespace - attr_accessor :private_key - attr_accessor :user_agent + attr_accessor :host, :keep_alive_timeout, :namespace, + :open_timeout, :poll_interval, :private_key, :read_timeout def initialize @host = 'https://api.pdfmonkey.io' + @keep_alive_timeout = 30 @namespace = 'api/v1' - @private_key = ENV['PDFMONKEY_PRIVATE_KEY'] - @user_agent = 'Ruby' + @open_timeout = 30 + @poll_interval = 0.5 + @private_key = ENV.fetch('PDFMONKEY_PRIVATE_KEY', nil) + @read_timeout = 30 + end + + def user_agent + "pdfmonkey-ruby/#{Pdfmonkey::VERSION}" + end + + def inspect + "#<#{self.class} host=#{host.inspect} namespace=#{namespace.inspect}>" end end diff --git a/lib/pdfmonkey/current_user.rb b/lib/pdfmonkey/current_user.rb new file mode 100644 index 0000000..5650e38 --- /dev/null +++ b/lib/pdfmonkey/current_user.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Pdfmonkey + class CurrentUser < Resource + ATTRIBUTES = %i[ + auth_token + available_documents + block_resources + created_at + current_plan + current_plan_interval + desired_name + email + errors + id + lang + paying_customer + share_links + trial_ends_on + updated_at + ].freeze + + COLLECTION = 'current_users' + MEMBER = 'current_user' + + def_delegators :attributes, *ATTRIBUTES + + def self.fetch + adapter = Pdfmonkey::Adapter.new + attrs = adapter.call(:get, self, path: 'current_user') + resource_attrs = attrs.transform_keys(&:to_sym) + resource_attrs.delete(:adapter) + new(adapter: adapter, **resource_attrs) + end + end +end diff --git a/lib/pdfmonkey/document.rb b/lib/pdfmonkey/document.rb index 10d9b02..bde2752 100644 --- a/lib/pdfmonkey/document.rb +++ b/lib/pdfmonkey/document.rb @@ -1,12 +1,11 @@ # frozen_string_literal: true -require 'forwardable' -require 'json' -require 'ostruct' - module Pdfmonkey - class Document - extend Forwardable + class Document < Resource + include Fetchable + include Creatable + include Updatable + include Deletable ATTRIBUTES = %i[ app_id @@ -20,6 +19,7 @@ class Document generation_logs id meta + output_type payload preview_url public_share_link @@ -28,78 +28,106 @@ class Document ].freeze COMPLETE_STATUSES = %w[error failure success].freeze + FAILURE_STATUSES = %w[error failure].freeze COLLECTION = 'documents' MEMBER = 'document' - attr_reader :attributes def_delegators :attributes, *ATTRIBUTES - def self.delete(document_id) - new(id: document_id).delete! + def self.generate!(*args, document_template_id: nil, payload: nil, meta: nil) + document_template_id, payload, meta = + resolve_document_args(args, document_template_id, payload, meta, __method__) + validate_template_id!(document_template_id) + + generate(document_template_id: document_template_id, payload: payload, meta: meta) + .send(:poll_until_done!) end - def self.fetch(document_id) - new(id: document_id).reload! + def self.generate(*args, document_template_id: nil, payload: nil, meta: nil) + create_document('pending', args, document_template_id, payload, meta, __method__) end - def self.generate!(document_template_id, payload, meta = {}) - document = generate(document_template_id, payload, meta) - document.reload! until document.done? - document + def self.create_draft(*args, document_template_id: nil, payload: nil, meta: nil) + create_document('draft', args, document_template_id, payload, meta, __method__) end - def self.generate(template_id, payload, meta = {}) - document = new( - document_template_id: template_id, - meta: meta.to_json, - payload: payload.to_json, - status: 'pending') + private_class_method def self.create_document(status, args, document_template_id, payload, meta, method_name) + document_template_id, payload, meta = + resolve_document_args(args, document_template_id, payload, meta, method_name) + validate_template_id!(document_template_id) + + new( + document_template_id: document_template_id, + meta: json_encode(meta), + payload: json_encode(payload), + status: status + ).save + end - document.send(:save) + private_class_method def self.validate_template_id!(template_id) + return unless template_id.nil? || template_id.to_s.strip.empty? + + raise ArgumentError, 'document_template_id is required' end - def initialize(adapter: Pdfmonkey::Adapter.new, **attributes) - @adapter = adapter - @attributes = OpenStruct.new(ATTRIBUTES.zip([]).to_h) - update(attributes) + private_class_method def self.json_encode(value) + case value + when nil then nil + when String then value + else value.to_json + end end - def delete! - adapter.call(:delete, self) + private_class_method def self.resolve_document_args(args, kw_template_id, kw_payload, meta, method_name) + if args.any? + warn "[PDFMonkey] Positional arguments for Document.#{method_name} are deprecated. " \ + 'Use keyword arguments instead: ' \ + "Document.#{method_name}(document_template_id:, payload:, meta:)", + uplevel: 2 + [args[0], args[1], args[2] || meta] + else + [kw_template_id, kw_payload, meta] + end end - def done? - COMPLETE_STATUSES.include?(status) + def self.list_cards(**) + Pdfmonkey::DocumentCard.list(**) end - def reload! - attributes = adapter.call(:get, self) - update(attributes) - self + def self.fetch_card(id) + Pdfmonkey::DocumentCard.fetch(id) end - def to_json - attrs = attributes.to_h - attrs.delete(:errors) + def self.fetch_full(id) + fetch(id) + end - { document: attrs }.to_json + def generate + update!(status: 'pending') end - private def save - attributes = adapter.call(:post, self) - update(attributes) - self + def generate! + generate + poll_until_done! end - private def update(new_attributes) - new_attributes.each do |key, value| - sym_key = key.to_sym - attributes[sym_key] = value if ATTRIBUTES.include?(sym_key) - end + def done? + COMPLETE_STATUSES.include?(status) end - private + private def poll_until_done! + until done? + sleep(Pdfmonkey.configuration.poll_interval) + reload! + end - attr_reader :adapter + if FAILURE_STATUSES.include?(status) + message = 'Document generation failed' + message += ": #{failure_cause}" if failure_cause + raise Pdfmonkey::GenerationError.new(message, document: self) + end + + self + end end end diff --git a/lib/pdfmonkey/document_card.rb b/lib/pdfmonkey/document_card.rb new file mode 100644 index 0000000..40da0f7 --- /dev/null +++ b/lib/pdfmonkey/document_card.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Pdfmonkey + class DocumentCard < Resource + include Fetchable + include Listable + + ATTRIBUTES = %i[ + app_id + created_at + document_template_id + document_template_identifier + download_url + errors + failure_cause + filename + id + meta + output_type + preview_url + public_share_link + status + updated_at + ].freeze + + COLLECTION = 'document_cards' + MEMBER = 'document_card' + + FILTERS = { + document_template_id: 'q[document_template_id]', + status: 'q[status]', + workspace_id: 'q[workspace_id]', + updated_since: 'q[updated_since]' + }.freeze + + def_delegators :attributes, *ATTRIBUTES + + def self.list(page: 1, **) + super + end + + def to_document + Pdfmonkey::Document.fetch(id) + end + end +end diff --git a/lib/pdfmonkey/engine.rb b/lib/pdfmonkey/engine.rb new file mode 100644 index 0000000..ec59f6d --- /dev/null +++ b/lib/pdfmonkey/engine.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Pdfmonkey + class Engine < Resource + include Listable + + ATTRIBUTES = %i[ + deprecated_on + errors + id + name + ].freeze + + COLLECTION = 'pdf_engines' + MEMBER = 'pdf_engine' + + def_delegators :attributes, *ATTRIBUTES + end +end diff --git a/lib/pdfmonkey/resource.rb b/lib/pdfmonkey/resource.rb new file mode 100644 index 0000000..a28a0b7 --- /dev/null +++ b/lib/pdfmonkey/resource.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require 'forwardable' +require 'json' + +module Pdfmonkey + class Resource + extend Forwardable + + module Fetchable + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def fetch(id) + new(id: id).reload! + end + end + + def reload! + new_attrs = adapter.call(:get, self) + update(new_attrs) + self + end + end + + module Creatable + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def create(**attrs) + new(**attrs).save + end + end + + def save + if attributes[:id] && !is_a?(Updatable) + raise Pdfmonkey::Error, "Cannot save an existing #{self.class.name} (updates are not supported)" + end + + method = attributes[:id] ? :put : :post + new_attrs = adapter.call(method, self) + update(new_attrs) + self + end + end + + module Updatable + def update!(**new_attrs) + previous = {} + new_attrs.each_key do |key| + sym_key = key.to_sym + previous[sym_key] = attributes[sym_key] if self.class::ATTRIBUTES.include?(sym_key) + end + + update(new_attrs) + response_attrs = adapter.call(:put, self) + update(response_attrs) + self + rescue StandardError + update(previous) + raise + end + end + + module Deletable + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def delete(id) + new(id: id).delete! + end + end + + def delete! + adapter.call(:delete, self) + end + end + + module Listable + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def list(page: 'all', **filters) + if const_defined?(:FILTERS, false) + unknown = filters.keys - self::FILTERS.keys + raise ArgumentError, "Unknown filter(s): #{unknown.join(', ')}" if unknown.any? + elsif filters.any? + raise ArgumentError, "#{name} does not support filters" + end + + adapter = Pdfmonkey.current_adapter || Pdfmonkey::Adapter.new + fetch_page(adapter, page: page, **filters) + end + + private def fetch_page(adapter, page:, **filters) + params = { 'page[number]' => page } + + if const_defined?(:FILTERS, false) + self::FILTERS.each do |kwarg, api_param| + params[api_param] = filters[kwarg] unless filters[kwarg].nil? + end + end + + body = adapter.call(:get, self, params: params, extract: :collection) + + collection_key = self::COLLECTION + items = body.fetch(collection_key, []).map do |attrs| + resource_attrs = attrs.transform_keys(&:to_sym) + resource_attrs.delete(:adapter) + new(adapter: adapter, **resource_attrs) + end + + page_fetcher = lambda do |page_number| + fetch_page(adapter, page: page_number, **filters) + end + + Collection.new( + items: items, + meta: body.fetch('meta', {}), + page_fetcher: page_fetcher + ) + end + end + end + + def initialize(adapter: Pdfmonkey.current_adapter || Pdfmonkey::Adapter.new, **attrs) + @adapter = adapter + @attributes = self.class.attributes_struct.new + update(attrs) + end + + def ==(other) + other.is_a?(self.class) && !attributes[:id].nil? && attributes[:id] == other.send(:attributes)[:id] + end + alias eql? == + + def hash + [self.class, attributes[:id] || object_id].hash + end + + SENSITIVE_ATTRIBUTES = %i[auth_token].freeze + + def inspect + attrs = self.class::ATTRIBUTES + .filter_map do |key| + next if attributes[key].nil? + + value = SENSITIVE_ATTRIBUTES.include?(key) ? '[FILTERED]' : attributes[key].inspect + "#{key}: #{value}" + end + .join(', ') + + "#<#{self.class} #{attrs}>" + end + + def to_h + attributes.to_h + end + + def to_json(state = nil) + attrs = attributes.to_h.compact + attrs.delete(:errors) + + member = self.class::MEMBER + { member => attrs }.to_json(state) + end + + ATTRIBUTES_STRUCT_MUTEX = Mutex.new + private_constant :ATTRIBUTES_STRUCT_MUTEX + + def self.attributes_struct + return @attributes_struct if @attributes_struct + + ATTRIBUTES_STRUCT_MUTEX.synchronize do + @attributes_struct ||= Struct.new(*self::ATTRIBUTES, keyword_init: true) + end + end + + private attr_reader :adapter, :attributes + + private def update(new_attributes) + valid_attrs = self.class::ATTRIBUTES + new_attributes.each do |key, value| + sym_key = key.to_sym + attributes[sym_key] = value if valid_attrs.include?(sym_key) + end + end + end +end diff --git a/lib/pdfmonkey/snippet.rb b/lib/pdfmonkey/snippet.rb new file mode 100644 index 0000000..02a1698 --- /dev/null +++ b/lib/pdfmonkey/snippet.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Pdfmonkey + class Snippet < Resource + include Fetchable + include Creatable + include Updatable + include Deletable + include Listable + + ATTRIBUTES = %i[ + code + created_at + creator_name + errors + id + identifier + updated_at + updater_name + workspace_id + ].freeze + + COLLECTION = 'snippets' + MEMBER = 'snippet' + + def_delegators :attributes, *ATTRIBUTES + end +end diff --git a/lib/pdfmonkey/template.rb b/lib/pdfmonkey/template.rb new file mode 100644 index 0000000..d14a0c7 --- /dev/null +++ b/lib/pdfmonkey/template.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Pdfmonkey + class Template < Resource + include Fetchable + include Creatable + include Updatable + include Deletable + + ATTRIBUTES = %i[ + app_id + auth_token + body + body_draft + checksum + created_at + deleted_at + edition_mode + errors + id + identifier + output_type + pdf_engine_draft_id + pdf_engine_id + preview_url + sample_data + sample_data_draft + scss_style + scss_style_draft + settings + settings_draft + template_folder_id + ttl + updated_at + ].freeze + + DRAFT_MAPPING = { + body: :body_draft, + scss_style: :scss_style_draft, + settings: :settings_draft, + sample_data: :sample_data_draft, + pdf_engine_id: :pdf_engine_draft_id + }.freeze + + COLLECTION = 'document_templates' + MEMBER = 'document_template' + + def_delegators :attributes, *ATTRIBUTES + + def self.create(**attrs) + super(**remap_to_draft(attrs)) + end + + def self.list_cards(**) + Pdfmonkey::TemplateCard.list(**) + end + + def self.fetch_full(id) + fetch(id) + end + + def update!(**new_attrs) + super(**self.class.send(:remap_to_draft, new_attrs)) + end + + def publish! + DRAFT_MAPPING.each do |published_key, draft_key| + attributes[published_key] = attributes[draft_key] + end + + response_attrs = adapter.call(:put, self) + update(response_attrs) + self + end + + private_class_method def self.remap_to_draft(attrs) + attrs.transform_keys { |k| DRAFT_MAPPING.fetch(k, k) } + end + end +end diff --git a/lib/pdfmonkey/template_card.rb b/lib/pdfmonkey/template_card.rb new file mode 100644 index 0000000..a8badeb --- /dev/null +++ b/lib/pdfmonkey/template_card.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Pdfmonkey + class TemplateCard < Resource + include Listable + + ATTRIBUTES = %i[ + app_id + auth_token + created_at + edition_mode + errors + id + identifier + is_draft + output_type + pdf_engine_deprecated_on + pdf_engine_name + template_folder_id + template_folder_identifier + updated_at + ].freeze + + COLLECTION = 'document_template_cards' + MEMBER = 'document_template_card' + + FILTERS = { + workspace_id: 'q[workspace_id]', + folders: 'q[folders]', + sort: 'sort' + }.freeze + + def_delegators :attributes, *ATTRIBUTES + + def self.list(workspace_id:, **) + super + end + + def to_template + Pdfmonkey::Template.fetch(id) + end + end +end diff --git a/lib/pdfmonkey/template_folder.rb b/lib/pdfmonkey/template_folder.rb new file mode 100644 index 0000000..839e8b9 --- /dev/null +++ b/lib/pdfmonkey/template_folder.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Pdfmonkey + class TemplateFolder < Resource + include Fetchable + include Creatable + include Updatable + include Deletable + include Listable + + ATTRIBUTES = %i[ + app_id + created_at + errors + id + identifier + updated_at + ].freeze + + COLLECTION = 'template_folders' + MEMBER = 'template_folder' + + def_delegators :attributes, *ATTRIBUTES + end +end diff --git a/lib/pdfmonkey/version.rb b/lib/pdfmonkey/version.rb index 7c15e7b..a015946 100644 --- a/lib/pdfmonkey/version.rb +++ b/lib/pdfmonkey/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Pdfmonkey - VERSION = '0.9.0' + VERSION = '1.0.0' end diff --git a/lib/pdfmonkey/webhook.rb b/lib/pdfmonkey/webhook.rb new file mode 100644 index 0000000..8933ffb --- /dev/null +++ b/lib/pdfmonkey/webhook.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Pdfmonkey + class Webhook < Resource + include Creatable + include Deletable + + ATTRIBUTES = %i[ + created_at + custom_channel + document_template_ids + errors + event + id + platform + updated_at + url + workspace_id + ].freeze + + COLLECTION = 'rest_hooks' + MEMBER = 'rest_hook' + + def_delegators :attributes, *ATTRIBUTES + end +end diff --git a/lib/pdfmonkey/workspace.rb b/lib/pdfmonkey/workspace.rb new file mode 100644 index 0000000..be2c300 --- /dev/null +++ b/lib/pdfmonkey/workspace.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Pdfmonkey + class Workspace < Resource + include Fetchable + include Listable + + ATTRIBUTES = %i[ + errors + id + identifier + invite_token + ].freeze + + COLLECTION = 'workspaces' + MEMBER = 'workspace' + + def_delegators :attributes, *ATTRIBUTES + + def self.list_cards(**) + list(**) + end + end +end diff --git a/pdfmonkey.gemspec b/pdfmonkey.gemspec index bd649c1..a1f12e8 100644 --- a/pdfmonkey.gemspec +++ b/pdfmonkey.gemspec @@ -15,17 +15,21 @@ Gem::Specification.new do |spec| spec.homepage = 'https://github.com/pdfmonkeyio/pdfmonkey-ruby' spec.license = 'MIT' - spec.files = Dir.chdir(__dir__) do - `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } - end + spec.metadata = { + 'changelog_uri' => 'https://github.com/pdfmonkeyio/pdfmonkey-ruby/blob/master/CHANGELOG.md', + 'source_code_uri' => 'https://github.com/pdfmonkeyio/pdfmonkey-ruby', + 'rubygems_mfa_required' => 'true' + } + + spec.required_ruby_version = '>= 3.2' + + spec.files = Dir['lib/**/*', 'LICENSE.txt', 'README.md', 'CHANGELOG.md'] spec.bindir = 'exe' spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ['lib'] - spec.add_runtime_dependency 'ostruct', '>= 0.6.0' - spec.add_development_dependency 'bundler', '~> 2.2' spec.add_development_dependency 'rake', '~> 13.0' spec.add_development_dependency 'rspec', '~> 3.8' - spec.add_development_dependency 'rubocop', '~> 1.26' + spec.add_development_dependency 'rubocop', '~> 1.68' end diff --git a/spec/pdfmonkey/adapter_spec.rb b/spec/pdfmonkey/adapter_spec.rb index bfbef62..8ff14b6 100644 --- a/spec/pdfmonkey/adapter_spec.rb +++ b/spec/pdfmonkey/adapter_spec.rb @@ -1,7 +1,19 @@ RSpec.describe Pdfmonkey::Adapter do let(:http) { double('http', request: response).as_null_object } - let(:resource_class) { double('resource class') } - let(:resource) { double('resource', class: resource_class, id: 'test') } + let(:resource_class) do + Class.new(Pdfmonkey::Resource) do + include Pdfmonkey::Resource::Fetchable + include Pdfmonkey::Resource::Creatable + include Pdfmonkey::Resource::Deletable + + const_set(:ATTRIBUTES, %i[id test errors].freeze) + const_set(:COLLECTION, 'resources') + const_set(:MEMBER, 'resource') + + def_delegators :attributes, *self::ATTRIBUTES + end + end + let(:resource) { resource_class.new(id: 'test') } let(:response) { double('response', body: response_body) } let(:response_body) { '{"resource":{"test":"value"}}' } @@ -10,11 +22,42 @@ allow(Net::HTTPSuccess).to receive(:===).with(response).and_return(true) allow(Net::HTTPNoContent).to receive(:===).with(response).and_return(false) + end - allow(resource_class) - .to receive(:const_get).with('COLLECTION').and_return('resources') - allow(resource_class) - .to receive(:const_get).with('MEMBER').and_return('resource') + describe '#inspect' do + it 'does not expose the API key' do + result = described_class.new.inspect + expect(result).not_to include(Pdfmonkey.configuration.private_key) + expect(result).to eq("#") + end + end + + describe '#close' do + it 'finishes an active connection' do + adapter = described_class.new + adapter.instance_variable_set(:@connection, http) + allow(http).to receive(:finish) + + adapter.close + + expect(http).to have_received(:finish) + expect(adapter.instance_variable_get(:@connection)).to be_nil + end + + it 'does not raise when no connection exists' do + adapter = described_class.new + expect { adapter.close }.not_to raise_error + end + + it 'does not raise when finish raises' do + adapter = described_class.new + failing_http = double('http') + allow(failing_http).to receive(:finish).and_raise(IOError, 'closed stream') + adapter.instance_variable_set(:@connection, failing_http) + + expect { adapter.close }.not_to raise_error + expect(adapter.instance_variable_get(:@connection)).to be_nil + end end describe '#call' do @@ -32,6 +75,13 @@ end end + context 'when calling :put for a resource' do + it 'returns the attributes hash returned by the API' do + attributes = subject.call(:put, resource) + expect(attributes).to eq('test' => 'value') + end + end + context 'when calling :delete for a resource' do let(:response_body) { nil } let(:response_class) { Net::HTTPNoContent } @@ -47,14 +97,192 @@ end end + context 'when using query params' do + it 'appends them to the URL' do + subject.call(:get, resource, params: { page: 2, status: 'success' }) + + expect(Net::HTTP) + .to have_received(:new) + .with('api.pdfmonkey.io', 443) + + expect(http) + .to have_received(:request) do |request| + expect(request.path).to include('page=2') + expect(request.path).to include('status=success') + end + end + end + + context 'when using a path override' do + it 'uses the path instead of the collection' do + subject.call(:get, resource, path: 'current_users') + + expect(http) + .to have_received(:request) do |request| + expect(request.path).to eq('/api/v1/current_users') + end + end + end + + context 'when using extract: :collection' do + let(:response_body) { '{"resource_cards":[{"id":"1"}],"meta":{"current_page":1}}' } + + it 'returns the full parsed body' do + result = subject.call(:get, resource, extract: :collection) + expect(result).to eq( + 'resource_cards' => [{ 'id' => '1' }], + 'meta' => { 'current_page' => 1 } + ) + end + end + + context 'when a Class is passed as resource' do + let(:response_body) { '{"resources":[{"id":"1"}],"meta":{"current_page":1}}' } + + it 'builds the URL from the Class constants' do + subject.call(:get, resource_class, extract: :collection) + + expect(http) + .to have_received(:request) do |request| + expect(request.path).to eq('/api/v1/resources') + end + end + end + + context 'when a success response has invalid JSON' do + let(:response_body) { 'not json at all' } + + before { allow(response).to receive(:code).and_return('200') } + + it 'raises an ApiError' do + expect { subject.call(:get, resource) } + .to raise_error(Pdfmonkey::ApiError) { |e| + expect(e.errors).to eq(['not json at all']) + expect(e.status_code).to eq(200) + } + end + end + context 'when the request fails at the HTTP level' do before do allow(http).to receive(:request).and_raise(SocketError, 'test failed') end - it 'returns a hash containing the exception message' do - attributes = subject.call(:get, resource) - expect(attributes).to eq(status: 'error', errors: ['test failed']) + it 'raises a ConnectionError' do + expect { subject.call(:get, resource) } + .to raise_error(Pdfmonkey::ConnectionError, 'test failed') + end + end + + context 'when the connection is stale' do + let(:stale_http) { double('stale_http', started?: false) } + let(:fresh_http) { double('fresh_http', request: response).as_null_object } + + before do + allow(Net::HTTP).to receive(:new).and_return(fresh_http) + allow(stale_http).to receive(:finish) + end + + it 'finishes the stale connection and creates a new one' do + adapter = described_class.new + + # First call sets up the stale connection + adapter.instance_variable_set(:@connection, stale_http) + + adapter.call(:get, resource) + + expect(stale_http).to have_received(:finish) + expect(fresh_http).to have_received(:request) + end + end + + context 'when using an unsupported HTTP method' do + it 'raises an ArgumentError' do + expect { subject.call(:patch, resource) } + .to raise_error(ArgumentError, /Unsupported HTTP method: :patch/) + end + end + + context 'when using an unknown extract mode' do + it 'raises an ArgumentError' do + expect { subject.call(:get, resource, extract: :unknown) } + .to raise_error(ArgumentError, /Unknown extract mode: :unknown/) + end + end + + context 'when the response is missing the expected member key' do + let(:response_body) { '{"wrong_key":{"test":"value"}}' } + + before { allow(response).to receive(:code).and_return('200') } + + it 'raises an ApiError' do + expect { subject.call(:get, resource) } + .to raise_error(Pdfmonkey::ApiError, /Missing 'resource' key in response/) + end + end + + context 'when no API key is configured' do + before do + Pdfmonkey.configuration.private_key = nil + end + + it 'raises a Pdfmonkey::Error' do + expect { subject.call(:get, resource) } + .to raise_error(Pdfmonkey::Error, /No API key configured/) + end + end + + context 'when API key is an empty string' do + before do + Pdfmonkey.configuration.private_key = '' + end + + it 'raises a Pdfmonkey::Error' do + expect { subject.call(:get, resource) } + .to raise_error(Pdfmonkey::Error, /No API key configured/) + end + end + + context 'when API key is whitespace only' do + before do + Pdfmonkey.configuration.private_key = ' ' + end + + it 'raises a Pdfmonkey::Error' do + expect { subject.call(:get, resource) } + .to raise_error(Pdfmonkey::Error, /No API key configured/) + end + end + + context 'when the error response has a nil body' do + let(:nil_body_response) { double('response', body: nil, code: '500') } + + before do + allow(http).to receive(:request).and_return(nil_body_response) + allow(Net::HTTPSuccess).to receive(:===).with(nil_body_response).and_return(false) + allow(Net::HTTPNoContent).to receive(:===).with(nil_body_response).and_return(false) + end + + it 'raises an ApiError without crashing' do + expect { subject.call(:get, resource) } + .to raise_error(Pdfmonkey::ApiError) { |e| + expect(e.status_code).to eq(500) + } + end + end + + context 'when a success response has a nil body' do + let(:nil_body_response) { double('response', body: nil, code: '200') } + + before do + allow(http).to receive(:request).and_return(nil_body_response) + allow(Net::HTTPSuccess).to receive(:===).with(nil_body_response).and_return(true) + allow(Net::HTTPNoContent).to receive(:===).with(nil_body_response).and_return(false) + end + + it 'raises an ApiError for invalid JSON' do + expect { subject.call(:get, resource) } + .to raise_error(Pdfmonkey::ApiError, /Invalid JSON/) end end @@ -62,32 +290,89 @@ before do allow(Net::HTTPSuccess).to receive(:===).with(response).and_return(false) allow(Net::HTTPNoContent).to receive(:===).with(response).and_return(false) + allow(response).to receive(:code).and_return('422') end context 'with an "error" response' do let(:response_body) { '{ "error": "test failed" }' } - it 'returns a hash containing the error message' do - attributes = subject.call(:get, resource) - expect(attributes).to eq(status: 'error', errors: ['test failed']) + it 'raises an ApiError with the error message' do + expect { subject.call(:get, resource) } + .to raise_error(Pdfmonkey::ApiError) { |e| + expect(e.errors).to eq(['test failed']) + expect(e.status_code).to eq(422) + } end end context 'with an "errors" response' do let(:response_body) { '{ "errors": [{ "detail": "test failed" }]}' } - it 'returns a hash containing the error message' do - attributes = subject.call(:get, resource) - expect(attributes).to eq(status: 'error', errors: ['test failed']) + it 'raises an ApiError with the error message' do + expect { subject.call(:get, resource) } + .to raise_error(Pdfmonkey::ApiError) { |e| + expect(e.errors).to eq(['test failed']) + } + end + end + + context 'with an "errors" response of plain strings' do + let(:response_body) { '{ "errors": ["Something went wrong", "Another error"] }' } + + it 'raises an ApiError preserving the string messages' do + expect { subject.call(:get, resource) } + .to raise_error(Pdfmonkey::ApiError) { |e| + expect(e.errors).to eq(['Something went wrong', 'Another error']) + expect(e.message).to eq('Something went wrong, Another error') + } + end + end + + context 'with an "errors" response lacking a "detail" key' do + let(:response_body) { '{ "errors": [{ "code": "not_found", "title": "Not Found" }] }' } + + it 'raises an ApiError with the hash JSON as fallback' do + expect { subject.call(:get, resource) } + .to raise_error(Pdfmonkey::ApiError) { |e| + expect(e.errors).to eq(['{"code":"not_found","title":"Not Found"}']) + } + end + end + + context 'with an "errors" response containing a "message" key' do + let(:response_body) { '{ "errors": [{ "message": "Rate limited" }] }' } + + it 'raises an ApiError using the message key' do + expect { subject.call(:get, resource) } + .to raise_error(Pdfmonkey::ApiError) { |e| + expect(e.errors).to eq(['Rate limited']) + } end end context 'with an "errors" response for validation errors' do let(:response_body) { '{"errors":{"status":["Quota error message"]}}' } - it 'returns a hash containing the error messages' do - attributes = subject.call(:post, resource) - expect(attributes).to eq(status: 'error', errors: { 'status' => ['Quota error message'] }) + it 'raises an ApiError with the error messages' do + expect { subject.call(:post, resource) } + .to raise_error(Pdfmonkey::ApiError) { |e| + expect(e.message).to eq('status: Quota error message') + expect(e.errors).to eq('status' => ['Quota error message']) + } + end + end + + context 'with a non-JSON response body' do + let(:response_body) { 'Internal Server Error' } + + before { allow(response).to receive(:code).and_return('500') } + + it 'raises an ApiError with the raw body' do + expect { subject.call(:get, resource) } + .to raise_error(Pdfmonkey::ApiError) { |e| + expect(e.errors).to eq(['Internal Server Error']) + expect(e.status_code).to eq(500) + } end end end diff --git a/spec/pdfmonkey/collection_spec.rb b/spec/pdfmonkey/collection_spec.rb new file mode 100644 index 0000000..f7a904b --- /dev/null +++ b/spec/pdfmonkey/collection_spec.rb @@ -0,0 +1,72 @@ +RSpec.describe Pdfmonkey::Collection do + let(:meta) {{ + 'current_page' => 2, + 'total_pages' => 5, + 'next_page' => 3, + 'prev_page' => 1 + }} + let(:items) { [double('item1'), double('item2')] } + let(:page_fetcher) { double('page_fetcher') } + + subject do + described_class.new(items: items, meta: meta, page_fetcher: page_fetcher) + end + + describe '#each' do + it 'iterates over items' do + collected = [] + subject.each { |item| collected << item } + expect(collected).to eq(items) + end + + it 'is Enumerable' do + expect(subject.count).to eq(2) + end + end + + describe '#current_page' do + it 'returns the current page number' do + expect(subject.current_page).to eq(2) + end + end + + describe '#total_pages' do + it 'returns the total number of pages' do + expect(subject.total_pages).to eq(5) + end + end + + describe '#next_page' do + it 'fetches the next page' do + next_collection = double('next_collection') + allow(page_fetcher).to receive(:call).with(3).and_return(next_collection) + + expect(subject.next_page).to eq(next_collection) + end + + context 'when there is no next page' do + let(:meta) { super().merge('next_page' => nil) } + + it 'returns nil' do + expect(subject.next_page).to be_nil + end + end + end + + describe '#prev_page' do + it 'fetches the previous page' do + prev_collection = double('prev_collection') + allow(page_fetcher).to receive(:call).with(1).and_return(prev_collection) + + expect(subject.prev_page).to eq(prev_collection) + end + + context 'when there is no previous page' do + let(:meta) { super().merge('prev_page' => nil) } + + it 'returns nil' do + expect(subject.prev_page).to be_nil + end + end + end +end diff --git a/spec/pdfmonkey/configuration_spec.rb b/spec/pdfmonkey/configuration_spec.rb index 30bab95..ff079a5 100644 --- a/spec/pdfmonkey/configuration_spec.rb +++ b/spec/pdfmonkey/configuration_spec.rb @@ -22,6 +22,17 @@ end end + describe 'Configuration#inspect' do + it 'does not expose the private key' do + config = Pdfmonkey::Configuration.new + config.private_key = 'sk_live_secret_key' + result = config.inspect + expect(result).not_to include('sk_live_secret_key') + expect(result).to include('host=') + expect(result).to include('namespace=') + end + end + describe '.configure' do it 'yields the Configuration to the provided block' do expect { |b| diff --git a/spec/pdfmonkey/current_user_spec.rb b/spec/pdfmonkey/current_user_spec.rb new file mode 100644 index 0000000..5574a52 --- /dev/null +++ b/spec/pdfmonkey/current_user_spec.rb @@ -0,0 +1,63 @@ +RSpec.describe Pdfmonkey::CurrentUser do + let(:adapter) { double('adapter') } + let(:user_attrs) {{ + 'id' => 'user-1', + 'email' => 'test@example.com', + 'desired_name' => 'Test User', + 'current_plan' => 'pro', + 'available_documents' => 1000 + }} + + describe '.fetch' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:get, described_class, path: 'current_user') + .and_return(user_attrs) + end + + it 'returns a CurrentUser' do + result = described_class.fetch + expect(result).to be_a(described_class) + expect(result.email).to eq('test@example.com') + expect(result.current_plan).to eq('pro') + end + + it 'uses the singular current_user path' do + described_class.fetch + + expect(adapter).to have_received(:call) + .with(:get, described_class, path: 'current_user') + end + end + + describe 'undefined methods' do + it 'does not respond to .create' do + expect { described_class.create }.to raise_error(NoMethodError) + end + + it 'does not respond to .delete' do + expect { described_class.delete('id') }.to raise_error(NoMethodError) + end + + it 'does not respond to #save' do + user = described_class.new(adapter: adapter) + expect { user.save }.to raise_error(NoMethodError) + end + + it 'does not respond to #update!' do + user = described_class.new(adapter: adapter) + expect { user.update!(email: 'x') }.to raise_error(NoMethodError) + end + + it 'does not respond to #delete!' do + user = described_class.new(adapter: adapter) + expect { user.delete! }.to raise_error(NoMethodError) + end + + it 'does not respond to #reload!' do + user = described_class.new(adapter: adapter) + expect { user.reload! }.to raise_error(NoMethodError) + end + end +end diff --git a/spec/pdfmonkey/document_card_spec.rb b/spec/pdfmonkey/document_card_spec.rb new file mode 100644 index 0000000..a5fdcfb --- /dev/null +++ b/spec/pdfmonkey/document_card_spec.rb @@ -0,0 +1,207 @@ +RSpec.describe Pdfmonkey::DocumentCard do + let(:adapter) { double('adapter') } + let(:card_attrs) {{ + 'id' => 'card-1', + 'app_id' => 'app-1', + 'document_template_id' => 'tpl-1', + 'status' => 'success', + 'filename' => 'test.pdf' + }} + + describe '.list' do + let(:api_response) {{ + 'document_cards' => [card_attrs], + 'meta' => { + 'current_page' => 1, + 'total_pages' => 3, + 'next_page' => 2, + 'prev_page' => nil + } + }} + + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:get, anything, params: anything, extract: :collection) + .and_return(api_response) + end + + it 'returns a Collection' do + result = described_class.list + expect(result).to be_a(Pdfmonkey::Collection) + end + + it 'contains DocumentCard items' do + result = described_class.list + expect(result.first).to be_a(described_class) + expect(result.first.id).to eq('card-1') + end + + it 'passes page parameter' do + described_class.list(page: 2) + + expect(adapter).to have_received(:call).with( + :get, anything, + params: hash_including('page[number]' => 2), + extract: :collection + ) + end + + it 'passes filter parameters' do + described_class.list( + document_template_id: 'tpl-1', + status: 'success', + workspace_id: 'ws-1' + ) + + expect(adapter).to have_received(:call).with( + :get, anything, + params: hash_including( + 'q[document_template_id]' => 'tpl-1', + 'q[status]' => 'success', + 'q[workspace_id]' => 'ws-1' + ), + extract: :collection + ) + end + + it 'has pagination metadata' do + result = described_class.list + expect(result.current_page).to eq(1) + expect(result.total_pages).to eq(3) + expect(result.next_page_number).to eq(2) + expect(result.prev_page_number).to be_nil + end + end + + describe 'pagination chaining' do + let(:page1_response) {{ + 'document_cards' => [card_attrs], + 'meta' => { + 'current_page' => 1, + 'total_pages' => 2, + 'next_page' => 2, + 'prev_page' => nil + } + }} + let(:page2_response) {{ + 'document_cards' => [{ 'id' => 'card-2', 'status' => 'pending' }], + 'meta' => { + 'current_page' => 2, + 'total_pages' => 2, + 'next_page' => nil, + 'prev_page' => 1 + } + }} + + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:get, anything, params: hash_including('page[number]' => 1), extract: :collection) + .and_return(page1_response) + allow(adapter).to receive(:call) + .with(:get, anything, params: hash_including('page[number]' => 2), extract: :collection) + .and_return(page2_response) + end + + it 'fetches the next page via next_page' do + page1 = described_class.list(page: 1) + page2 = page1.next_page + + expect(page2).to be_a(Pdfmonkey::Collection) + expect(page2.first.id).to eq('card-2') + expect(page2.current_page).to eq(2) + expect(page2.next_page).to be_nil + end + + it 'fetches the previous page via prev_page' do + page2 = described_class.list(page: 2) + + allow(adapter).to receive(:call) + .with(:get, anything, params: hash_including('page[number]' => 2), extract: :collection) + .and_return(page2_response) + + page2 = described_class.list(page: 2) + page1 = page2.prev_page + + expect(page1).to be_a(Pdfmonkey::Collection) + expect(page1.first.id).to eq('card-1') + expect(page1.current_page).to eq(1) + end + end + + describe 'undefined methods' do + it 'does not respond to .create' do + expect { described_class.create }.to raise_error(NoMethodError) + end + + it 'does not respond to .delete' do + expect { described_class.delete('id') }.to raise_error(NoMethodError) + end + + it 'does not respond to #save' do + card = described_class.new(adapter: adapter, id: 'card-1') + expect { card.save }.to raise_error(NoMethodError) + end + + it 'does not respond to #update!' do + card = described_class.new(adapter: adapter, id: 'card-1') + expect { card.update!(status: 'x') }.to raise_error(NoMethodError) + end + + it 'does not respond to #delete!' do + card = described_class.new(adapter: adapter, id: 'card-1') + expect { card.delete! }.to raise_error(NoMethodError) + end + end + + describe '.fetch' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:get, anything) + .and_return(card_attrs) + end + + it 'returns a DocumentCard' do + result = described_class.fetch('card-1') + expect(result).to be_a(described_class) + expect(result.id).to eq('card-1') + end + end + + describe '#reload!' do + subject { described_class.new(adapter: adapter, id: 'card-1', status: 'pending') } + + before do + allow(adapter).to receive(:call) + .with(:get, subject) + .and_return(card_attrs.merge('status' => 'success')) + end + + it 'reloads the document card' do + subject.reload! + expect(subject.status).to eq('success') + end + + it 'returns self' do + expect(subject.reload!).to be subject + end + end + + describe '#to_document' do + let(:document) { double('document') } + + before do + allow(Pdfmonkey::Document).to receive(:fetch) + .with('card-1') + .and_return(document) + end + + subject { described_class.new(adapter: adapter, id: 'card-1') } + + it 'fetches the full Document' do + expect(subject.to_document).to eq(document) + end + end +end diff --git a/spec/pdfmonkey/document_spec.rb b/spec/pdfmonkey/document_spec.rb index 7b4e2c6..2a31940 100644 --- a/spec/pdfmonkey/document_spec.rb +++ b/spec/pdfmonkey/document_spec.rb @@ -1,9 +1,7 @@ -require 'securerandom' - RSpec.describe Pdfmonkey::Document do subject { described_class.new(adapter: adapter, **attributes) } - let(:adapter) { double } + let(:adapter) { double('adapter') } let(:attributes) {{ app_id: 'app-id value', checksum: 'checksum value', @@ -21,8 +19,7 @@ }} describe '.delete' do - let(:adapter) { spy } - let(:document) { described_class.delete('xxx') } + let(:adapter) { double('adapter') } before do allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) @@ -39,13 +36,14 @@ end describe '.fetch' do + let(:adapter) { double('adapter') } let(:document) { described_class.fetch('xxx') } before do - allow_any_instance_of(Pdfmonkey::Adapter) - .to receive(:call) + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) .with(:get, an_object_having_attributes(id: 'xxx')) - .and_return(attributes) + .and_return(attributes.transform_keys(&:to_s)) end it 'fetches the document' do @@ -54,19 +52,22 @@ end describe '.generate!' do - let(:adapter) { double } - let(:document) { described_class.generate!('test-template-id', test: 'test value') } + let(:adapter) { double('adapter') } + let(:document) do + described_class.generate!(document_template_id: 'test-template-id', payload: { test: 'test value' }) + end let(:statuses) {[ - { status: 'pending' }, - { status: 'generating' }, - { status: 'generating' }, - { status: 'success' } + { 'status' => 'pending' }, + { 'status' => 'generating' }, + { 'status' => 'generating' }, + { 'status' => 'success' } ]} before do allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) - allow(adapter).to receive(:call).with(:post, anything).and_return(id: 'new id') + allow(adapter).to receive(:call).with(:post, anything).and_return('id' => 'new id') allow(adapter).to receive(:call).with(:get, anything).and_return(*statuses) + allow_any_instance_of(described_class).to receive(:sleep) end it 'creates a new document and returns it' do @@ -76,9 +77,9 @@ context 'when the meta parameter is provided' do let(:document) do described_class.generate!( - 'test-template-id', - { payload_key: 'paylad value' }, - { meta_key: 'meta value' }) + document_template_id: 'test-template-id', + payload: { payload_key: 'paylad value' }, + meta: { meta_key: 'meta value' }) end it 'adds it to the document' do @@ -94,44 +95,164 @@ context 'when the document eventually gets a failure status' do let(:statuses) {[ - { status: 'pending' }, - { status: 'generating' }, - { status: 'generating' }, - { status: 'failure' } + { 'status' => 'pending' }, + { 'status' => 'generating' }, + { 'status' => 'failure', 'failure_cause' => 'Template error' } + ]} + + it 'raises a GenerationError with the failure cause' do + expect { document }.to raise_error( + Pdfmonkey::GenerationError, /Document generation failed: Template error/ + ) + end + + it 'includes the document on the error' do + described_class.generate!(document_template_id: 'test-template-id', payload: { test: 'test value' }) + rescue Pdfmonkey::GenerationError => e + expect(e.document).to be_a(described_class) + expect(e.document.status).to eq('failure') + end + end + + context 'when the document eventually gets an error status' do + let(:statuses) {[ + { 'status' => 'pending' }, + { 'status' => 'error' } ]} - it 'waits for the failure status before returning the document' do - expect(document.status).to eq('failure') + it 'raises a GenerationError' do + expect { document }.to raise_error( + Pdfmonkey::GenerationError, /Document generation failed/ + ) + end + + it 'includes the document on the error' do + described_class.generate!(document_template_id: 'test-template-id', payload: { test: 'test value' }) + rescue Pdfmonkey::GenerationError => e + expect(e.document).to be_a(described_class) + expect(e.document.status).to eq('error') + end + end + + context 'without document_template_id' do + it 'raises ArgumentError' do + expect { + described_class.generate!(payload: { test: 'value' }) + }.to raise_error(ArgumentError, /document_template_id is required/) + end + end + + context 'with blank document_template_id' do + it 'raises ArgumentError' do + expect { + described_class.generate!(document_template_id: ' ', payload: { test: 'value' }) + }.to raise_error(ArgumentError, /document_template_id is required/) + end + end + + context 'with positional arguments (deprecated)' do + it 'emits a deprecation warning' do + expect { + described_class.generate!('test-template-id', { test: 'test value' }) + }.to output(/Positional arguments for Document.generate! are deprecated/).to_stderr end end end describe '.generate' do - let(:adapter) { ->(_meth, document) { document.attributes.to_h.merge(id: 'new id') } } - let(:document) { described_class.generate('test-template-id', test: 'test value') } + let(:adapter) { double('adapter') } + let(:document) do + described_class.generate(document_template_id: 'test-template-id', payload: { test: 'test value' }) + end - before { allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) } + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:post, anything) + .and_return(attributes.transform_keys(&:to_s).merge('id' => 'new id')) + end it 'creates a new document and returns it' do expect(document).to be_a(described_class) end it 'sets the document attributes to the one returned by the API' do - expect(document).to have_attributes(id: 'new id', payload: '{"test":"test value"}') + expect(document).to have_attributes(id: 'new id', payload: 'payload value') end context 'when the meta parameter is provided' do let(:document) do described_class.generate( - 'test-template-id', - { payload_key: 'paylad value' }, - { meta_key: 'meta value' }) + document_template_id: 'test-template-id', + payload: { payload_key: 'paylad value' }, + meta: { meta_key: 'meta value' }) + end + + before do + allow(adapter).to receive(:call) + .with(:post, anything) + .and_return(attributes.transform_keys(&:to_s).merge('id' => 'new id', 'meta' => '{"meta_key":"meta value"}')) end it 'adds it to the document' do expect(document.meta).to eq('{"meta_key":"meta value"}') end end + + context 'without document_template_id' do + it 'raises ArgumentError' do + expect { + described_class.generate(payload: { test: 'value' }) + }.to raise_error(ArgumentError, /document_template_id is required/) + end + end + + context 'with positional arguments (deprecated)' do + it 'emits a deprecation warning' do + expect { + described_class.generate('test-template-id', { test: 'test value' }) + }.to output(/Positional arguments for Document.generate are deprecated/).to_stderr + end + end + end + + describe '.create_draft' do + let(:adapter) { double('adapter') } + let(:document) do + described_class.create_draft(document_template_id: 'test-template-id', payload: { test: 'test value' }) + end + + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:post, anything) + .and_return(attributes.transform_keys(&:to_s).merge('id' => 'draft-id', 'status' => 'draft')) + end + + it 'creates a draft document' do + expect(document).to be_a(described_class) + expect(document.status).to eq('draft') + end + + it 'sets the document id from the API response' do + expect(document.id).to eq('draft-id') + end + + context 'without document_template_id' do + it 'raises ArgumentError' do + expect { + described_class.create_draft(payload: { test: 'value' }) + }.to raise_error(ArgumentError, /document_template_id is required/) + end + end + + context 'with positional arguments (deprecated)' do + it 'emits a deprecation warning' do + expect { + described_class.create_draft('test-template-id', { test: 'test value' }) + }.to output(/Positional arguments for Document.create_draft are deprecated/).to_stderr + end + end end describe '#delete!' do @@ -146,6 +267,71 @@ end end + describe '#generate' do + let(:attributes) {{ id: 'draft-id', status: 'draft', document_template_id: 'tpl-id' }} + + before do + allow(adapter).to receive(:call) + .with(:put, subject) + .and_return('id' => 'draft-id', 'status' => 'pending') + end + + it 'updates the status to pending' do + subject.generate + expect(subject.status).to eq('pending') + end + + it 'returns self' do + expect(subject.generate).to be subject + end + end + + describe '#generate!' do + let(:attributes) {{ id: 'draft-id', status: 'draft', document_template_id: 'tpl-id' }} + let(:statuses) {[ + { 'status' => 'pending' }, + { 'status' => 'generating' }, + { 'status' => 'success' } + ]} + + before do + allow(adapter).to receive(:call) + .with(:put, subject) + .and_return('id' => 'draft-id', 'status' => 'pending') + allow(adapter).to receive(:call) + .with(:get, subject) + .and_return(*statuses) + allow(subject).to receive(:sleep) + end + + it 'polls until the document is done' do + subject.generate! + expect(subject.status).to eq('success') + end + + it 'returns self' do + expect(subject.generate!).to be subject + end + + it 'sleeps between polls' do + subject.generate! + expect(subject).to have_received(:sleep).with(0.5).exactly(3).times + end + + context 'when generation fails' do + let(:statuses) {[ + { 'status' => 'pending' }, + { 'status' => 'failure', 'failure_cause' => 'Template error' } + ]} + + it 'raises a GenerationError' do + expect { subject.generate! }.to raise_error( + Pdfmonkey::GenerationError, /Document generation failed: Template error/ + ) + end + end + end + describe '#done?' do shared_examples 'detecting "done" states' do |state, expected_result| let(:attributes) {{ status: state }} @@ -162,7 +348,7 @@ end describe '#reload!' do - let(:new_attributes) { attributes.merge(status: 'new value') } + let(:new_attributes) { attributes.transform_keys(&:to_s).merge('status' => 'new value') } before { allow(adapter).to receive(:call).with(:get, subject).and_return(new_attributes) } @@ -186,9 +372,7 @@ 'document_template_id' => 'tpl-id value', 'download_url' => 'download-url value', 'failure_cause' => 'failure-cause value', - 'filename' => nil, 'generation_logs' => [], - 'id' => nil, 'meta' => 'meta value', 'payload' => 'payload value', 'preview_url' => 'preview-url value', @@ -199,6 +383,75 @@ end end + describe '.generate with string payload' do + let(:adapter) { double('adapter') } + + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call).with(:post, anything).and_return('id' => 'new id') + end + + it 'does not double-encode a string payload' do + described_class.generate( + document_template_id: 'tpl', + payload: '{"already":"json"}' + ) + + expect(adapter).to have_received(:call).with(:post, an_object_having_attributes( + payload: '{"already":"json"}' + )) + end + + it 'passes nil through without encoding' do + described_class.generate( + document_template_id: 'tpl', + payload: nil + ) + + expect(adapter).to have_received(:call).with(:post, an_object_having_attributes( + payload: nil + )) + end + end + + describe '.list_cards' do + it 'delegates to DocumentCard.list' do + expect(Pdfmonkey::DocumentCard).to receive(:list).with(page: 2).and_return(:result) + expect(described_class.list_cards(page: 2)).to eq(:result) + end + end + + describe '.fetch_card' do + it 'delegates to DocumentCard.fetch' do + expect(Pdfmonkey::DocumentCard).to receive(:fetch).with('doc-id').and_return(:card) + expect(described_class.fetch_card('doc-id')).to eq(:card) + end + end + + describe '.fetch_full' do + it 'delegates to .fetch' do + expect(described_class).to receive(:fetch).with('doc-id').and_return(:document) + expect(described_class.fetch_full('doc-id')).to eq(:document) + end + end + + describe '#update!' do + before do + allow(adapter).to receive(:call) + .with(:put, subject) + .and_return('status' => 'pending', 'app_id' => 'app-id value') + end + + it 'sends a PUT request and updates attributes' do + subject.update!(status: 'pending') + expect(subject.status).to eq('pending') + end + + it 'returns self' do + expect(subject.update!(status: 'pending')).to be subject + end + end + shared_examples 'providing reader method' do |attr, expected_value| specify "for #{attr}" do expect(subject.public_send(attr)).to eq(expected_value) diff --git a/spec/pdfmonkey/engine_spec.rb b/spec/pdfmonkey/engine_spec.rb new file mode 100644 index 0000000..fdc8c91 --- /dev/null +++ b/spec/pdfmonkey/engine_spec.rb @@ -0,0 +1,76 @@ +RSpec.describe Pdfmonkey::Engine do + let(:adapter) { double('adapter') } + + describe '.list' do + let(:api_response) {{ + 'pdf_engines' => [ + { 'id' => 'eng-1', 'name' => 'Chromium', 'deprecated_on' => nil }, + { 'id' => 'eng-2', 'name' => 'Legacy', 'deprecated_on' => '2024-01-01' } + ], + 'meta' => { + 'current_page' => 1, + 'total_pages' => 1, + 'next_page' => nil, + 'prev_page' => nil + } + }} + + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:get, anything, params: anything, extract: :collection) + .and_return(api_response) + end + + it 'returns a Collection' do + result = described_class.list + expect(result).to be_a(Pdfmonkey::Collection) + end + + it 'contains Engine items' do + result = described_class.list + expect(result.count).to eq(2) + expect(result.first).to be_a(described_class) + expect(result.first.name).to eq('Chromium') + end + + it 'has a working page_fetcher' do + result = described_class.list + expect(result.next_page).to be_nil + end + end + + describe 'unavailable methods' do + it 'does not respond to .create' do + expect { described_class.create }.to raise_error(NoMethodError) + end + + it 'does not respond to .delete' do + expect { described_class.delete('id') }.to raise_error(NoMethodError) + end + + it 'does not respond to .fetch' do + expect { described_class.fetch('id') }.to raise_error(NoMethodError) + end + + it 'does not respond to #save' do + engine = described_class.new(adapter: adapter, id: 'eng-1') + expect { engine.save }.to raise_error(NoMethodError) + end + + it 'does not respond to #update!' do + engine = described_class.new(adapter: adapter, id: 'eng-1') + expect { engine.update!(name: 'x') }.to raise_error(NoMethodError) + end + + it 'does not respond to #delete!' do + engine = described_class.new(adapter: adapter, id: 'eng-1') + expect { engine.delete! }.to raise_error(NoMethodError) + end + + it 'does not respond to #reload!' do + engine = described_class.new(adapter: adapter, id: 'eng-1') + expect { engine.reload! }.to raise_error(NoMethodError) + end + end +end diff --git a/spec/pdfmonkey/resource_spec.rb b/spec/pdfmonkey/resource_spec.rb new file mode 100644 index 0000000..5f480f2 --- /dev/null +++ b/spec/pdfmonkey/resource_spec.rb @@ -0,0 +1,384 @@ +RSpec.describe Pdfmonkey::Resource do + let(:test_class) do + Class.new(Pdfmonkey::Resource) do + include Pdfmonkey::Resource::Fetchable + include Pdfmonkey::Resource::Creatable + include Pdfmonkey::Resource::Updatable + include Pdfmonkey::Resource::Deletable + + const_set(:ATTRIBUTES, %i[id name status errors].freeze) + const_set(:COLLECTION, 'test_resources') + const_set(:MEMBER, 'test_resource') + + def_delegators :attributes, *self::ATTRIBUTES + end + end + + let(:adapter) { double('adapter') } + let(:resource) { test_class.new(adapter: adapter, id: '123', name: 'Test') } + + describe '.fetch' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:get, anything) + .and_return('id' => '123', 'name' => 'Fetched') + end + + it 'returns a resource with fetched attributes' do + result = test_class.fetch('123') + expect(result.name).to eq('Fetched') + end + end + + describe '.delete' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:delete, anything) + .and_return(true) + end + + it 'deletes the resource' do + expect(test_class.delete('123')).to be true + end + end + + describe '.create' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:post, anything) + .and_return('id' => '456', 'name' => 'Created') + end + + it 'creates and returns a resource' do + result = test_class.create(name: 'Created') + expect(result).to be_a(test_class) + expect(result.id).to eq('456') + expect(result.name).to eq('Created') + end + end + + describe '#reload!' do + before do + allow(adapter).to receive(:call) + .with(:get, resource) + .and_return('id' => '123', 'name' => 'Reloaded') + end + + it 'updates attributes from the API' do + resource.reload! + expect(resource.name).to eq('Reloaded') + end + + it 'returns self' do + expect(resource.reload!).to be resource + end + end + + describe '#save' do + context 'when the resource has an id and includes Updatable' do + before do + allow(adapter).to receive(:call) + .with(:put, resource) + .and_return('id' => '123', 'name' => 'Saved') + end + + it 'sends a PUT and updates attributes' do + resource.save + expect(resource.id).to eq('123') + expect(resource.name).to eq('Saved') + end + + it 'returns self' do + expect(resource.save).to be resource + end + end + + context 'when the resource has no id' do + let(:new_resource) { test_class.new(adapter: adapter, name: 'New') } + + before do + allow(adapter).to receive(:call) + .with(:post, new_resource) + .and_return('id' => '789', 'name' => 'Created') + end + + it 'sends a POST' do + new_resource.save + expect(new_resource.id).to eq('789') + end + end + + context 'when the resource is Creatable-only (no Updatable)' do + let(:creatable_only_class) do + Class.new(Pdfmonkey::Resource) do + include Pdfmonkey::Resource::Creatable + + const_set(:ATTRIBUTES, %i[id name errors].freeze) + const_set(:COLLECTION, 'creatables') + const_set(:MEMBER, 'creatable') + + def_delegators :attributes, *self::ATTRIBUTES + end + end + + it 'raises when id is present' do + instance = creatable_only_class.new(adapter: adapter, id: '123', name: 'Test') + expect { instance.save }.to raise_error(Pdfmonkey::Error, /updates are not supported/) + end + + it 'sends a POST when id is nil' do + instance = creatable_only_class.new(adapter: adapter, name: 'Test') + allow(adapter).to receive(:call) + .with(:post, instance) + .and_return('id' => '456', 'name' => 'Test') + + instance.save + expect(adapter).to have_received(:call).with(:post, instance) + end + end + end + + describe '#update!' do + before do + allow(adapter).to receive(:call) + .with(:put, resource) + .and_return('id' => '123', 'name' => 'Updated') + end + + it 'sends a PUT and updates attributes' do + resource.update!(name: 'Updated') + expect(resource.name).to eq('Updated') + end + + it 'returns self' do + expect(resource.update!(name: 'Updated')).to be resource + end + + context 'when the API call fails' do + before do + allow(adapter).to receive(:call) + .with(:put, resource) + .and_raise(Pdfmonkey::ApiError.new('fail', errors: ['fail'], status_code: 422)) + end + + it 'rolls back local changes' do + expect { resource.update!(name: 'New Name') }.to raise_error(Pdfmonkey::ApiError) + expect(resource.name).to eq('Test') + end + end + + context 'when an unexpected error occurs' do + before do + allow(adapter).to receive(:call) + .with(:put, resource) + .and_raise(RuntimeError, 'unexpected') + end + + it 'rolls back local changes' do + expect { resource.update!(name: 'New Name') }.to raise_error(RuntimeError) + expect(resource.name).to eq('Test') + end + end + end + + describe '#delete!' do + it 'sends a delete request' do + expect(adapter).to receive(:call).with(:delete, resource).and_return(true) + expect(resource.delete!).to be true + end + end + + describe '#inspect' do + it 'shows the class name and non-nil attributes' do + result = resource.inspect + expect(result).to include('id: "123"') + expect(result).to include('name: "Test"') + expect(result).not_to include('status:') + end + + it 'does not expose the adapter' do + expect(resource.inspect).not_to include('adapter') + end + + it 'filters sensitive attributes' do + sensitive_class = Class.new(Pdfmonkey::Resource) do + const_set(:ATTRIBUTES, %i[id auth_token email errors].freeze) + const_set(:COLLECTION, 'sensitives') + const_set(:MEMBER, 'sensitive') + + def_delegators :attributes, *self::ATTRIBUTES + end + + r = sensitive_class.new(adapter: adapter, id: '1', auth_token: 'sk_live_secret', email: 'user@example.com') + result = r.inspect + expect(result).to include('auth_token: [FILTERED]') + expect(result).not_to include('sk_live_secret') + expect(result).to include('email: "user@example.com"') + end + + it 'includes attributes set to false' do + bool_class = Class.new(Pdfmonkey::Resource) do + const_set(:ATTRIBUTES, %i[id active errors].freeze) + const_set(:COLLECTION, 'bools') + const_set(:MEMBER, 'bool') + + def_delegators :attributes, *self::ATTRIBUTES + end + + r = bool_class.new(adapter: adapter, id: '1', active: false) + expect(r.inspect).to include('active: false') + end + end + + describe '#to_json' do + it 'serializes under the MEMBER key' do + data = JSON.parse(resource.to_json) + expect(data).to have_key('test_resource') + expect(data['test_resource']['name']).to eq('Test') + end + + it 'excludes the errors attribute' do + data = JSON.parse(resource.to_json) + expect(data['test_resource']).not_to have_key('errors') + end + + it 'excludes nil attributes' do + r = test_class.new(adapter: adapter, name: 'Test') + data = JSON.parse(r.to_json) + expect(data['test_resource']).not_to have_key('id') + expect(data['test_resource']).not_to have_key('status') + expect(data['test_resource']['name']).to eq('Test') + end + end + + describe '#initialize' do + it 'only sets known attributes' do + r = test_class.new(adapter: adapter, name: 'Test', unknown: 'ignored') + expect(r.name).to eq('Test') + end + + it 'uses a Struct for attributes' do + expect(resource.send(:attributes)).to be_a(Struct) + end + end + + describe '#==' do + it 'is equal when class and id match' do + a = test_class.new(adapter: adapter, id: '123', name: 'A') + b = test_class.new(adapter: adapter, id: '123', name: 'B') + expect(a).to eq(b) + end + + it 'is not equal when ids differ' do + a = test_class.new(adapter: adapter, id: '123') + b = test_class.new(adapter: adapter, id: '456') + expect(a).not_to eq(b) + end + + it 'is not equal when id is nil' do + a = test_class.new(adapter: adapter, name: 'A') + b = test_class.new(adapter: adapter, name: 'A') + expect(a).not_to eq(b) + end + + it 'is not equal to a different class with the same id' do + other_class = Class.new(Pdfmonkey::Resource) do + const_set(:ATTRIBUTES, %i[id errors].freeze) + const_set(:COLLECTION, 'others') + const_set(:MEMBER, 'other') + + def_delegators :attributes, *self::ATTRIBUTES + end + + a = test_class.new(adapter: adapter, id: '123') + b = other_class.new(adapter: adapter, id: '123') + expect(a).not_to eq(b) + end + end + + describe '#hash' do + it 'is the same for equal resources' do + a = test_class.new(adapter: adapter, id: '123') + b = test_class.new(adapter: adapter, id: '123') + expect(a.hash).to eq(b.hash) + end + + it 'works as Hash keys' do + a = test_class.new(adapter: adapter, id: '123') + b = test_class.new(adapter: adapter, id: '123') + hash = { a => 'found' } + expect(hash[b]).to eq('found') + end + end + + describe '#to_h' do + it 'returns all attributes as a hash' do + result = resource.to_h + expect(result).to eq(id: '123', name: 'Test', status: nil, errors: nil) + end + end + + describe 'module composition' do + let(:minimal_class) do + Class.new(Pdfmonkey::Resource) do + const_set(:ATTRIBUTES, %i[id errors].freeze) + const_set(:COLLECTION, 'minimals') + const_set(:MEMBER, 'minimal') + + def_delegators :attributes, *self::ATTRIBUTES + end + end + + it 'does not have CRUD methods when no modules are included' do + expect { minimal_class.fetch('id') }.to raise_error(NoMethodError) + expect { minimal_class.create }.to raise_error(NoMethodError) + expect { minimal_class.delete('id') }.to raise_error(NoMethodError) + + instance = minimal_class.new(adapter: adapter) + expect { instance.save }.to raise_error(NoMethodError) + expect { instance.reload! }.to raise_error(NoMethodError) + expect { instance.update!(id: '1') }.to raise_error(NoMethodError) + expect { instance.delete! }.to raise_error(NoMethodError) + end + end + + describe 'Listable filter validation' do + let(:filtered_class) do + Class.new(Pdfmonkey::Resource) do + include Pdfmonkey::Resource::Listable + + const_set(:ATTRIBUTES, %i[id errors].freeze) + const_set(:COLLECTION, 'filtereds') + const_set(:MEMBER, 'filtered') + const_set(:FILTERS, { status: 'q[status]' }.freeze) + + def_delegators :attributes, *self::ATTRIBUTES + end + end + + let(:unfiltered_class) do + Class.new(Pdfmonkey::Resource) do + include Pdfmonkey::Resource::Listable + + const_set(:ATTRIBUTES, %i[id errors].freeze) + const_set(:COLLECTION, 'unfiltereds') + const_set(:MEMBER, 'unfiltered') + + def_delegators :attributes, *self::ATTRIBUTES + end + end + + it 'raises ArgumentError for unknown filter keys' do + expect { filtered_class.list(status: 'ok', bogus: 'x') } + .to raise_error(ArgumentError, /Unknown filter\(s\): bogus/) + end + + it 'raises ArgumentError when filters are passed to a non-filterable resource' do + expect { unfiltered_class.list(foo: 'bar') } + .to raise_error(ArgumentError, /does not support filters/) + end + end +end diff --git a/spec/pdfmonkey/snippet_spec.rb b/spec/pdfmonkey/snippet_spec.rb new file mode 100644 index 0000000..5d90c1e --- /dev/null +++ b/spec/pdfmonkey/snippet_spec.rb @@ -0,0 +1,114 @@ +RSpec.describe Pdfmonkey::Snippet do + let(:adapter) { double('adapter') } + let(:snippet_attrs) {{ + 'id' => 'snip-1', + 'identifier' => 'header', + 'code' => '
Header
', + 'workspace_id' => 'ws-1' + }} + + describe '.list' do + let(:api_response) {{ + 'snippets' => [snippet_attrs], + 'meta' => { + 'current_page' => 1, + 'total_pages' => 1, + 'next_page' => nil, + 'prev_page' => nil + } + }} + + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:get, anything, params: anything, extract: :collection) + .and_return(api_response) + end + + it 'returns a Collection' do + result = described_class.list + expect(result).to be_a(Pdfmonkey::Collection) + end + + it 'contains Snippet items' do + result = described_class.list + expect(result.first).to be_a(described_class) + expect(result.first.identifier).to eq('header') + end + end + + describe '.fetch' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:get, anything) + .and_return(snippet_attrs) + end + + it 'returns a Snippet' do + result = described_class.fetch('snip-1') + expect(result).to be_a(described_class) + expect(result.code).to eq('
Header
') + end + end + + describe '.create' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:post, anything) + .and_return(snippet_attrs) + end + + it 'creates and returns a Snippet' do + result = described_class.create( + identifier: 'header', + code: '
Header
', + workspace_id: 'ws-1' + ) + expect(result).to be_a(described_class) + expect(result.id).to eq('snip-1') + end + end + + describe '.delete' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:delete, anything) + .and_return(true) + end + + it 'deletes the snippet' do + expect(described_class.delete('snip-1')).to be true + end + end + + describe '#update!' do + subject { described_class.new(adapter: adapter, id: 'snip-1', code: '
Old
') } + + before do + allow(adapter).to receive(:call) + .with(:put, subject) + .and_return('id' => 'snip-1', 'code' => '
New
') + end + + it 'sends a PUT request and updates attributes' do + subject.update!(code: '
New
') + expect(subject.code).to eq('
New
') + end + + it 'returns self' do + expect(subject.update!(code: '
New
')).to be subject + end + end + + describe '#delete!' do + subject { described_class.new(adapter: adapter, id: 'snip-1') } + + it 'sends a delete request' do + expect(adapter).to receive(:call).with(:delete, subject).and_return(true) + expect(subject.delete!).to be true + end + end +end diff --git a/spec/pdfmonkey/template_card_spec.rb b/spec/pdfmonkey/template_card_spec.rb new file mode 100644 index 0000000..e2d46a2 --- /dev/null +++ b/spec/pdfmonkey/template_card_spec.rb @@ -0,0 +1,107 @@ +RSpec.describe Pdfmonkey::TemplateCard do + let(:adapter) { double('adapter') } + let(:card_attrs) {{ + 'id' => 'tpl-1', + 'app_id' => 'app-1', + 'identifier' => 'my-template', + 'edition_mode' => 'html', + 'output_type' => 'pdf' + }} + + describe '.list' do + let(:api_response) {{ + 'document_template_cards' => [card_attrs], + 'meta' => { + 'current_page' => 1, + 'total_pages' => 2, + 'next_page' => 2, + 'prev_page' => nil + } + }} + + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:get, anything, params: anything, extract: :collection) + .and_return(api_response) + end + + it 'returns a Collection' do + result = described_class.list(workspace_id: 'ws-1') + expect(result).to be_a(Pdfmonkey::Collection) + end + + it 'contains TemplateCard items' do + result = described_class.list(workspace_id: 'ws-1') + expect(result.first).to be_a(described_class) + expect(result.first.identifier).to eq('my-template') + end + + it 'requires workspace_id' do + expect { described_class.list }.to raise_error(ArgumentError) + end + + it 'passes workspace_id and page params' do + described_class.list(workspace_id: 'ws-1', page: 2) + + expect(adapter).to have_received(:call).with( + :get, anything, + params: hash_including( + 'q[workspace_id]' => 'ws-1', + 'page[number]' => 2 + ), + extract: :collection + ) + end + end + + describe 'undefined methods' do + it 'does not respond to .create' do + expect { described_class.create }.to raise_error(NoMethodError) + end + + it 'does not respond to .delete' do + expect { described_class.delete('id') }.to raise_error(NoMethodError) + end + + it 'does not respond to .fetch' do + expect { described_class.fetch('id') }.to raise_error(NoMethodError) + end + + it 'does not respond to #save' do + card = described_class.new(adapter: adapter, id: 'tpl-1') + expect { card.save }.to raise_error(NoMethodError) + end + + it 'does not respond to #update!' do + card = described_class.new(adapter: adapter, id: 'tpl-1') + expect { card.update!(identifier: 'x') }.to raise_error(NoMethodError) + end + + it 'does not respond to #delete!' do + card = described_class.new(adapter: adapter, id: 'tpl-1') + expect { card.delete! }.to raise_error(NoMethodError) + end + + it 'does not respond to #reload!' do + card = described_class.new(adapter: adapter, id: 'tpl-1') + expect { card.reload! }.to raise_error(NoMethodError) + end + end + + describe '#to_template' do + let(:template) { double('template') } + + before do + allow(Pdfmonkey::Template).to receive(:fetch) + .with('tpl-1') + .and_return(template) + end + + subject { described_class.new(adapter: adapter, id: 'tpl-1') } + + it 'fetches the full Template' do + expect(subject.to_template).to eq(template) + end + end +end diff --git a/spec/pdfmonkey/template_folder_spec.rb b/spec/pdfmonkey/template_folder_spec.rb new file mode 100644 index 0000000..d3d7293 --- /dev/null +++ b/spec/pdfmonkey/template_folder_spec.rb @@ -0,0 +1,111 @@ +RSpec.describe Pdfmonkey::TemplateFolder do + let(:adapter) { double('adapter') } + let(:folder_attrs) {{ + 'id' => 'folder-1', + 'app_id' => 'app-1', + 'identifier' => 'invoices', + 'created_at' => '2024-01-01', + 'updated_at' => '2024-01-01' + }} + + describe '.list' do + let(:api_response) {{ + 'template_folders' => [folder_attrs], + 'meta' => { + 'current_page' => 1, + 'total_pages' => 1, + 'next_page' => nil, + 'prev_page' => nil + } + }} + + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:get, anything, params: anything, extract: :collection) + .and_return(api_response) + end + + it 'returns a Collection' do + result = described_class.list + expect(result).to be_a(Pdfmonkey::Collection) + end + + it 'contains TemplateFolder items' do + result = described_class.list + expect(result.first).to be_a(described_class) + expect(result.first.identifier).to eq('invoices') + end + end + + describe '.fetch' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:get, anything) + .and_return(folder_attrs) + end + + it 'returns a TemplateFolder' do + result = described_class.fetch('folder-1') + expect(result).to be_a(described_class) + expect(result.id).to eq('folder-1') + end + end + + describe '.create' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:post, anything) + .and_return(folder_attrs) + end + + it 'creates and returns a TemplateFolder' do + result = described_class.create(identifier: 'invoices') + expect(result).to be_a(described_class) + expect(result.id).to eq('folder-1') + end + end + + describe '.delete' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:delete, anything) + .and_return(true) + end + + it 'deletes the folder' do + expect(described_class.delete('folder-1')).to be true + end + end + + describe '#update!' do + subject { described_class.new(adapter: adapter, id: 'folder-1', identifier: 'old') } + + before do + allow(adapter).to receive(:call) + .with(:put, subject) + .and_return('id' => 'folder-1', 'identifier' => 'new-name') + end + + it 'sends a PUT request and updates attributes' do + subject.update!(identifier: 'new-name') + expect(subject.identifier).to eq('new-name') + end + + it 'returns self' do + expect(subject.update!(identifier: 'new-name')).to be subject + end + end + + describe '#delete!' do + subject { described_class.new(adapter: adapter, id: 'folder-1') } + + it 'sends a delete request' do + expect(adapter).to receive(:call).with(:delete, subject).and_return(true) + expect(subject.delete!).to be true + end + end +end diff --git a/spec/pdfmonkey/template_spec.rb b/spec/pdfmonkey/template_spec.rb new file mode 100644 index 0000000..7494f2d --- /dev/null +++ b/spec/pdfmonkey/template_spec.rb @@ -0,0 +1,202 @@ +RSpec.describe Pdfmonkey::Template do + let(:adapter) { double('adapter') } + let(:template_attrs) {{ + 'id' => 'tpl-1', + 'app_id' => 'app-1', + 'identifier' => 'my-template', + 'body' => '

Hello

', + 'body_draft' => '

Hello Draft

', + 'output_type' => 'pdf' + }} + + describe '.fetch' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:get, anything) + .and_return(template_attrs) + end + + it 'returns a Template' do + result = described_class.fetch('tpl-1') + expect(result).to be_a(described_class) + expect(result.id).to eq('tpl-1') + expect(result.identifier).to eq('my-template') + end + end + + describe '.create' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:post, anything) + .and_return(template_attrs) + end + + it 'creates and returns a Template' do + result = described_class.create( + identifier: 'my-template', + body: '

Hello

' + ) + expect(result).to be_a(described_class) + expect(result.id).to eq('tpl-1') + end + + it 'remaps body to body_draft' do + sent_resource = nil + allow(adapter).to receive(:call).with(:post, anything) do |_, resource| + sent_resource = JSON.parse(resource.to_json) + template_attrs + end + + described_class.create(identifier: 'my-template', body: '

Hello

') + + expect(sent_resource['document_template']).to include('body_draft' => '

Hello

') + expect(sent_resource['document_template']).not_to have_key('body') + end + + it 'remaps scss_style to scss_style_draft' do + sent_resource = nil + allow(adapter).to receive(:call).with(:post, anything) do |_, resource| + sent_resource = JSON.parse(resource.to_json) + template_attrs + end + + described_class.create(identifier: 'x', scss_style: '.red { color: red }') + + expect(sent_resource['document_template']).to include('scss_style_draft' => '.red { color: red }') + end + + it 'remaps pdf_engine_id to pdf_engine_draft_id' do + sent_resource = nil + allow(adapter).to receive(:call).with(:post, anything) do |_, resource| + sent_resource = JSON.parse(resource.to_json) + template_attrs + end + + described_class.create(identifier: 'x', pdf_engine_id: 'engine-1') + + expect(sent_resource['document_template']).to include('pdf_engine_draft_id' => 'engine-1') + end + end + + describe '.delete' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:delete, anything) + .and_return(true) + end + + it 'deletes the template' do + expect(described_class.delete('tpl-1')).to be true + end + end + + describe '.list_cards' do + it 'delegates to TemplateCard.list' do + expect(Pdfmonkey::TemplateCard).to receive(:list).with(workspace_id: 'ws-1').and_return(:result) + expect(described_class.list_cards(workspace_id: 'ws-1')).to eq(:result) + end + end + + describe '.fetch_full' do + it 'delegates to .fetch' do + expect(described_class).to receive(:fetch).with('tpl-1').and_return(:template) + expect(described_class.fetch_full('tpl-1')).to eq(:template) + end + end + + describe '#update!' do + subject { described_class.new(adapter: adapter, id: 'tpl-1', body_draft: '

Old

') } + + before do + allow(adapter).to receive(:call) + .with(:put, subject) + .and_return('id' => 'tpl-1', 'body_draft' => '

New

') + end + + it 'remaps body to body_draft and sends a PUT request' do + subject.update!(body: '

New

') + expect(subject.body_draft).to eq('

New

') + end + + it 'returns self' do + expect(subject.update!(body: '

New

')).to be subject + end + end + + describe '#publish!' do + subject do + described_class.new( + adapter: adapter, + id: 'tpl-1', + body_draft: '

Draft

', + scss_style_draft: '.draft {}', + settings_draft: '{}', + sample_data_draft: '{"x":1}', + pdf_engine_draft_id: 'engine-1' + ) + end + + before do + allow(adapter).to receive(:call) + .with(:put, subject) + .and_return( + 'id' => 'tpl-1', + 'body' => '

Draft

', + 'body_draft' => '

Draft

', + 'scss_style' => '.draft {}', + 'scss_style_draft' => '.draft {}', + 'settings' => '{}', + 'settings_draft' => '{}', + 'sample_data' => '{"x":1}', + 'sample_data_draft' => '{"x":1}', + 'pdf_engine_id' => 'engine-1', + 'pdf_engine_draft_id' => 'engine-1' + ) + end + + it 'copies draft fields to published fields' do + subject.publish! + expect(subject.body).to eq('

Draft

') + expect(subject.scss_style).to eq('.draft {}') + expect(subject.settings).to eq('{}') + expect(subject.sample_data).to eq('{"x":1}') + expect(subject.pdf_engine_id).to eq('engine-1') + end + + it 'sends a PUT request' do + subject.publish! + expect(adapter).to have_received(:call).with(:put, subject) + end + + it 'returns self' do + expect(subject.publish!).to be subject + end + end + + describe '#reload!' do + subject { described_class.new(adapter: adapter, id: 'tpl-1') } + + before do + allow(adapter).to receive(:call) + .with(:get, subject) + .and_return(template_attrs) + end + + it 'reloads the template' do + subject.reload! + expect(subject.body).to eq('

Hello

') + end + end + + describe '#delete!' do + subject { described_class.new(adapter: adapter, id: 'tpl-1') } + + it 'sends a delete request' do + expect(adapter).to receive(:call).with(:delete, subject).and_return(true) + expect(subject.delete!).to be true + end + end +end diff --git a/spec/pdfmonkey/webhook_spec.rb b/spec/pdfmonkey/webhook_spec.rb new file mode 100644 index 0000000..2657f52 --- /dev/null +++ b/spec/pdfmonkey/webhook_spec.rb @@ -0,0 +1,81 @@ +RSpec.describe Pdfmonkey::Webhook do + let(:adapter) { double('adapter') } + let(:webhook_attrs) {{ + 'id' => 'hook-1', + 'url' => 'https://example.com/webhook', + 'event' => 'document.generation.completed', + 'workspace_id' => 'ws-1', + 'document_template_ids' => ['tpl-1'], + 'platform' => nil, + 'custom_channel' => nil + }} + + describe '.create' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:post, anything) + .and_return(webhook_attrs) + end + + it 'creates and returns a Webhook' do + result = described_class.create( + url: 'https://example.com/webhook', + event: 'document.generation.completed', + workspace_id: 'ws-1' + ) + expect(result).to be_a(described_class) + expect(result.id).to eq('hook-1') + expect(result.url).to eq('https://example.com/webhook') + end + end + + describe '.delete' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:delete, anything) + .and_return(true) + end + + it 'deletes the webhook' do + expect(described_class.delete('hook-1')).to be true + end + end + + describe '#delete!' do + subject { described_class.new(adapter: adapter, id: 'hook-1') } + + it 'sends a delete request' do + expect(adapter).to receive(:call).with(:delete, subject).and_return(true) + expect(subject.delete!).to be true + end + end + + describe 'unavailable methods' do + it 'does not respond to .fetch' do + expect { described_class.fetch('id') }.to raise_error(NoMethodError) + end + + it 'does not respond to .list' do + expect { described_class.list }.to raise_error(NoMethodError) + end + + it 'does not respond to #update!' do + webhook = described_class.new(adapter: adapter, id: 'hook-1') + expect { webhook.update!(url: 'x') }.to raise_error(NoMethodError) + end + + it 'does not respond to #reload!' do + webhook = described_class.new(adapter: adapter, id: 'hook-1') + expect { webhook.reload! }.to raise_error(NoMethodError) + end + end + + describe 'COLLECTION and MEMBER' do + it 'uses rest_hooks as the API resource name' do + expect(described_class::COLLECTION).to eq('rest_hooks') + expect(described_class::MEMBER).to eq('rest_hook') + end + end +end diff --git a/spec/pdfmonkey/workspace_spec.rb b/spec/pdfmonkey/workspace_spec.rb new file mode 100644 index 0000000..c889554 --- /dev/null +++ b/spec/pdfmonkey/workspace_spec.rb @@ -0,0 +1,78 @@ +RSpec.describe Pdfmonkey::Workspace do + let(:adapter) { double('adapter') } + let(:workspace_attrs) {{ + 'id' => 'ws-1', + 'identifier' => 'my-workspace', + 'invite_token' => 'abc123' + }} + + describe '.list_cards' do + let(:api_response) {{ + 'workspaces' => [workspace_attrs], + 'meta' => { + 'current_page' => 1, + 'total_pages' => 1, + 'next_page' => nil, + 'prev_page' => nil + } + }} + + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:get, anything, params: anything, extract: :collection) + .and_return(api_response) + end + + it 'returns a Collection' do + result = described_class.list_cards + expect(result).to be_a(Pdfmonkey::Collection) + end + + it 'contains Workspace items' do + result = described_class.list_cards + expect(result.first).to be_a(described_class) + expect(result.first.identifier).to eq('my-workspace') + end + end + + describe '.fetch' do + before do + allow(Pdfmonkey::Adapter).to receive(:new).and_return(adapter) + allow(adapter).to receive(:call) + .with(:get, anything) + .and_return(workspace_attrs) + end + + it 'returns a Workspace' do + result = described_class.fetch('ws-1') + expect(result).to be_a(described_class) + expect(result.id).to eq('ws-1') + end + end + + describe 'unavailable methods' do + it 'does not respond to .create' do + expect { described_class.create(identifier: 'x') }.to raise_error(NoMethodError) + end + + it 'does not respond to .delete' do + expect { described_class.delete('ws-1') }.to raise_error(NoMethodError) + end + + it 'does not respond to #update!' do + workspace = described_class.new(adapter: adapter, id: 'ws-1') + expect { workspace.update!(identifier: 'x') }.to raise_error(NoMethodError) + end + + it 'does not respond to #save' do + workspace = described_class.new(adapter: adapter, id: 'ws-1') + expect { workspace.save }.to raise_error(NoMethodError) + end + + it 'does not respond to #delete!' do + workspace = described_class.new(adapter: adapter, id: 'ws-1') + expect { workspace.delete! }.to raise_error(NoMethodError) + end + end +end diff --git a/spec/pdfmonkey_spec.rb b/spec/pdfmonkey_spec.rb index e69de29..72a7d9e 100644 --- a/spec/pdfmonkey_spec.rb +++ b/spec/pdfmonkey_spec.rb @@ -0,0 +1,40 @@ +RSpec.describe Pdfmonkey do + describe '.with_adapter' do + let(:custom_adapter) { double('custom adapter') } + + it 'makes the adapter available via .current_adapter inside the block' do + described_class.with_adapter(custom_adapter) do + expect(described_class.current_adapter).to eq(custom_adapter) + end + end + + it 'restores the previous adapter after the block' do + described_class.with_adapter(custom_adapter) { } + expect(described_class.current_adapter).to be_nil + end + + it 'restores the previous adapter even on exception' do + described_class.with_adapter(custom_adapter) { raise 'boom' } rescue nil + expect(described_class.current_adapter).to be_nil + end + + it 'supports nesting' do + outer = double('outer') + inner = double('inner') + + described_class.with_adapter(outer) do + described_class.with_adapter(inner) do + expect(described_class.current_adapter).to eq(inner) + end + expect(described_class.current_adapter).to eq(outer) + end + expect(described_class.current_adapter).to be_nil + end + end + + describe '.current_adapter' do + it 'returns nil when no adapter is set' do + expect(described_class.current_adapter).to be_nil + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b800fb4..138d84b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -91,6 +91,11 @@ # order dependency and want to debug it, you can fix the order by providing # the seed, which is printed after each run. # --seed 1234 + config.before(:each) do + Pdfmonkey.configuration = Pdfmonkey::Configuration.new + Pdfmonkey.configuration.private_key = 'test-api-key' + end + config.order = :random # Seed global randomization in this process using the `--seed` CLI option.