deckle 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 37ae8365d9f1406de529662560d15b7f0057e6f1b080b33db0af181804de9b79
4
+ data.tar.gz: 736dc2a6e9fa3663061ff55d616b6468f51738ba40f24252cd94c7940726b456
5
+ SHA512:
6
+ metadata.gz: 2a04633e7f070f025b6b58b9909d65c3811baaf7a497c585cdd691de6028c02e919c3c8157f298ba6c3ca9fd0a89b61617230b11bc6f2904180c702985b9aa7c
7
+ data.tar.gz: ec3ff7534cac1507b357eb5a9b3391cfe14e3d78ba26f4cfd8877c4b9acdcdf0c5adf1efac7ffdaf1b00a2a34127c39defb787054a330a39d79d6247295abbc9
data/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # Deckle Ruby SDK
2
+
3
+ PDF generation API for developers. HTML in, pixel-perfect PDF out.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ gem install deckle
9
+ ```
10
+
11
+ Or in a `Gemfile`:
12
+
13
+ ```ruby
14
+ gem "deckle"
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ ```ruby
20
+ require "deckle"
21
+
22
+ client = Deckle::Client.new(api_key: ENV["DECKLE_API_KEY"])
23
+
24
+ pdf = client.generate(html: "<h1>Hello, Deckle!</h1>")
25
+ puts pdf["url"]
26
+ ```
27
+
28
+ ## Templates
29
+
30
+ ```ruby
31
+ template = client.templates.create(
32
+ name: "Invoice",
33
+ html_content: "<h1>Invoice #{{number}}</h1><p>{{amount}}</p>"
34
+ )
35
+
36
+ pdf = client.from_template(
37
+ template: template["id"],
38
+ data: { "number" => 42, "amount" => "$500" }
39
+ )
40
+ ```
41
+
42
+ ## PDF tools
43
+
44
+ ```ruby
45
+ # AES-256 password protect a PDF (AES via qpdf server-side)
46
+ result = client.pdf_protect(
47
+ pdf: base64_pdf,
48
+ user_password: "open-me",
49
+ permissions: { "print" => "low", "modify" => false }
50
+ )
51
+
52
+ # Cryptographic PAdES signature
53
+ client.pdf_sign_annotation(
54
+ pdf: base64_pdf,
55
+ name: "Jane Doe",
56
+ signature: { "p12" => base64_p12, "password" => "p12-pass" }
57
+ )
58
+ ```
59
+
60
+ ## Error handling
61
+
62
+ ```ruby
63
+ begin
64
+ client.generate(html: "...")
65
+ rescue Deckle::RateLimitError => e
66
+ puts "rate limited; retry after #{e.retry_after}s"
67
+ rescue Deckle::AuthenticationError => e
68
+ puts "bad API key: #{e.message}"
69
+ rescue Deckle::Error => e
70
+ puts "error #{e.code}: #{e.message}"
71
+ end
72
+ ```
73
+
74
+ ## Development
75
+
76
+ ```bash
77
+ bundle install
78
+ bundle exec rspec # run the test suite (Faraday stubbed via Faraday::Adapter::Test)
79
+ bundle exec rake test # same, via Rakefile
80
+ ```
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Deckle
7
+ class Client
8
+ attr_reader :templates
9
+
10
+ # Status codes that are safe to retry.
11
+ RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504].freeze
12
+
13
+ # Create a new Deckle client.
14
+ #
15
+ # @param api_key [String] Your Deckle API key.
16
+ # @param base_url [String] API base URL.
17
+ # @param timeout [Integer] Request timeout in seconds.
18
+ # @param max_retries [Integer] Maximum number of retries for failed requests (default 3).
19
+ def initialize(api_key:, base_url: "https://api.getdeckle.dev", timeout: 30, max_retries: 3)
20
+ raise ArgumentError, "Deckle API key is required" if api_key.nil? || api_key.empty?
21
+
22
+ @api_key = api_key
23
+ @base_url = base_url.chomp("/")
24
+ @timeout = timeout
25
+ @max_retries = max_retries
26
+
27
+ @conn = Faraday.new(url: @base_url) do |f|
28
+ f.options.timeout = @timeout
29
+ f.options.open_timeout = @timeout
30
+ f.headers["Authorization"] = "Bearer #{@api_key}"
31
+ f.headers["Content-Type"] = "application/json"
32
+ f.headers["User-Agent"] = "deckle-ruby/#{Deckle::VERSION}"
33
+ f.adapter Faraday.default_adapter
34
+ end
35
+
36
+ @templates = Templates.new(self)
37
+ end
38
+
39
+ # Generate a PDF from raw HTML.
40
+ #
41
+ # @param html [String] HTML string to convert to PDF.
42
+ # @param options [Hash, nil] PDF rendering options (format, margin, orientation, etc.).
43
+ # @param output [String] Output format - "url" or "base64".
44
+ # @param webhook [String, nil] URL to POST when generation completes.
45
+ # @return [Hash] Generation response with id, status, url, etc.
46
+ def generate(html:, options: nil, output: "url", webhook: nil, watermark: nil)
47
+ body = { "html" => html, "output" => output }
48
+ body["options"] = options if options
49
+ body["webhook"] = webhook if webhook
50
+ body["watermark"] = watermark if watermark
51
+
52
+ request(:post, "/v1/generate", body: body)
53
+ end
54
+
55
+ # Generate a PDF from a saved template with dynamic data.
56
+ #
57
+ # @param template [String] Template ID (tmpl_xxx).
58
+ # @param data [Hash] Data to merge into the template.
59
+ # @param options [Hash, nil] PDF rendering options.
60
+ # @param output [String] Output format - "url" or "base64".
61
+ # @return [Hash] Generation response.
62
+ def from_template(template:, data:, options: nil, output: "url", webhook: nil)
63
+ body = { "template" => template, "data" => data, "output" => output }
64
+ body["options"] = options if options
65
+ body["webhook"] = webhook if webhook
66
+
67
+ request(:post, "/v1/generate", body: body)
68
+ end
69
+
70
+ # Generate a PDF from a React component string.
71
+ #
72
+ # @param react [String] JSX/TSX component source with default export.
73
+ # @param data [Hash, nil] Props to pass to the component.
74
+ # @param styles [String, nil] CSS styles to inject.
75
+ # @param options [Hash, nil] PDF rendering options.
76
+ # @param output [String] Output format - "url" or "base64".
77
+ # @return [Hash] Generation response.
78
+ def from_react(react:, data: nil, styles: nil, options: nil, output: "url", webhook: nil)
79
+ body = { "react" => react, "output" => output }
80
+ body["data"] = data if data
81
+ body["styles"] = styles if styles
82
+ body["options"] = options if options
83
+ body["webhook"] = webhook if webhook
84
+
85
+ request(:post, "/v1/generate", body: body)
86
+ end
87
+
88
+ # Submit a batch of PDF generation jobs for async processing.
89
+ #
90
+ # @param items [Array<Hash>] List of generation requests.
91
+ # @param webhook [String, nil] URL to POST when the batch completes.
92
+ # @return [Hash] Batch response with batch_id, total, and generation IDs.
93
+ def batch(items:, webhook: nil)
94
+ body = { "items" => items }
95
+ body["webhook"] = webhook if webhook
96
+
97
+ request(:post, "/v1/generate/batch", body: body)
98
+ end
99
+
100
+ # Get a generation by ID.
101
+ #
102
+ # @param id [String] Generation ID.
103
+ # @return [Hash] Generation details.
104
+ def get_generation(id)
105
+ request(:get, "/v1/generations/#{id}")
106
+ end
107
+
108
+ # List recent generations.
109
+ #
110
+ # @param limit [Integer] Maximum number of results (default 50).
111
+ # @param offset [Integer] Offset for pagination (default 0).
112
+ # @return [Hash] List response with data array.
113
+ def list_generations(limit: 50, offset: 0)
114
+ request(:get, "/v1/generations?limit=#{limit}&offset=#{offset}")
115
+ end
116
+
117
+ # Get usage statistics for the current billing period.
118
+ #
119
+ # @return [Hash] Usage statistics.
120
+ def get_usage
121
+ request(:get, "/v1/usage")
122
+ end
123
+
124
+ # ── PDF tools ──────────────────────────────────────────────────
125
+ #
126
+ # NOTE: `sign_annotation` returns `signature_annotation_added: true`
127
+ # (not `signed:`) so it's clear that this is a visual annotation,
128
+ # not a cryptographic signature. `protect` uses real AES-256
129
+ # encryption via qpdf server-side.
130
+
131
+ # Merge multiple base64-encoded PDFs into one. Requires >= 2 inputs.
132
+ def pdf_merge(pdfs:, output: "url")
133
+ request(:post, "/v1/pdf/merge", body: { "pdfs" => pdfs, "output" => output })
134
+ end
135
+
136
+ # Split a PDF by page ranges. Pass nil ranges to split every page.
137
+ def pdf_split(pdf:, ranges: nil, output: "url")
138
+ body = { "pdf" => pdf, "output" => output }
139
+ body["ranges"] = ranges if ranges
140
+ request(:post, "/v1/pdf/split", body: body)
141
+ end
142
+
143
+ # Get metadata about a PDF (page count, title, author, etc.).
144
+ def pdf_info(pdf:)
145
+ request(:post, "/v1/pdf/info", body: { "pdf" => pdf })
146
+ end
147
+
148
+ # Fill named form fields in an existing AcroForm PDF.
149
+ def pdf_fill_form(pdf:, fields:, flatten: false, output: "url")
150
+ request(:post, "/v1/pdf/forms/fill", body: {
151
+ "pdf" => pdf,
152
+ "fields" => fields,
153
+ "flatten" => flatten,
154
+ "output" => output
155
+ })
156
+ end
157
+
158
+ # Add text / checkbox / dropdown form fields to a PDF.
159
+ def pdf_add_form_fields(pdf:, fields:, output: "url")
160
+ request(:post, "/v1/pdf/forms/add-fields", body: {
161
+ "pdf" => pdf,
162
+ "fields" => fields,
163
+ "output" => output
164
+ })
165
+ end
166
+
167
+ # List the form fields on a PDF.
168
+ def pdf_list_form_fields(pdf:)
169
+ request(:post, "/v1/pdf/forms/list-fields", body: { "pdf" => pdf })
170
+ end
171
+
172
+ # Convert a PDF to PDF/A-1b archival format.
173
+ def pdf_to_pdfa(pdf:, title: nil, author: nil, subject: nil, output: "url")
174
+ body = { "pdf" => pdf, "output" => output }
175
+ body["title"] = title if title
176
+ body["author"] = author if author
177
+ body["subject"] = subject if subject
178
+ request(:post, "/v1/pdf/pdfa", body: body)
179
+ end
180
+
181
+ # Sign a PDF.
182
+ #
183
+ # Without :signature → adds a VISUAL annotation only. Response has
184
+ # signature_annotation_added=true and cryptographically_signed=false.
185
+ #
186
+ # With :signature → also embeds a real PAdES-B-B cryptographic
187
+ # signature. :signature must be a hash with:
188
+ # :p12 base64-encoded PKCS#12 (P12/PFX) blob, max 100 KB decoded
189
+ # :password P12 passphrase ("" for unprotected P12s)
190
+ # The P12 is sent over TLS and used ephemerally — never persisted.
191
+ def pdf_sign_annotation(pdf:, name:, reason: nil, location: nil, contact: nil,
192
+ page: nil, x: nil, y: nil, width: nil, height: nil,
193
+ output: "url", signature: nil)
194
+ body = { "pdf" => pdf, "name" => name, "output" => output }
195
+ body["reason"] = reason unless reason.nil?
196
+ body["location"] = location unless location.nil?
197
+ body["contact"] = contact unless contact.nil?
198
+ body["page"] = page unless page.nil?
199
+ body["x"] = x unless x.nil?
200
+ body["y"] = y unless y.nil?
201
+ body["width"] = width unless width.nil?
202
+ body["height"] = height unless height.nil?
203
+ body["signature"] = signature unless signature.nil?
204
+ request(:post, "/v1/pdf/sign", body: body)
205
+ end
206
+
207
+ # AES-256 encrypt a PDF. At least one of user_password / owner_password
208
+ # must be set. If only one is supplied the other is mirrored on the
209
+ # server so an empty owner password cannot be used to strip restrictions.
210
+ #
211
+ # permissions: hash with optional keys :print ("none" | "low" | "full"),
212
+ # :modify (bool), :copy (bool), :annotate (bool).
213
+ def pdf_protect(pdf:, user_password: nil, owner_password: nil,
214
+ permissions: nil, output: "url")
215
+ if user_password.nil? && owner_password.nil?
216
+ raise ArgumentError, "user_password or owner_password is required"
217
+ end
218
+ body = { "pdf" => pdf, "output" => output }
219
+ body["user_password"] = user_password unless user_password.nil?
220
+ body["owner_password"] = owner_password unless owner_password.nil?
221
+ body["permissions"] = permissions unless permissions.nil?
222
+ request(:post, "/v1/pdf/protect", body: body)
223
+ end
224
+
225
+ # ── Marketplace ────────────────────────────────────────────────
226
+
227
+ def marketplace_list
228
+ request(:get, "/v1/marketplace")
229
+ end
230
+
231
+ def marketplace_get(id)
232
+ request(:get, "/v1/marketplace/#{id}")
233
+ end
234
+
235
+ def marketplace_clone(id)
236
+ request(:post, "/v1/marketplace/#{id}/clone")
237
+ end
238
+
239
+ def marketplace_publish(id)
240
+ request(:post, "/v1/marketplace/#{id}/publish")
241
+ end
242
+
243
+ def marketplace_unpublish(id)
244
+ request(:post, "/v1/marketplace/#{id}/unpublish")
245
+ end
246
+
247
+ # Report a public marketplace template for moderator review.
248
+ #
249
+ # reason: one of "spam", "malicious", "copyright", "inappropriate",
250
+ # "other". notes: optional, max 1000 chars.
251
+ #
252
+ # Returns { "report_id" => ..., "auto_actioned" => bool }.
253
+ # auto_actioned is true when this report tripped the auto-hide
254
+ # threshold (3 independent reports). Re-reporting the same template
255
+ # from the same user yields a 409 (Deckle::Error).
256
+ def marketplace_report(id, reason:, notes: nil)
257
+ body = { "reason" => reason }
258
+ body["notes"] = notes unless notes.nil?
259
+ request(:post, "/v1/marketplace/#{id}/report", body: body)
260
+ end
261
+
262
+ # ── Starter templates ──────────────────────────────────────────
263
+
264
+ def starter_templates_list
265
+ request(:get, "/v1/starter-templates")
266
+ end
267
+
268
+ def starter_templates_get(slug)
269
+ request(:get, "/v1/starter-templates/#{slug}")
270
+ end
271
+
272
+ def starter_templates_clone(slug)
273
+ request(:post, "/v1/starter-templates/#{slug}/clone")
274
+ end
275
+
276
+ # ── Template versions ─────────────────────────────────────────
277
+
278
+ def list_template_versions(template_id)
279
+ request(:get, "/v1/templates/#{template_id}/versions")
280
+ end
281
+
282
+ def get_template_version(template_id, version_id)
283
+ request(:get, "/v1/templates/#{template_id}/versions/#{version_id}")
284
+ end
285
+
286
+ def restore_template_version(template_id, version_id)
287
+ request(:post, "/v1/templates/#{template_id}/restore",
288
+ body: { "version_id" => version_id })
289
+ end
290
+
291
+ # ── AI ────────────────────────────────────────────────────────
292
+
293
+ # Generate a template from a natural-language prompt. The HTML
294
+ # returned has been server-sanitized so it's safe to render.
295
+ # Requires the server to be configured with ANTHROPIC_API_KEY.
296
+ def generate_template_from_prompt(prompt:, variables: nil,
297
+ template_type: "other", style: "professional")
298
+ body = { "prompt" => prompt, "type" => template_type, "style" => style }
299
+ body["variables"] = variables if variables
300
+ request(:post, "/v1/ai/generate-template", body: body)
301
+ end
302
+
303
+ # Make an HTTP request and handle errors. Retries on 429/5xx with
304
+ # exponential backoff.
305
+ #
306
+ # NOTE: this method is intentionally public so that Templates (a
307
+ # separate class) can call `@client.request(...)`. Ruby's `protected`
308
+ # keyword forbids cross-class invocations, which previously caused
309
+ # every `templates.*` call to raise NoMethodError.
310
+ #
311
+ # @param method [Symbol] HTTP method (:get, :post, :put, :delete).
312
+ # @param path [String] API path.
313
+ # @param body [Hash, nil] Request body.
314
+ # @return [Hash] Parsed JSON response.
315
+ def request(method, path, body: nil)
316
+ last_exception = nil
317
+
318
+ (@max_retries + 1).times do |attempt|
319
+ begin
320
+ response = @conn.run_request(method, path, body ? JSON.generate(body) : nil, nil)
321
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
322
+ last_exception = e
323
+ if attempt < @max_retries
324
+ sleep(2**attempt)
325
+ next
326
+ end
327
+ raise
328
+ end
329
+
330
+ begin
331
+ data = JSON.parse(response.body)
332
+ rescue JSON::ParserError
333
+ raise Error.new(
334
+ "Non-JSON response from API (status #{response.status})",
335
+ status_code: response.status,
336
+ code: "INVALID_RESPONSE"
337
+ )
338
+ end
339
+
340
+ # Retry on retryable status codes unless exhausted
341
+ if RETRYABLE_STATUS_CODES.include?(response.status) && attempt < @max_retries
342
+ delay = if response.status == 429
343
+ (response.headers["Retry-After"] || (2**attempt).to_s).to_f
344
+ else
345
+ 2**attempt
346
+ end
347
+ sleep(delay)
348
+ next
349
+ end
350
+
351
+ if response.status == 401
352
+ raise AuthenticationError, data.dig("error", "message") || "Unauthorized"
353
+ end
354
+
355
+ if response.status == 403
356
+ raise UsageLimitError, data.dig("error", "message") || "Forbidden"
357
+ end
358
+
359
+ if response.status == 404
360
+ raise NotFoundError, data.dig("error", "message") || "Not found"
361
+ end
362
+
363
+ if response.status == 429
364
+ retry_after = (response.headers["Retry-After"] || "1").to_i
365
+ raise RateLimitError.new(
366
+ data.dig("error", "message") || "Rate limit exceeded",
367
+ retry_after: retry_after
368
+ )
369
+ end
370
+
371
+ unless response.success?
372
+ error = data["error"] || {}
373
+ raise Error.new(
374
+ error["message"] || "Request failed",
375
+ status_code: response.status,
376
+ code: error["code"] || "UNKNOWN"
377
+ )
378
+ end
379
+
380
+ return data
381
+ end
382
+
383
+ raise last_exception
384
+ end
385
+ end
386
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deckle
4
+ class Error < StandardError
5
+ attr_reader :status_code, :code, :message
6
+
7
+ def initialize(message = "Request failed", status_code: 0, code: "UNKNOWN")
8
+ @message = message
9
+ @status_code = status_code
10
+ @code = code
11
+ super(message)
12
+ end
13
+ end
14
+
15
+ class AuthenticationError < Error
16
+ def initialize(message = "Invalid API key")
17
+ super(message, status_code: 401, code: "UNAUTHORIZED")
18
+ end
19
+ end
20
+
21
+ class RateLimitError < Error
22
+ attr_reader :retry_after
23
+
24
+ def initialize(message = "Rate limit exceeded", retry_after: 1)
25
+ @retry_after = retry_after
26
+ super(message, status_code: 429, code: "RATE_LIMITED")
27
+ end
28
+ end
29
+
30
+ class ValidationError < Error
31
+ def initialize(message = "Validation failed")
32
+ super(message, status_code: 400, code: "VALIDATION_ERROR")
33
+ end
34
+ end
35
+
36
+ class NotFoundError < Error
37
+ def initialize(message = "Not found")
38
+ super(message, status_code: 404, code: "NOT_FOUND")
39
+ end
40
+ end
41
+
42
+ class UsageLimitError < Error
43
+ def initialize(message = "Usage limit exceeded")
44
+ super(message, status_code: 403, code: "USAGE_LIMIT")
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deckle
4
+ class Templates
5
+ def initialize(client)
6
+ @client = client
7
+ end
8
+
9
+ # Create a new template.
10
+ #
11
+ # @param name [String] Template name.
12
+ # @param html_content [String] HTML content of the template.
13
+ # @param schema [Hash, nil] JSON schema for template data validation.
14
+ # @param is_public [Boolean] Whether the template is publicly accessible.
15
+ # @return [Hash] Created template.
16
+ def create(name:, html_content:, schema: nil, is_public: false)
17
+ body = { "name" => name, "html_content" => html_content, "is_public" => is_public }
18
+ body["schema"] = schema if schema
19
+
20
+ @client.request(:post, "/v1/templates", body: body)
21
+ end
22
+
23
+ # List all templates.
24
+ #
25
+ # @return [Hash] List response with data array.
26
+ def list
27
+ @client.request(:get, "/v1/templates")
28
+ end
29
+
30
+ # Get a template by ID.
31
+ #
32
+ # @param id [String] Template ID.
33
+ # @return [Hash] Template details.
34
+ def get(id)
35
+ @client.request(:get, "/v1/templates/#{id}")
36
+ end
37
+
38
+ # Update a template.
39
+ #
40
+ # @param id [String] Template ID.
41
+ # @param name [String, nil] New template name.
42
+ # @param html_content [String, nil] New HTML content.
43
+ # @param schema [Hash, nil] New JSON schema.
44
+ # @param is_public [Boolean, nil] New public visibility setting.
45
+ # @return [Hash] Updated template.
46
+ def update(id, name: nil, html_content: nil, schema: nil, is_public: nil)
47
+ body = {}
48
+ body["name"] = name unless name.nil?
49
+ body["html_content"] = html_content unless html_content.nil?
50
+ body["schema"] = schema unless schema.nil?
51
+ body["is_public"] = is_public unless is_public.nil?
52
+
53
+ @client.request(:put, "/v1/templates/#{id}", body: body)
54
+ end
55
+
56
+ # Delete a template.
57
+ #
58
+ # @param id [String] Template ID.
59
+ # @return [Hash] Deletion confirmation.
60
+ def delete(id)
61
+ @client.request(:delete, "/v1/templates/#{id}")
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deckle
4
+ VERSION = "1.0.0"
5
+ end
data/lib/deckle.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "deckle/version"
4
+ require_relative "deckle/errors"
5
+ require_relative "deckle/templates"
6
+ require_relative "deckle/client"
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Deckle Ruby SDK — request shape, error mapping, and namespace
4
+ # coverage. Mirrors packages/sdk-typescript/src/client.test.ts.
5
+ #
6
+ # Faraday::Adapter::Test intercepts every request so no real network
7
+ # is touched.
8
+
9
+ require "spec_helper"
10
+
11
+ RSpec.describe Deckle::Client do
12
+ include DeckleSpecHelpers
13
+
14
+ let(:usage_payload) do
15
+ {
16
+ "period_start" => "2026-01-01",
17
+ "period_end" => "2026-01-31",
18
+ "generation_count" => 0,
19
+ "total_pages" => 0,
20
+ "total_bytes" => 0,
21
+ "plan" => "free",
22
+ "limit" => 1000
23
+ }
24
+ end
25
+
26
+ describe "constructor" do
27
+ it "refuses an empty api_key" do
28
+ expect { Deckle::Client.new(api_key: "") }.to raise_error(ArgumentError, /required/)
29
+ end
30
+
31
+ it "refuses a nil api_key" do
32
+ expect { Deckle::Client.new(api_key: nil) }.to raise_error(ArgumentError, /required/)
33
+ end
34
+
35
+ it "strips trailing slash from base_url" do
36
+ # We can't read @base_url directly, but the stripped form is
37
+ # used in @conn — exercising one call confirms the URL is right.
38
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
39
+ s.get("/v1/usage") { |_env| [200, {}, JSON.generate(usage_payload)] }
40
+ end
41
+ # Pass the slash through the real constructor; build_client
42
+ # rebuilds @conn but we set base_url on the client constructor
43
+ # so the chomp logic is exercised before our rebuild.
44
+ client = Deckle::Client.new(
45
+ api_key: "k", base_url: "#{DeckleSpecHelpers::BASE_URL}/", max_retries: 0
46
+ )
47
+ # Replace its conn with the stub adapter.
48
+ conn = Faraday.new(url: DeckleSpecHelpers::BASE_URL) do |f|
49
+ f.headers["Authorization"] = "Bearer k"
50
+ f.adapter(:test, stubs)
51
+ end
52
+ client.instance_variable_set(:@conn, conn)
53
+ expect { client.get_usage }.not_to raise_error
54
+ stubs.verify_stubbed_calls
55
+ end
56
+ end
57
+
58
+ describe "request shape" do
59
+ it "sends Bearer auth and JSON Content-Type on POST" do
60
+ captured = {}
61
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
62
+ s.post("/v1/generate") do |env|
63
+ captured[:auth] = env.request_headers["Authorization"]
64
+ captured[:content_type] = env.request_headers["Content-Type"]
65
+ captured[:body] = env.body
66
+ [200, {}, JSON.generate({ "id" => "gen_1", "status" => "completed", "url" => "x",
67
+ "pages" => 1, "file_size" => 1, "generation_time_ms" => 1 })]
68
+ end
69
+ end
70
+ build_client(stubs).generate(html: "<h1>hi</h1>")
71
+ expect(captured[:auth]).to eq("Bearer dk_live_test")
72
+ expect(captured[:content_type]).to eq("application/json")
73
+ expect(JSON.parse(captured[:body])).to include("html" => "<h1>hi</h1>")
74
+ stubs.verify_stubbed_calls
75
+ end
76
+
77
+ it "uses the right path for get_generation" do
78
+ called = false
79
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
80
+ s.get("/v1/generations/gen_42") do
81
+ called = true
82
+ [200, {}, JSON.generate({ "id" => "gen_42", "status" => "completed" })]
83
+ end
84
+ end
85
+ build_client(stubs).get_generation("gen_42")
86
+ expect(called).to be(true)
87
+ end
88
+
89
+ it "propagates limit and offset on list_generations" do
90
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
91
+ s.get("/v1/generations") do |env|
92
+ expect(env.url.query).to include("limit=10").and include("offset=20")
93
+ [200, {}, JSON.generate({ "data" => [], "has_more" => false })]
94
+ end
95
+ end
96
+ build_client(stubs).list_generations(limit: 10, offset: 20)
97
+ stubs.verify_stubbed_calls
98
+ end
99
+ end
100
+
101
+ describe "error handling" do
102
+ it "raises AuthenticationError on 401" do
103
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
104
+ s.get("/v1/usage") do
105
+ [401, {}, JSON.generate({ "error" => { "code" => "UNAUTHORIZED", "message" => "no" } })]
106
+ end
107
+ end
108
+ expect { build_client(stubs).get_usage }.to raise_error(Deckle::AuthenticationError)
109
+ end
110
+
111
+ it "raises RateLimitError on 429 with Retry-After" do
112
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
113
+ s.get("/v1/usage") do
114
+ [429, { "Retry-After" => "11" },
115
+ JSON.generate({ "error" => { "code" => "RATE_LIMITED", "message" => "slow" } })]
116
+ end
117
+ end
118
+ begin
119
+ build_client(stubs).get_usage
120
+ raise "should have raised"
121
+ rescue Deckle::RateLimitError => e
122
+ expect(e.retry_after).to eq(11)
123
+ end
124
+ end
125
+
126
+ it "raises a generic Error on a non-retryable 4xx" do
127
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
128
+ s.post("/v1/generate") do
129
+ [400, {}, JSON.generate({ "error" => { "code" => "VALIDATION_ERROR", "message" => "bad" } })]
130
+ end
131
+ end
132
+ expect do
133
+ build_client(stubs).generate(html: "")
134
+ end.to raise_error(Deckle::Error) { |e|
135
+ expect(e.status_code).to eq(400)
136
+ expect(e.code).to eq("VALIDATION_ERROR")
137
+ }
138
+ end
139
+
140
+ it "raises NotFoundError on 404" do
141
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
142
+ s.get("/v1/generations/gen_missing") do
143
+ [404, {}, JSON.generate({ "error" => { "code" => "NOT_FOUND", "message" => "nope" } })]
144
+ end
145
+ end
146
+ expect do
147
+ build_client(stubs).get_generation("gen_missing")
148
+ end.to raise_error(Deckle::NotFoundError)
149
+ end
150
+
151
+ it "does not retry when max_retries is zero" do
152
+ hit = 0
153
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
154
+ s.get("/v1/usage") do
155
+ hit += 1
156
+ [500, {}, JSON.generate({ "error" => {} })]
157
+ end
158
+ end
159
+ expect do
160
+ build_client(stubs, max_retries: 0).get_usage
161
+ end.to raise_error(Deckle::Error)
162
+ expect(hit).to eq(1)
163
+ end
164
+ end
165
+
166
+ describe "PDF + marketplace + AI namespaces" do
167
+ it "pdf_merge POSTs to /v1/pdf/merge" do
168
+ called = false
169
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
170
+ s.post("/v1/pdf/merge") do
171
+ called = true
172
+ [200, {}, JSON.generate({ "url" => "x", "file_size" => 1 })]
173
+ end
174
+ end
175
+ build_client(stubs).pdf_merge(pdfs: %w[a b])
176
+ expect(called).to be(true)
177
+ end
178
+
179
+ it "pdf_protect raises before sending when no password is supplied" do
180
+ stubs = Faraday::Adapter::Test::Stubs.new
181
+ expect do
182
+ build_client(stubs).pdf_protect(pdf: "abc")
183
+ end.to raise_error(ArgumentError, /password/)
184
+ end
185
+
186
+ it "pdf_protect forwards passwords and permissions" do
187
+ captured_body = nil
188
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
189
+ s.post("/v1/pdf/protect") do |env|
190
+ captured_body = JSON.parse(env.body)
191
+ [200, {}, JSON.generate({ "url" => "x", "file_size" => 1, "encrypted" => true, "encryption" => "AES-256" })]
192
+ end
193
+ end
194
+ build_client(stubs).pdf_protect(
195
+ pdf: "abc",
196
+ user_password: "reader",
197
+ owner_password: "owner",
198
+ permissions: { "print" => "low", "modify" => false, "copy" => true }
199
+ )
200
+ expect(captured_body["user_password"]).to eq("reader")
201
+ expect(captured_body["owner_password"]).to eq("owner")
202
+ expect(captured_body["permissions"]["print"]).to eq("low")
203
+ end
204
+
205
+ it "pdf_sign_annotation forwards optional signature material" do
206
+ captured_body = nil
207
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
208
+ s.post("/v1/pdf/sign") do |env|
209
+ captured_body = JSON.parse(env.body)
210
+ [200, {}, JSON.generate({
211
+ "url" => "x", "file_size" => 1,
212
+ "signature_annotation_added" => true,
213
+ "cryptographically_signed" => true,
214
+ "signature_type" => "PAdES-B-B"
215
+ })]
216
+ end
217
+ end
218
+ build_client(stubs).pdf_sign_annotation(
219
+ pdf: "abc",
220
+ name: "Test Signer",
221
+ signature: { "p12" => "base64-blob", "password" => "pw" }
222
+ )
223
+ expect(captured_body["signature"]).to eq("p12" => "base64-blob", "password" => "pw")
224
+ end
225
+
226
+ it "marketplace_list hits /v1/marketplace" do
227
+ called = false
228
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
229
+ s.get("/v1/marketplace") do
230
+ called = true
231
+ [200, {}, JSON.generate({ "data" => [] })]
232
+ end
233
+ end
234
+ build_client(stubs).marketplace_list
235
+ expect(called).to be(true)
236
+ end
237
+
238
+ it "marketplace_report POSTs reason + notes" do
239
+ captured = nil
240
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
241
+ s.post("/v1/marketplace/tmpl_xyz/report") do |env|
242
+ captured = JSON.parse(env.body)
243
+ [201, {}, JSON.generate({ "report_id" => "rep_1", "auto_actioned" => false })]
244
+ end
245
+ end
246
+ result = build_client(stubs).marketplace_report("tmpl_xyz", reason: "spam", notes: "repetitive")
247
+ expect(result["report_id"]).to eq("rep_1")
248
+ expect(result["auto_actioned"]).to be(false)
249
+ expect(captured).to eq("reason" => "spam", "notes" => "repetitive")
250
+ end
251
+
252
+ it "starter_templates_list hits /v1/starter-templates" do
253
+ called = false
254
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
255
+ s.get("/v1/starter-templates") do
256
+ called = true
257
+ [200, {}, JSON.generate({ "data" => [] })]
258
+ end
259
+ end
260
+ build_client(stubs).starter_templates_list
261
+ expect(called).to be(true)
262
+ end
263
+
264
+ it "generate_template_from_prompt posts to /v1/ai/generate-template" do
265
+ called = false
266
+ stubs = Faraday::Adapter::Test::Stubs.new do |s|
267
+ s.post("/v1/ai/generate-template") do
268
+ called = true
269
+ [200, {}, JSON.generate({ "html_content" => "<div>" })]
270
+ end
271
+ end
272
+ build_client(stubs).generate_template_from_prompt(prompt: "an invoice")
273
+ expect(called).to be(true)
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+
5
+ require "deckle"
6
+ require "faraday"
7
+ require "json"
8
+
9
+ RSpec.configure do |config|
10
+ config.expect_with :rspec do |expectations|
11
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
12
+ end
13
+
14
+ config.mock_with :rspec do |mocks|
15
+ mocks.verify_partial_doubles = true
16
+ end
17
+
18
+ config.shared_context_metadata_behavior = :apply_to_host_groups
19
+ config.disable_monkey_patching!
20
+ config.order = :random
21
+ Kernel.srand config.seed
22
+ end
23
+
24
+ module DeckleSpecHelpers
25
+ BASE_URL = "https://api.test.local"
26
+
27
+ # Build a Deckle::Client whose Faraday connection uses the supplied
28
+ # Faraday::Adapter::Test::Stubs. Real network is never touched.
29
+ def build_client(stubs, api_key: "dk_live_test", max_retries: 0)
30
+ client = Deckle::Client.new(
31
+ api_key: api_key,
32
+ base_url: BASE_URL,
33
+ timeout: 5,
34
+ max_retries: max_retries
35
+ )
36
+ test_conn = Faraday.new(url: BASE_URL) do |f|
37
+ f.headers["Authorization"] = "Bearer #{api_key}"
38
+ f.headers["Content-Type"] = "application/json"
39
+ f.headers["User-Agent"] = "deckle-ruby/#{Deckle::VERSION}"
40
+ f.adapter(:test, stubs)
41
+ end
42
+ client.instance_variable_set(:@conn, test_conn)
43
+ client
44
+ end
45
+ end
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: deckle
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Deckle
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.13'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.13'
40
+ description: Generate PDFs from HTML, templates, and React components using the Deckle
41
+ API.
42
+ email:
43
+ - support@getdeckle.dev
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - README.md
49
+ - lib/deckle.rb
50
+ - lib/deckle/client.rb
51
+ - lib/deckle/errors.rb
52
+ - lib/deckle/templates.rb
53
+ - lib/deckle/version.rb
54
+ - spec/deckle/client_spec.rb
55
+ - spec/spec_helper.rb
56
+ homepage: https://getdeckle.dev
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ homepage_uri: https://getdeckle.dev
61
+ source_code_uri: https://github.com/Yoshyaes/deckle
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '3.0'
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 4.0.10
77
+ specification_version: 4
78
+ summary: Ruby SDK for the Deckle PDF generation API
79
+ test_files:
80
+ - spec/deckle/client_spec.rb
81
+ - spec/spec_helper.rb