signingstudio 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/CHANGELOG.md +20 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/lib/signingstudio/client.rb +38 -0
- data/lib/signingstudio/configuration.rb +31 -0
- data/lib/signingstudio/errors.rb +49 -0
- data/lib/signingstudio/http_client.rb +207 -0
- data/lib/signingstudio/resources/documents.rb +94 -0
- data/lib/signingstudio/resources/templates.rb +132 -0
- data/lib/signingstudio/version.rb +5 -0
- data/lib/signingstudio/webhooks/verifier.rb +51 -0
- data/lib/signingstudio.rb +8 -0
- data/signingstudio.gemspec +34 -0
- metadata +148 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a1cca61538db4db313df61c802371547979134e4884f7862e93921b7b6d26dd7
|
|
4
|
+
data.tar.gz: ecf355dd098c824253323d377686ffd4a7474f8f9ad5a9799029e9504dd171b8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: dbd5edef52f10caf45ca88379f2ba4b804e47954be9a1ffc5549f4089c7a59410fbd8f5da44630eeaebd32515d88b4718bc806b4acf5cc55a9f3d94f708bf8fb
|
|
7
|
+
data.tar.gz: 282f51f2d61d5b6ebb0f2fea86085ba32c43ac2b6ab98cb8c83533681280eb86b18af60378c50b069cbadb60142b04eef7a26b79b0e8def650437952ae116b7c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `signingstudio` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [1.0.0] — 2026-07-22
|
|
8
|
+
|
|
9
|
+
Initial public release. Covers 100% of the Signing Studio v1 REST API.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `Signingstudio::Client` with `documents` and `templates` resources.
|
|
13
|
+
- `Documents`: list, send, get, delete (soft + hard), restore, archive, unarchive, cancel, progress, download_url, download_pdf, activity, remind.
|
|
14
|
+
- `Templates`: list, create (PDF path / IO / bytes), get, update, delete, pdf_url, download_pdf, replace_pdf, set_fields, duplicate, archive, history, history_pdf_url, download_history_pdf.
|
|
15
|
+
- `Signingstudio::Webhooks::Verifier` — constant-time HMAC-SHA256 verification via `OpenSSL.fixed_length_secure_compare`.
|
|
16
|
+
- Typed exception hierarchy: `Signingstudio::Error` → `ApiError` → `AuthenticationError`, `NotFoundError`, `ValidationError`, `RateLimitError`.
|
|
17
|
+
- Bounded retry policy — 429 (with Retry-After ≤ 60s), 5xx, and network errors retried with exponential backoff + jitter.
|
|
18
|
+
- Faraday-based transport with connection pooling suitable for Sidekiq / Rails / cron workloads.
|
|
19
|
+
- RSpec test suite with WebMock covering the transport layer, resource dispatch, and webhook verifier.
|
|
20
|
+
- GitHub Actions CI on Ruby 3.0 – 3.3; release workflow publishes to RubyGems via OIDC trusted publishing.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Signing Studio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Signing Studio Ruby SDK
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/signingstudio)
|
|
4
|
+
[](https://github.com/alyasdds/signingstudio-ruby/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Official Ruby client for the **[Signing Studio](https://signingstudio.com)** e-signature API. Covers every public v1 endpoint — send documents from templates, poll signing progress, manage templates and their fields, and verify webhook deliveries.
|
|
8
|
+
|
|
9
|
+
Requires **Ruby 3.0+**.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
gem install signingstudio
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or add to your Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "signingstudio", "~> 1.0"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "signingstudio"
|
|
27
|
+
|
|
28
|
+
client = Signingstudio::Client.new(api_key: ENV.fetch("SIGNING_STUDIO_API_KEY"))
|
|
29
|
+
|
|
30
|
+
doc = client.documents.send(
|
|
31
|
+
template_id: "11111111-2222-3333-4444-555555555555",
|
|
32
|
+
title: "MSA — Acme",
|
|
33
|
+
recipients: [{ name: "Alex Doe", email: "alex@acme.com" }],
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
puts "Sent #{doc["id"]} (#{doc["status"]})"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Get your API key from **Signing Studio → Settings → API Keys**. The plaintext key is shown once at creation — store it in Rails credentials, Vault, or an environment variable.
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
client = Signingstudio::Client.new(
|
|
45
|
+
api_key: "sk_live_...",
|
|
46
|
+
base_url: "https://api.signingstudio.com", # default
|
|
47
|
+
max_retries: 3, # 429 + 5xx + network
|
|
48
|
+
request_timeout: 120, # seconds
|
|
49
|
+
user_agent: "my-app/1.4",
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Retry policy** — conservative and predictable:
|
|
54
|
+
- **429** with `Retry-After ≤ 60s` → sleep and retry, up to `max_retries`.
|
|
55
|
+
- **429** with `Retry-After > 60s` (typically monthly quota) → raise `RateLimitError` immediately.
|
|
56
|
+
- **5xx** and **network errors** → exponential backoff (500ms · 1s · 2s · 4s) with jitter.
|
|
57
|
+
|
|
58
|
+
## Documents
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
listing = client.documents.list(status: "sent", view: "active", limit: 50)
|
|
62
|
+
|
|
63
|
+
doc = client.documents.send(
|
|
64
|
+
template_id: template_id,
|
|
65
|
+
title: "MSA — Acme",
|
|
66
|
+
subject: "Please sign",
|
|
67
|
+
message: "Signing at your convenience",
|
|
68
|
+
expires_at: "2026-08-01T00:00:00Z",
|
|
69
|
+
recipients: [
|
|
70
|
+
{ name: "Alex", email: "alex@acme.com", signing_order: 0 },
|
|
71
|
+
{ name: "Bo", email: "bo@acme.com", signing_order: 1 },
|
|
72
|
+
],
|
|
73
|
+
prefill_values: [{ field_name: "company", value: "Acme Inc." }],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
client.documents.get(doc["id"])
|
|
77
|
+
client.documents.progress(doc["id"]) # cheap; ideal for polling
|
|
78
|
+
client.documents.activity(doc["id"])
|
|
79
|
+
|
|
80
|
+
client.documents.cancel(doc["id"])
|
|
81
|
+
client.documents.archive(doc["id"])
|
|
82
|
+
client.documents.unarchive(doc["id"])
|
|
83
|
+
client.documents.restore(doc["id"])
|
|
84
|
+
client.documents.delete(doc["id"]) # soft
|
|
85
|
+
client.documents.delete(doc["id"], hard: true) # hard purge
|
|
86
|
+
|
|
87
|
+
remind = client.documents.remind(doc["id"], recipient_id)
|
|
88
|
+
fresh_url = remind["signing_url"]
|
|
89
|
+
|
|
90
|
+
url = client.documents.download_url(doc["id"])["url"]
|
|
91
|
+
bytes = client.documents.download_pdf(doc["id"])
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Templates
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
templates = client.templates.list
|
|
98
|
+
|
|
99
|
+
template = client.templates.create(
|
|
100
|
+
"./msa.pdf",
|
|
101
|
+
{
|
|
102
|
+
name: "MSA v2",
|
|
103
|
+
signer_count: 1,
|
|
104
|
+
delivery_methods: ["email"],
|
|
105
|
+
signers: [{ role: "Customer", delivery: ["email"] }],
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
client.templates.update(template["id"], name: "MSA v3")
|
|
110
|
+
client.templates.delete(template["id"])
|
|
111
|
+
|
|
112
|
+
client.templates.replace_pdf(template["id"], "./msa-updated.pdf")
|
|
113
|
+
versions = client.templates.history(template["id"])
|
|
114
|
+
old_pdf_url = client.templates.history_pdf_url(template["id"], versions[0]["id"])["url"]
|
|
115
|
+
|
|
116
|
+
client.templates.set_fields(template["id"], [
|
|
117
|
+
{ field_type: "signature", page: 1, x: 60, y: 82, width: 30, height: 6, signer_index: 0, required: true },
|
|
118
|
+
{ field_type: "text", page: 1, x: 10, y: 20, width: 30, height: 4, signer_index: 0, name: "company", label: "Company name", required: true },
|
|
119
|
+
])
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### PDF upload constraints
|
|
123
|
+
|
|
124
|
+
- Max **50 MB** per file.
|
|
125
|
+
- `application/pdf` only.
|
|
126
|
+
- Multipart field name must be `file` — the SDK sets this for you.
|
|
127
|
+
|
|
128
|
+
## Webhooks
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
require "sinatra"
|
|
132
|
+
require "json"
|
|
133
|
+
require "signingstudio"
|
|
134
|
+
|
|
135
|
+
SECRET = ENV.fetch("SIGNING_STUDIO_WEBHOOK_SECRET")
|
|
136
|
+
verifier = Signingstudio::Client.webhook_verifier(SECRET)
|
|
137
|
+
|
|
138
|
+
post "/webhooks/signing-studio" do
|
|
139
|
+
raw = request.body.read # RAW body — do NOT re-serialize
|
|
140
|
+
sig = request.env["HTTP_X_DDS_SIGNATURE"] || ""
|
|
141
|
+
halt 401 unless verifier.valid?(raw, sig)
|
|
142
|
+
|
|
143
|
+
payload = JSON.parse(raw)
|
|
144
|
+
# payload["event"] is one of:
|
|
145
|
+
# "document.sent" | "document.viewed" | "document.signed"
|
|
146
|
+
# | "document.declined" | "document.completed"
|
|
147
|
+
content_type :json
|
|
148
|
+
{ received: true }.to_json
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Always sign the RAW body**, not a parsed-and-re-serialized body.
|
|
153
|
+
|
|
154
|
+
## Errors
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
begin
|
|
158
|
+
client.documents.send(payload)
|
|
159
|
+
rescue Signingstudio::ValidationError => e
|
|
160
|
+
# e.errors is Hash{String => Array<String>}
|
|
161
|
+
rescue Signingstudio::RateLimitError => e
|
|
162
|
+
# e.window is "minute" | "day" | nil
|
|
163
|
+
# e.retry_after is Integer | nil
|
|
164
|
+
rescue Signingstudio::AuthenticationError
|
|
165
|
+
# Refresh the API key.
|
|
166
|
+
rescue Signingstudio::NotFoundError
|
|
167
|
+
# Doesn't exist on this tenant.
|
|
168
|
+
rescue Signingstudio::ApiError => e
|
|
169
|
+
Rails.logger.error("signingstudio request=#{e.request_id} status=#{e.status_code}")
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
All SDK-raised exceptions inherit from `Signingstudio::Error`.
|
|
174
|
+
|
|
175
|
+
## Rate limits
|
|
176
|
+
|
|
177
|
+
Every response carries:
|
|
178
|
+
|
|
179
|
+
- `X-RateLimit-Limit-Minute`, `X-RateLimit-Remaining-Minute`
|
|
180
|
+
- `X-RateLimit-Limit-Day`, `X-RateLimit-Remaining-Day`
|
|
181
|
+
|
|
182
|
+
Platform defaults: **120 req/min** and **20,000 req/day** per API key.
|
|
183
|
+
|
|
184
|
+
## Testing
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
bundle install
|
|
188
|
+
bundle exec rspec
|
|
189
|
+
bundle exec rubocop
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
CI runs against Ruby 3.0 – 3.3 on every push/PR.
|
|
193
|
+
|
|
194
|
+
## Versioning
|
|
195
|
+
|
|
196
|
+
Semantic versioning. `CHANGELOG.md` records every release.
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "signingstudio/configuration"
|
|
4
|
+
require "signingstudio/http_client"
|
|
5
|
+
require "signingstudio/resources/documents"
|
|
6
|
+
require "signingstudio/resources/templates"
|
|
7
|
+
require "signingstudio/webhooks/verifier"
|
|
8
|
+
|
|
9
|
+
module Signingstudio
|
|
10
|
+
# Top-level entry point.
|
|
11
|
+
#
|
|
12
|
+
# client = Signingstudio::Client.new(api_key: ENV["SIGNING_STUDIO_API_KEY"])
|
|
13
|
+
# doc = client.documents.send(
|
|
14
|
+
# template_id: "11111111-2222-3333-4444-555555555555",
|
|
15
|
+
# title: "MSA — Acme",
|
|
16
|
+
# recipients: [{ name: "Alex", email: "alex@acme.com" }],
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# Client instances are thread-safe — Faraday keeps a per-host connection
|
|
20
|
+
# pool internally, so a single instance is the recommended shape for
|
|
21
|
+
# Sidekiq workers, Rails apps, and cron scripts.
|
|
22
|
+
class Client
|
|
23
|
+
attr_reader :documents, :templates, :http
|
|
24
|
+
|
|
25
|
+
def initialize(api_key: nil, config: nil, **opts)
|
|
26
|
+
resolved = config || Configuration.new(api_key: api_key, **opts)
|
|
27
|
+
@http = HttpClient.new(resolved)
|
|
28
|
+
@documents = Resources::Documents.new(@http)
|
|
29
|
+
@templates = Resources::Templates.new(@http)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Build a webhook verifier. Stand-alone rather than a method on Client
|
|
33
|
+
# so receivers can construct it without also building an API client.
|
|
34
|
+
def self.webhook_verifier(secret)
|
|
35
|
+
Webhooks::Verifier.new(secret)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Signingstudio
|
|
4
|
+
# Immutable configuration for a Client. Build with keyword args or
|
|
5
|
+
# populate individual attributes on a fresh instance.
|
|
6
|
+
class Configuration
|
|
7
|
+
DEFAULT_BASE_URL = "https://api.signingstudio.com"
|
|
8
|
+
DEFAULT_MAX_RETRIES = 3
|
|
9
|
+
DEFAULT_REQUEST_TIMEOUT = 120 # seconds
|
|
10
|
+
DEFAULT_USER_AGENT = "signingstudio-ruby/1.0"
|
|
11
|
+
|
|
12
|
+
attr_reader :api_key, :base_url, :max_retries, :request_timeout, :user_agent
|
|
13
|
+
|
|
14
|
+
def initialize(
|
|
15
|
+
api_key:,
|
|
16
|
+
base_url: DEFAULT_BASE_URL,
|
|
17
|
+
max_retries: DEFAULT_MAX_RETRIES,
|
|
18
|
+
request_timeout: DEFAULT_REQUEST_TIMEOUT,
|
|
19
|
+
user_agent: DEFAULT_USER_AGENT
|
|
20
|
+
)
|
|
21
|
+
raise ArgumentError, "api_key must not be empty" if api_key.nil? || api_key.empty?
|
|
22
|
+
raise ArgumentError, "max_retries must be non-negative" if max_retries.negative?
|
|
23
|
+
|
|
24
|
+
@api_key = api_key
|
|
25
|
+
@base_url = base_url
|
|
26
|
+
@max_retries = max_retries
|
|
27
|
+
@request_timeout = request_timeout
|
|
28
|
+
@user_agent = user_agent
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Signingstudio
|
|
4
|
+
# Base class for every exception raised by the SDK. Catch this at the
|
|
5
|
+
# outer boundary of your code and inspect the subclass for specifics.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Non-2xx HTTP response from the API. Specific status codes (401, 404,
|
|
9
|
+
# 422, 429) are surfaced as dedicated subclasses.
|
|
10
|
+
class ApiError < Error
|
|
11
|
+
attr_reader :status_code, :request_id, :body
|
|
12
|
+
|
|
13
|
+
def initialize(message, status_code:, request_id: nil, body: nil)
|
|
14
|
+
super(message)
|
|
15
|
+
@status_code = status_code
|
|
16
|
+
@request_id = request_id
|
|
17
|
+
@body = body
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# HTTP 401 — API key missing / unknown / account inactive.
|
|
22
|
+
class AuthenticationError < ApiError; end
|
|
23
|
+
|
|
24
|
+
# HTTP 404 — resource doesn't exist under this tenant.
|
|
25
|
+
class NotFoundError < ApiError; end
|
|
26
|
+
|
|
27
|
+
# HTTP 422 — Zod schema or business-rule failure. #errors is the
|
|
28
|
+
# field-name → messages map from the server.
|
|
29
|
+
class ValidationError < ApiError
|
|
30
|
+
attr_reader :errors
|
|
31
|
+
|
|
32
|
+
def initialize(message, status_code:, request_id: nil, body: nil, errors: {})
|
|
33
|
+
super(message, status_code: status_code, request_id: request_id, body: body)
|
|
34
|
+
@errors = errors || {}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# HTTP 429 — sliding-window rate limit or monthly quota. #retry_after is
|
|
39
|
+
# populated for sliding-window; nil for quota exhaustion.
|
|
40
|
+
class RateLimitError < ApiError
|
|
41
|
+
attr_reader :retry_after, :window
|
|
42
|
+
|
|
43
|
+
def initialize(message, status_code:, request_id: nil, body: nil, retry_after: nil, window: nil)
|
|
44
|
+
super(message, status_code: status_code, request_id: request_id, body: body)
|
|
45
|
+
@retry_after = retry_after
|
|
46
|
+
@window = window
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/multipart"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
require "signingstudio/errors"
|
|
8
|
+
|
|
9
|
+
module Signingstudio
|
|
10
|
+
# HTTP transport. Wraps Faraday with:
|
|
11
|
+
#
|
|
12
|
+
# - Authorization header injection
|
|
13
|
+
# - JSON / multipart body handling
|
|
14
|
+
# - Envelope unwrap ({ success, data, meta } → ApiResponse)
|
|
15
|
+
# - Status → typed exception mapping
|
|
16
|
+
# - Bounded retry on 429 (with Retry-After ≤ 60s), 5xx, and network errors
|
|
17
|
+
class HttpClient
|
|
18
|
+
# Result wrapper — data, meta, rate-limit metadata + request id.
|
|
19
|
+
ApiResponse = Struct.new(:status_code, :data, :meta, :request_id, :rate_limit, keyword_init: true) do
|
|
20
|
+
def raw_bytes
|
|
21
|
+
# Populated only for binary requests. #data holds the string body
|
|
22
|
+
# when the transport is asked for bytes; expose as an alias here for
|
|
23
|
+
# readability.
|
|
24
|
+
data
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
RateLimit = Struct.new(:remaining_minute, :remaining_day, :limit_minute, :limit_day, keyword_init: true)
|
|
29
|
+
|
|
30
|
+
def initialize(config)
|
|
31
|
+
@config = config
|
|
32
|
+
@faraday = Faraday.new(
|
|
33
|
+
url: "#{config.base_url.chomp("/")}/api/v1/",
|
|
34
|
+
request: { timeout: config.request_timeout, open_timeout: 10 },
|
|
35
|
+
) do |f|
|
|
36
|
+
f.request :multipart
|
|
37
|
+
f.adapter Faraday.default_adapter
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def request_json(method, path, query: nil, json: nil, multipart: nil)
|
|
42
|
+
execute(method, path, query: query, json: json, multipart: multipart, binary: false)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def request_binary(method, path, query: nil)
|
|
46
|
+
execute(method, path, query: query, binary: true)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def execute(method, path, query: nil, json: nil, multipart: nil, binary: false)
|
|
52
|
+
attempt = 0
|
|
53
|
+
loop do
|
|
54
|
+
response =
|
|
55
|
+
begin
|
|
56
|
+
@faraday.run_request(method, path.sub(%r{^/+}, ""), body_for(json, multipart), headers(binary)) do |req|
|
|
57
|
+
req.params = normalize_query(query) if query
|
|
58
|
+
end
|
|
59
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
60
|
+
raise Error, "Network error contacting Signing Studio: #{e.message}" if attempt >= @config.max_retries
|
|
61
|
+
|
|
62
|
+
sleep(backoff_seconds(attempt))
|
|
63
|
+
attempt += 1
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
status = response.status
|
|
68
|
+
return unwrap(response, binary) if status.between?(200, 299)
|
|
69
|
+
|
|
70
|
+
if status == 429 && attempt < @config.max_retries
|
|
71
|
+
retry_after = extract_retry_after(response)
|
|
72
|
+
if retry_after && retry_after <= 60
|
|
73
|
+
sleep(retry_after)
|
|
74
|
+
attempt += 1
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if status >= 500 && attempt < @config.max_retries
|
|
80
|
+
sleep(backoff_seconds(attempt))
|
|
81
|
+
attempt += 1
|
|
82
|
+
next
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
raise build_api_error(response, status)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def body_for(json, multipart)
|
|
90
|
+
return multipart if multipart
|
|
91
|
+
return json.to_json if json
|
|
92
|
+
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def headers(binary)
|
|
97
|
+
{
|
|
98
|
+
"Authorization" => "Bearer #{@config.api_key}",
|
|
99
|
+
"User-Agent" => @config.user_agent,
|
|
100
|
+
"Accept" => binary ? "application/pdf, application/octet-stream, */*" : "application/json",
|
|
101
|
+
"Content-Type" => "application/json",
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def unwrap(response, binary)
|
|
106
|
+
request_id = response.headers["x-request-id"]
|
|
107
|
+
rate_limit = read_rate_limit(response)
|
|
108
|
+
|
|
109
|
+
if binary
|
|
110
|
+
return ApiResponse.new(
|
|
111
|
+
status_code: response.status,
|
|
112
|
+
data: response.body,
|
|
113
|
+
meta: {},
|
|
114
|
+
request_id: request_id,
|
|
115
|
+
rate_limit: rate_limit,
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
body = response.body
|
|
120
|
+
envelope = body.nil? || body.empty? ? {} : parse_json(body, response.status)
|
|
121
|
+
ApiResponse.new(
|
|
122
|
+
status_code: response.status,
|
|
123
|
+
data: envelope["data"],
|
|
124
|
+
meta: (envelope["meta"].is_a?(Hash) ? envelope["meta"] : {}),
|
|
125
|
+
request_id: request_id,
|
|
126
|
+
rate_limit: rate_limit,
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def read_rate_limit(response)
|
|
131
|
+
RateLimit.new(
|
|
132
|
+
remaining_minute: parse_int(response.headers["x-ratelimit-remaining-minute"]),
|
|
133
|
+
remaining_day: parse_int(response.headers["x-ratelimit-remaining-day"]),
|
|
134
|
+
limit_minute: parse_int(response.headers["x-ratelimit-limit-minute"]),
|
|
135
|
+
limit_day: parse_int(response.headers["x-ratelimit-limit-day"]),
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def build_api_error(response, status)
|
|
140
|
+
body = safe_parse_json(response.body)
|
|
141
|
+
message = body.is_a?(Hash) && body["message"].is_a?(String) ? body["message"] : "Signing Studio API returned HTTP #{status}"
|
|
142
|
+
request_id = response.headers["x-request-id"] || (body.is_a?(Hash) ? body["requestId"] : nil)
|
|
143
|
+
|
|
144
|
+
common = { status_code: status, request_id: request_id, body: body }
|
|
145
|
+
|
|
146
|
+
case status
|
|
147
|
+
when 401
|
|
148
|
+
AuthenticationError.new(message, **common)
|
|
149
|
+
when 404
|
|
150
|
+
NotFoundError.new(message, **common)
|
|
151
|
+
when 422
|
|
152
|
+
errors = body.is_a?(Hash) && body["errors"].is_a?(Hash) ? body["errors"] : {}
|
|
153
|
+
ValidationError.new(message, **common, errors: errors)
|
|
154
|
+
when 429
|
|
155
|
+
RateLimitError.new(
|
|
156
|
+
message,
|
|
157
|
+
**common,
|
|
158
|
+
retry_after: extract_retry_after(response),
|
|
159
|
+
window: response.headers["x-ratelimit-window"],
|
|
160
|
+
)
|
|
161
|
+
else
|
|
162
|
+
ApiError.new(message, **common)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def parse_json(body, status)
|
|
167
|
+
JSON.parse(body)
|
|
168
|
+
rescue JSON::ParserError => e
|
|
169
|
+
raise Error, "Malformed JSON in server response (HTTP #{status}): #{e.message}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def safe_parse_json(body)
|
|
173
|
+
JSON.parse(body) unless body.nil? || body.empty?
|
|
174
|
+
rescue JSON::ParserError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def parse_int(str)
|
|
179
|
+
Integer(str)
|
|
180
|
+
rescue ArgumentError, TypeError
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def extract_retry_after(response)
|
|
185
|
+
parse_int(response.headers["retry-after"])
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def normalize_query(query)
|
|
189
|
+
query.each_with_object({}) do |(k, v), out|
|
|
190
|
+
next if v.nil?
|
|
191
|
+
|
|
192
|
+
out[k.to_s] = case v
|
|
193
|
+
when true then "true"
|
|
194
|
+
when false then "false"
|
|
195
|
+
else v.to_s
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Exponential backoff (500ms · 1s · 2s · 4s) plus up to 25% jitter to
|
|
201
|
+
# avoid herd effects when many callers retry at once.
|
|
202
|
+
def backoff_seconds(attempt)
|
|
203
|
+
base = 0.5 * (2**attempt)
|
|
204
|
+
base + rand * (base / 4.0)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Signingstudio
|
|
6
|
+
module Resources
|
|
7
|
+
# Documents resource — twelve endpoints under /api/v1/documents.
|
|
8
|
+
#
|
|
9
|
+
# Every method returns a plain Ruby Hash matching the shape documented
|
|
10
|
+
# in the Signing Studio API reference. The SDK does not coerce or
|
|
11
|
+
# reshape the response.
|
|
12
|
+
class Documents
|
|
13
|
+
def initialize(http)
|
|
14
|
+
@http = http
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# List documents. All parameters are optional keyword args.
|
|
18
|
+
#
|
|
19
|
+
# @param status [String] filter by document status (sent/completed/…)
|
|
20
|
+
# @param view [String] 'active' (default) | 'archived' | 'deleted' | 'all'
|
|
21
|
+
# @param limit [Integer] page size (server-capped at 200; default 50)
|
|
22
|
+
# @param offset [Integer] zero-based offset
|
|
23
|
+
#
|
|
24
|
+
# @return [Hash] { data: [...], meta: { total:, limit:, offset:, view: } }
|
|
25
|
+
def list(status: nil, view: nil, limit: nil, offset: nil)
|
|
26
|
+
response = @http.request_json(:get, "documents", query: {
|
|
27
|
+
status: status, view: view, limit: limit, offset: offset,
|
|
28
|
+
})
|
|
29
|
+
{ "data" => response.data || [], "meta" => response.meta }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Send a new document from a template.
|
|
33
|
+
#
|
|
34
|
+
# Server rules to know about:
|
|
35
|
+
# * template_id is required.
|
|
36
|
+
# * recipients must have at least one entry.
|
|
37
|
+
# * prefill_values need either field_id (uuid) or field_name + value.
|
|
38
|
+
# * signature/initials fields cannot be prefilled.
|
|
39
|
+
# * required+readonly template fields MUST be prefilled.
|
|
40
|
+
def send(payload)
|
|
41
|
+
@http.request_json(:post, "documents", json: payload).data
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def get(id)
|
|
45
|
+
@http.request_json(:get, "documents/#{enc(id)}").data
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Delete a document. hard: true purges the file from storage. Only
|
|
49
|
+
# valid on cancelled / completed / declined / expired documents.
|
|
50
|
+
def delete(id, hard: false)
|
|
51
|
+
query = hard ? { hard: "true" } : nil
|
|
52
|
+
@http.request_json(:delete, "documents/#{enc(id)}", query: query).data
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def restore(id) = @http.request_json(:post, "documents/#{enc(id)}/restore").data
|
|
56
|
+
def archive(id) = @http.request_json(:post, "documents/#{enc(id)}/archive").data
|
|
57
|
+
def unarchive(id) = @http.request_json(:post, "documents/#{enc(id)}/unarchive").data
|
|
58
|
+
def cancel(id) = @http.request_json(:post, "documents/#{enc(id)}/cancel").data
|
|
59
|
+
|
|
60
|
+
# Read current signing progress — cheaper than #get when polling.
|
|
61
|
+
def progress(id)
|
|
62
|
+
@http.request_json(:get, "documents/#{enc(id)}/progress").data
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Presigned download URL. Returns { "url" => "..." }. TTL ~1h.
|
|
66
|
+
def download_url(id)
|
|
67
|
+
@http.request_json(:get, "documents/#{enc(id)}/download").data
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Fetch the PDF as raw bytes (follows the presigned redirect).
|
|
71
|
+
def download_pdf(id)
|
|
72
|
+
@http.request_binary(:get, "documents/#{enc(id)}/download", query: { redirect: "1" }).raw_bytes
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def activity(id)
|
|
76
|
+
@http.request_json(:get, "documents/#{enc(id)}/activity").data || []
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Re-send the signing invite for a specific recipient. Response
|
|
80
|
+
# includes a freshly-minted signing URL — capture it if you need to
|
|
81
|
+
# relay it out-of-band; the URL is not stored server-side.
|
|
82
|
+
def remind(document_id, recipient_id)
|
|
83
|
+
@http.request_json(:post,
|
|
84
|
+
"documents/#{enc(document_id)}/recipients/#{enc(recipient_id)}/remind").data
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def enc(str)
|
|
90
|
+
CGI.escape(str.to_s)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "json"
|
|
5
|
+
require "faraday/multipart"
|
|
6
|
+
|
|
7
|
+
module Signingstudio
|
|
8
|
+
module Resources
|
|
9
|
+
# Templates resource — twelve endpoints under /api/v1/templates.
|
|
10
|
+
#
|
|
11
|
+
# PDF upload constraints (server-enforced, 400 on violation):
|
|
12
|
+
# - Max 50 MB per file
|
|
13
|
+
# - application/pdf only
|
|
14
|
+
# - Multipart form field must be `file` — the SDK sets this for you
|
|
15
|
+
class Templates
|
|
16
|
+
def initialize(http)
|
|
17
|
+
@http = http
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def list
|
|
21
|
+
@http.request_json(:get, "templates").data || []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Create a template from a PDF plus metadata.
|
|
25
|
+
#
|
|
26
|
+
# @param pdf [String, Pathname, IO] path or an open IO/StringIO
|
|
27
|
+
# @param metadata [Hash] template metadata (name required; see README)
|
|
28
|
+
# @param filename [String] override the recorded filename
|
|
29
|
+
def create(pdf, metadata, filename: nil)
|
|
30
|
+
body = build_multipart(pdf, filename, metadata)
|
|
31
|
+
@http.request_json(:post, "templates", multipart: body).data
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def get(id)
|
|
35
|
+
@http.request_json(:get, "templates/#{enc(id)}").data
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def update(id, metadata)
|
|
39
|
+
@http.request_json(:patch, "templates/#{enc(id)}", json: metadata).data
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Soft-delete only. Hard-delete is not exposed on v1.
|
|
43
|
+
def delete(id)
|
|
44
|
+
@http.request_json(:delete, "templates/#{enc(id)}").data
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def pdf_url(id)
|
|
48
|
+
@http.request_json(:get, "templates/#{enc(id)}/pdf").data
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def download_pdf(id)
|
|
52
|
+
@http.request_binary(:get, "templates/#{enc(id)}/pdf", query: { redirect: "1" }).raw_bytes
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Replace the PDF backing an existing template. Prior versions stay
|
|
56
|
+
# accessible via #history and #history_pdf_url.
|
|
57
|
+
def replace_pdf(id, pdf, filename: nil)
|
|
58
|
+
body = build_multipart(pdf, filename, nil)
|
|
59
|
+
@http.request_json(:put, "templates/#{enc(id)}/pdf", multipart: body).data
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Full-replace of every field on the template. The server deletes
|
|
63
|
+
# existing rows and inserts the supplied set; partial updates are
|
|
64
|
+
# not supported.
|
|
65
|
+
def set_fields(id, fields)
|
|
66
|
+
@http.request_json(:put, "templates/#{enc(id)}/fields", json: { fields: fields }).data || []
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def duplicate(id) = @http.request_json(:post, "templates/#{enc(id)}/duplicate").data
|
|
70
|
+
def archive(id) = @http.request_json(:post, "templates/#{enc(id)}/archive").data
|
|
71
|
+
|
|
72
|
+
def history(id)
|
|
73
|
+
@http.request_json(:get, "templates/#{enc(id)}/history").data || []
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def history_pdf_url(template_id, history_id)
|
|
77
|
+
@http.request_json(:get, "templates/#{enc(template_id)}/history/#{history_id}/pdf").data
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def download_history_pdf(template_id, history_id)
|
|
81
|
+
@http.request_binary(:get,
|
|
82
|
+
"templates/#{enc(template_id)}/history/#{history_id}/pdf",
|
|
83
|
+
query: { redirect: "1" }).raw_bytes
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Build the multipart hash Faraday expects. Nested arrays/objects go
|
|
89
|
+
# through JSON.generate so complex fields like `signers` and
|
|
90
|
+
# `delivery_methods` survive the form-data trip.
|
|
91
|
+
def build_multipart(pdf, filename, metadata)
|
|
92
|
+
parts = {}
|
|
93
|
+
(metadata || {}).each do |key, value|
|
|
94
|
+
next if value.nil?
|
|
95
|
+
|
|
96
|
+
parts[key] = case value
|
|
97
|
+
when String, Numeric then value.to_s
|
|
98
|
+
when TrueClass then "true"
|
|
99
|
+
when FalseClass then "false"
|
|
100
|
+
else JSON.generate(value)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
io, resolved_name = resolve_pdf(pdf, filename)
|
|
105
|
+
parts["file"] = Faraday::Multipart::FilePart.new(io, "application/pdf", resolved_name)
|
|
106
|
+
parts
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def resolve_pdf(pdf, override)
|
|
110
|
+
case pdf
|
|
111
|
+
when String
|
|
112
|
+
if File.file?(pdf)
|
|
113
|
+
[File.open(pdf, "rb"), override || File.basename(pdf)]
|
|
114
|
+
else
|
|
115
|
+
# Treat as raw bytes.
|
|
116
|
+
[StringIO.new(pdf), override || "template.pdf"]
|
|
117
|
+
end
|
|
118
|
+
when Pathname
|
|
119
|
+
[File.open(pdf.to_s, "rb"), override || File.basename(pdf.to_s)]
|
|
120
|
+
else
|
|
121
|
+
# Any object that responds to :read — File, StringIO, Tempfile.
|
|
122
|
+
name = override || (pdf.respond_to?(:path) && pdf.path ? File.basename(pdf.path) : "template.pdf")
|
|
123
|
+
[pdf, name]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def enc(str)
|
|
128
|
+
CGI.escape(str.to_s)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Signingstudio
|
|
6
|
+
module Webhooks
|
|
7
|
+
# Verify incoming Signing Studio webhook deliveries.
|
|
8
|
+
#
|
|
9
|
+
# The server signs the raw JSON body with HMAC-SHA256 keyed on the
|
|
10
|
+
# webhook's secret and sends it as `X-DDS-Signature: sha256=<hex>`.
|
|
11
|
+
#
|
|
12
|
+
# ALWAYS pass the RAW body — a parsed & re-serialized body will fail
|
|
13
|
+
# verification, even if it looks identical.
|
|
14
|
+
class Verifier
|
|
15
|
+
def initialize(secret)
|
|
16
|
+
raise ArgumentError, "webhook secret must not be empty" if secret.nil? || secret.empty?
|
|
17
|
+
|
|
18
|
+
@secret = secret
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def valid?(raw_body, header)
|
|
22
|
+
return false if header.nil? || header.empty?
|
|
23
|
+
|
|
24
|
+
received = header.start_with?("sha256=") ? header[7..] : header
|
|
25
|
+
# Hex is length-fixed; fast-fail rejects payloads mangled in transit
|
|
26
|
+
# before we spend cycles on the HMAC.
|
|
27
|
+
return false unless received.length == 64 && received.match?(/\A[0-9a-fA-F]+\z/)
|
|
28
|
+
|
|
29
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", @secret, raw_body)
|
|
30
|
+
secure_compare(expected, received.downcase)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Constant-time string comparison. Falls back to a manual XOR loop
|
|
36
|
+
# so ancient Ruby installs without OpenSSL#fixed_length_secure_compare
|
|
37
|
+
# still get timing safety.
|
|
38
|
+
def secure_compare(a, b)
|
|
39
|
+
return false unless a.bytesize == b.bytesize
|
|
40
|
+
|
|
41
|
+
if OpenSSL.respond_to?(:fixed_length_secure_compare)
|
|
42
|
+
return OpenSSL.fixed_length_secure_compare(a, b)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
result = 0
|
|
46
|
+
a.bytes.zip(b.bytes) { |x, y| result |= (x ^ y) }
|
|
47
|
+
result.zero?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/signingstudio/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "signingstudio"
|
|
7
|
+
spec.version = Signingstudio::VERSION
|
|
8
|
+
spec.authors = ["Signing Studio"]
|
|
9
|
+
spec.email = ["contact@signingstudio.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Official Ruby SDK for the Signing Studio e-signature API."
|
|
12
|
+
spec.description = "Full coverage of the v1 REST endpoints — send documents, manage templates, verify webhooks — with typed errors, retry-aware transport, and a small dependency footprint."
|
|
13
|
+
spec.homepage = "https://signingstudio.com"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
spec.required_ruby_version = ">= 3.0"
|
|
16
|
+
|
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
18
|
+
spec.metadata["source_code_uri"] = "https://github.com/alyasdds/signingstudio-ruby"
|
|
19
|
+
spec.metadata["documentation_uri"] = "https://signingstudio.com/docs"
|
|
20
|
+
spec.metadata["changelog_uri"] = "https://github.com/alyasdds/signingstudio-ruby/blob/main/CHANGELOG.md"
|
|
21
|
+
spec.metadata["bug_tracker_uri"] = "https://github.com/alyasdds/signingstudio-ruby/issues"
|
|
22
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
23
|
+
|
|
24
|
+
spec.files = Dir.glob(%w[lib/**/* LICENSE README.md CHANGELOG.md signingstudio.gemspec])
|
|
25
|
+
spec.require_paths = ["lib"]
|
|
26
|
+
|
|
27
|
+
spec.add_dependency "faraday", "~> 2.7"
|
|
28
|
+
spec.add_dependency "faraday-multipart", "~> 1.0"
|
|
29
|
+
|
|
30
|
+
spec.add_development_dependency "rspec", "~> 3.13"
|
|
31
|
+
spec.add_development_dependency "webmock", "~> 3.23"
|
|
32
|
+
spec.add_development_dependency "rubocop", "~> 1.65"
|
|
33
|
+
spec.add_development_dependency "rake", "~> 13.2"
|
|
34
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: signingstudio
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Signing Studio
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-23 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: faraday
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.7'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.7'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: faraday-multipart
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rspec
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.13'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.13'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: webmock
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.23'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.23'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rubocop
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '1.65'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '1.65'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: rake
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '13.2'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '13.2'
|
|
97
|
+
description: Full coverage of the v1 REST endpoints — send documents, manage templates,
|
|
98
|
+
verify webhooks — with typed errors, retry-aware transport, and a small dependency
|
|
99
|
+
footprint.
|
|
100
|
+
email:
|
|
101
|
+
- contact@signingstudio.com
|
|
102
|
+
executables: []
|
|
103
|
+
extensions: []
|
|
104
|
+
extra_rdoc_files: []
|
|
105
|
+
files:
|
|
106
|
+
- CHANGELOG.md
|
|
107
|
+
- LICENSE
|
|
108
|
+
- README.md
|
|
109
|
+
- lib/signingstudio.rb
|
|
110
|
+
- lib/signingstudio/client.rb
|
|
111
|
+
- lib/signingstudio/configuration.rb
|
|
112
|
+
- lib/signingstudio/errors.rb
|
|
113
|
+
- lib/signingstudio/http_client.rb
|
|
114
|
+
- lib/signingstudio/resources/documents.rb
|
|
115
|
+
- lib/signingstudio/resources/templates.rb
|
|
116
|
+
- lib/signingstudio/version.rb
|
|
117
|
+
- lib/signingstudio/webhooks/verifier.rb
|
|
118
|
+
- signingstudio.gemspec
|
|
119
|
+
homepage: https://signingstudio.com
|
|
120
|
+
licenses:
|
|
121
|
+
- MIT
|
|
122
|
+
metadata:
|
|
123
|
+
homepage_uri: https://signingstudio.com
|
|
124
|
+
source_code_uri: https://github.com/alyasdds/signingstudio-ruby
|
|
125
|
+
documentation_uri: https://signingstudio.com/docs
|
|
126
|
+
changelog_uri: https://github.com/alyasdds/signingstudio-ruby/blob/main/CHANGELOG.md
|
|
127
|
+
bug_tracker_uri: https://github.com/alyasdds/signingstudio-ruby/issues
|
|
128
|
+
rubygems_mfa_required: 'true'
|
|
129
|
+
post_install_message:
|
|
130
|
+
rdoc_options: []
|
|
131
|
+
require_paths:
|
|
132
|
+
- lib
|
|
133
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - ">="
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '3.0'
|
|
138
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
139
|
+
requirements:
|
|
140
|
+
- - ">="
|
|
141
|
+
- !ruby/object:Gem::Version
|
|
142
|
+
version: '0'
|
|
143
|
+
requirements: []
|
|
144
|
+
rubygems_version: 3.5.22
|
|
145
|
+
signing_key:
|
|
146
|
+
specification_version: 4
|
|
147
|
+
summary: Official Ruby SDK for the Signing Studio e-signature API.
|
|
148
|
+
test_files: []
|