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 +7 -0
- data/README.md +84 -0
- data/lib/deckle/client.rb +386 -0
- data/lib/deckle/errors.rb +47 -0
- data/lib/deckle/templates.rb +64 -0
- data/lib/deckle/version.rb +5 -0
- data/lib/deckle.rb +6 -0
- data/spec/deckle/client_spec.rb +276 -0
- data/spec/spec_helper.rb +45 -0
- metadata +81 -0
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
|
data/lib/deckle.rb
ADDED
|
@@ -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
|
data/spec/spec_helper.rb
ADDED
|
@@ -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
|