From 46444e10feaf392ed418c91e52516b0c6493ffff Mon Sep 17 00:00:00 2001 From: Simon Courtois Date: Tue, 17 Feb 2026 15:50:27 +0100 Subject: [PATCH] Adding resource-based architecture Introducing Resource base class with Fetchable, Listable, Creatable, Updatable, Deletable mixins. Adding 11 resource classes, Collection for pagination, Engine for API routing, and comprehensive test suite. Setting up rubocop with Ruby 3.2+ targeting and clean offense state. --- .github/workflows/ci.yml | 37 +- .gitignore | 2 + .rubocop.yml | 213 ++--------- CHANGELOG.md | 35 ++ Gemfile.lock | 58 --- README.md | 498 +++++++++++++++++++++---- Rakefile | 4 +- bin/console | 1 + lib/pdfmonkey.rb | 52 ++- lib/pdfmonkey/adapter.rb | 187 ++++++++-- lib/pdfmonkey/collection.rb | 37 ++ lib/pdfmonkey/configuration.rb | 21 +- lib/pdfmonkey/current_user.rb | 36 ++ lib/pdfmonkey/document.rb | 126 ++++--- lib/pdfmonkey/document_card.rb | 46 +++ lib/pdfmonkey/engine.rb | 19 + lib/pdfmonkey/resource.rb | 197 ++++++++++ lib/pdfmonkey/snippet.rb | 28 ++ lib/pdfmonkey/template.rb | 80 ++++ lib/pdfmonkey/template_card.rb | 43 +++ lib/pdfmonkey/template_folder.rb | 25 ++ lib/pdfmonkey/version.rb | 2 +- lib/pdfmonkey/webhook.rb | 26 ++ lib/pdfmonkey/workspace.rb | 24 ++ pdfmonkey.gemspec | 16 +- spec/pdfmonkey/adapter_spec.rb | 321 +++++++++++++++- spec/pdfmonkey/collection_spec.rb | 72 ++++ spec/pdfmonkey/configuration_spec.rb | 11 + spec/pdfmonkey/current_user_spec.rb | 63 ++++ spec/pdfmonkey/document_card_spec.rb | 207 ++++++++++ spec/pdfmonkey/document_spec.rb | 321 ++++++++++++++-- spec/pdfmonkey/engine_spec.rb | 76 ++++ spec/pdfmonkey/resource_spec.rb | 384 +++++++++++++++++++ spec/pdfmonkey/snippet_spec.rb | 114 ++++++ spec/pdfmonkey/template_card_spec.rb | 107 ++++++ spec/pdfmonkey/template_folder_spec.rb | 111 ++++++ spec/pdfmonkey/template_spec.rb | 202 ++++++++++ spec/pdfmonkey/webhook_spec.rb | 81 ++++ spec/pdfmonkey/workspace_spec.rb | 78 ++++ spec/pdfmonkey_spec.rb | 40 ++ spec/spec_helper.rb | 5 + 41 files changed, 3509 insertions(+), 497 deletions(-) delete mode 100644 Gemfile.lock create mode 100644 lib/pdfmonkey/collection.rb create mode 100644 lib/pdfmonkey/current_user.rb create mode 100644 lib/pdfmonkey/document_card.rb create mode 100644 lib/pdfmonkey/engine.rb create mode 100644 lib/pdfmonkey/resource.rb create mode 100644 lib/pdfmonkey/snippet.rb create mode 100644 lib/pdfmonkey/template.rb create mode 100644 lib/pdfmonkey/template_card.rb create mode 100644 lib/pdfmonkey/template_folder.rb create mode 100644 lib/pdfmonkey/webhook.rb create mode 100644 lib/pdfmonkey/workspace.rb create mode 100644 spec/pdfmonkey/collection_spec.rb create mode 100644 spec/pdfmonkey/current_user_spec.rb create mode 100644 spec/pdfmonkey/document_card_spec.rb create mode 100644 spec/pdfmonkey/engine_spec.rb create mode 100644 spec/pdfmonkey/resource_spec.rb create mode 100644 spec/pdfmonkey/snippet_spec.rb create mode 100644 spec/pdfmonkey/template_card_spec.rb create mode 100644 spec/pdfmonkey/template_folder_spec.rb create mode 100644 spec/pdfmonkey/template_spec.rb create mode 100644 spec/pdfmonkey/webhook_spec.rb create mode 100644 spec/pdfmonkey/workspace_spec.rb 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.