easydocforms 0.1.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 +17 -0
- data/LICENSE +21 -0
- data/README.md +127 -0
- data/lib/easydocforms/client.rb +179 -0
- data/lib/easydocforms/errors.rb +73 -0
- data/lib/easydocforms/version.rb +5 -0
- data/lib/easydocforms/webhook.rb +87 -0
- data/lib/easydocforms.rb +24 -0
- data/lib/generators/easydocforms/webhook/templates/webhooks_controller.rb +33 -0
- data/lib/generators/easydocforms/webhook/webhook_generator.rb +42 -0
- metadata +60 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b8579650e0df3df94f0102fb155b27ff8085cbe2739ba087446dd26bbd872239
|
|
4
|
+
data.tar.gz: cdbb3095f0d0ede335a3f531dc47c98d0c0b758e00b4322a30371e31e7c56778
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b5d2fa258cd32821ab501c0eade49587e7b97b2dca1ed9dd8e71ff0f09af1682e6edb1cf692c454676056e260121e2c2c13ff5bc435570899e72033eaa039f25
|
|
7
|
+
data.tar.gz: 5f0244065924672668c3462488d1dc20e796a264ceb1de041c83590ebec9ef106f4bed699690873e0464dbccc2d6aecd3b63ae31b1b1192d69078f393c15bdc6
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- `EasyDocForms::Client` covering every Partner API v1 operation: ping,
|
|
8
|
+
imports (create / get / wait), templates, fill links, submissions
|
|
9
|
+
(get / download PDF / signed pdf-link), webhooks (create / list / delete /
|
|
10
|
+
test).
|
|
11
|
+
- `EasyDocForms::Webhook.verify!` / `.construct_event` implementing the
|
|
12
|
+
`X-EDF-Signature` contract: HMAC-SHA256 over the raw body, constant-time
|
|
13
|
+
compare, ±5 minute replay tolerance.
|
|
14
|
+
- Typed errors: `AuthenticationError`, `PermissionError`, `NotFoundError`,
|
|
15
|
+
`PDFPendingError`, `RateLimitError`.
|
|
16
|
+
- `rails generate easydocforms:webhook` — a verified receiver controller.
|
|
17
|
+
- Zero runtime dependencies.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EasyDocForms (easydocforms.com)
|
|
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,127 @@
|
|
|
1
|
+
# easydocforms
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/easydocforms)
|
|
4
|
+
[](https://github.com/easydocforms/easydocforms-ruby/actions/workflows/ci.yml)
|
|
5
|
+
|
|
6
|
+
The official Ruby SDK for the [EasyDocForms](https://easydocforms.com) Partner API.
|
|
7
|
+
|
|
8
|
+
EasyDocForms turns a blank PDF intake form into a hosted, mobile-friendly fillable form — and returns the completed, pixel-exact PDF plus structured JSON answers. The API wraps the same document-understanding pipeline EasyDocForms runs in production for healthcare intake: import a blank PDF, wait for the template, mint a hosted fill link, hand it to a patient, then retrieve the results.
|
|
9
|
+
|
|
10
|
+
**Zero runtime dependencies** — standard library only (`net/http`, `json`, `openssl`).
|
|
11
|
+
|
|
12
|
+
- API reference: <https://easydocforms.com/docs/api>
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# Gemfile
|
|
18
|
+
gem "easydocforms"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quickstart
|
|
22
|
+
|
|
23
|
+
API keys are created in the EasyDocForms app under **Settings → Integrations → Partner API** (shown exactly once).
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "easydocforms"
|
|
27
|
+
|
|
28
|
+
client = EasyDocForms::Client.new(ENV["EASYDOCFORMS_API_KEY"])
|
|
29
|
+
pong = client.ping
|
|
30
|
+
puts "org #{pong[:org_id]}, key #{pong[:key_name]}, scopes #{pong[:scopes].join(", ")}"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## The full loop
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# 1. Import a blank PDF (async — returns immediately).
|
|
37
|
+
import = client.create_import(
|
|
38
|
+
pdf_url: "https://example.com/new-patient-intake.pdf",
|
|
39
|
+
filename: "new-patient-intake.pdf",
|
|
40
|
+
blank_form_attestation: true # you attest the PDF is a blank template — no PHI
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# 2. Wait for processing (typically 1–10 minutes). Imports never fail for
|
|
44
|
+
# quality reasons: the template is always created, and :review_required tells
|
|
45
|
+
# your staff what to double-check in the EasyDocForms editor.
|
|
46
|
+
import = client.wait_for_import(import[:import_id])
|
|
47
|
+
raise import[:error] if import[:status] == "failed"
|
|
48
|
+
|
|
49
|
+
# 3. Mint a hosted fill link and hand it to the patient. No EasyDocForms
|
|
50
|
+
# account needed on their side.
|
|
51
|
+
link = client.create_fill_link(
|
|
52
|
+
template_id: import[:template_id],
|
|
53
|
+
external_ref: "visit-8675309" # your correlation id — must not contain PHI
|
|
54
|
+
)
|
|
55
|
+
puts "send the patient to: #{link[:url]}"
|
|
56
|
+
|
|
57
|
+
# 4. When the patient submits (see webhooks below), fetch the results.
|
|
58
|
+
submission = client.get_submission(submission_id)
|
|
59
|
+
submission[:answers] # => { field_id => value, ... }
|
|
60
|
+
|
|
61
|
+
File.binwrite("completed.pdf", client.download_submission_pdf(submission_id))
|
|
62
|
+
|
|
63
|
+
# Or get a ~10-minute signed URL that needs no Authorization header — safe to
|
|
64
|
+
# hand to a browser or EMR without embedding your API key.
|
|
65
|
+
begin
|
|
66
|
+
pdf_link = client.get_submission_pdf_link(submission_id)
|
|
67
|
+
rescue EasyDocForms::PDFPendingError
|
|
68
|
+
# The frozen artifact isn't ready yet; use download_submission_pdf instead.
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Webhooks
|
|
73
|
+
|
|
74
|
+
Register a delivery URL, store the one-time `whsec_*` secret, and verify every delivery's `X-EDF-Signature` header against the **raw** request body:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
result = client.create_webhook(url: "https://your-app.example.com/webhooks/easydocforms")
|
|
78
|
+
result[:secret] # shown only once — store it now
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
event = EasyDocForms::Webhook.construct_event(
|
|
83
|
+
payload: request.body.read,
|
|
84
|
+
header: request.headers[EasyDocForms::Webhook::HEADER],
|
|
85
|
+
secret: ENV["EASYDOCFORMS_WEBHOOK_SECRET"]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
case event[:event]
|
|
89
|
+
when "submission.created"
|
|
90
|
+
# PHI-minimized: no answers in the payload. Fetch them with your API key
|
|
91
|
+
# via event[:data][:submission_id].
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Verification recomputes an HMAC-SHA256 over the raw body, compares in constant time, and rejects timestamps more than 5 minutes from now (configurable via `tolerance:`). A failed check raises `EasyDocForms::SignatureVerificationError` — respond `400` and move on.
|
|
96
|
+
|
|
97
|
+
### Rails
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
rails generate easydocforms:webhook
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
creates a verified receiver controller at `app/controllers/easydocforms_webhooks_controller.rb` and prints the route + credentials setup.
|
|
104
|
+
|
|
105
|
+
## Error handling
|
|
106
|
+
|
|
107
|
+
API failures raise typed subclasses of `EasyDocForms::APIError`, each carrying `status_code`, the server's message, and a machine-readable `code` on authorization failures:
|
|
108
|
+
|
|
109
|
+
| Error | When |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `AuthenticationError` | 401 — missing, invalid, or revoked key |
|
|
112
|
+
| `PermissionError` | 403 — `code` is `SCOPE_REQUIRED` or `PARTNER_API_NOT_ENABLED` |
|
|
113
|
+
| `NotFoundError` | 404 — no such resource in your organization |
|
|
114
|
+
| `PDFPendingError` | 409 on `get_submission_pdf_link` — fall back to `download_submission_pdf` |
|
|
115
|
+
| `RateLimitError` | 429 — back off and retry |
|
|
116
|
+
|
|
117
|
+
The SDK does not retry automatically.
|
|
118
|
+
|
|
119
|
+
## PHI boundary
|
|
120
|
+
|
|
121
|
+
- **Imports are blank forms only.** Every import requires `blank_form_attestation: true`, asserting the PDF contains no patient-identifiable information.
|
|
122
|
+
- **`external_ref` must never contain PHI.** It is an opaque correlation id echoed on submissions and webhook events.
|
|
123
|
+
- **Webhook payloads are PHI-minimized by design** — ids and retrieve URLs, never patient answers. Answers are only available over the authenticated API.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module EasyDocForms
|
|
8
|
+
# Client for the EasyDocForms Partner API. Thread-safe; standard library
|
|
9
|
+
# only.
|
|
10
|
+
#
|
|
11
|
+
# client = EasyDocForms::Client.new("edfk_live_...")
|
|
12
|
+
# client.ping # => { org_id: "...", key_name: "...", scopes: [...] }
|
|
13
|
+
#
|
|
14
|
+
# All methods return Hashes with symbol keys, shaped exactly like the API's
|
|
15
|
+
# JSON (see https://easydocforms.com/docs/api). API failures raise
|
|
16
|
+
# EasyDocForms::APIError subclasses.
|
|
17
|
+
class Client
|
|
18
|
+
USER_AGENT = "easydocforms-ruby/#{VERSION}"
|
|
19
|
+
|
|
20
|
+
# @param api_key [String] an edfk_live_* key, created in the EasyDocForms
|
|
21
|
+
# app under Settings → Integrations → Partner API (shown exactly once).
|
|
22
|
+
def initialize(api_key, base_url: DEFAULT_BASE_URL, open_timeout: 10, read_timeout: 60)
|
|
23
|
+
raise ArgumentError, "api_key is required" if api_key.to_s.empty?
|
|
24
|
+
|
|
25
|
+
@api_key = api_key
|
|
26
|
+
@base_url = base_url.sub(%r{/+\z}, "")
|
|
27
|
+
@open_timeout = open_timeout
|
|
28
|
+
@read_timeout = read_timeout
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Proves the key authenticates, names its organization, and echoes its
|
|
32
|
+
# scopes. Requires no scope.
|
|
33
|
+
def ping
|
|
34
|
+
request(:get, "/ping")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Imports a blank PDF (async; scope imports:write). Supply exactly one of
|
|
38
|
+
# +pdf_url+ (public HTTPS) or +pdf_base64+. +blank_form_attestation+ must
|
|
39
|
+
# be true: you attest the PDF is a blank template with no patient
|
|
40
|
+
# information (PHI). Returns { import_id:, status: "queued" } immediately;
|
|
41
|
+
# processing typically takes 1–10 minutes.
|
|
42
|
+
def create_import(filename:, blank_form_attestation:, pdf_url: nil, pdf_base64: nil, title: nil)
|
|
43
|
+
request(:post, "/imports", body: {
|
|
44
|
+
pdf_url: pdf_url,
|
|
45
|
+
pdf_base64: pdf_base64,
|
|
46
|
+
filename: filename,
|
|
47
|
+
title: title,
|
|
48
|
+
blank_form_attestation: blank_form_attestation
|
|
49
|
+
})
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Polls an import job (scope imports:write). Result fields appear when
|
|
53
|
+
# +:status+ is "succeeded"; +:error+ when "failed".
|
|
54
|
+
def get_import(import_id)
|
|
55
|
+
request(:get, "/imports/#{encode(import_id)}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Polls #get_import until the job reaches a terminal status ("succeeded"
|
|
59
|
+
# or "failed") and returns it — a failed import is returned, not raised.
|
|
60
|
+
# Raises EasyDocForms::Error after +timeout+ seconds.
|
|
61
|
+
def wait_for_import(import_id, poll_interval: 5, timeout: 900)
|
|
62
|
+
deadline = Time.now + timeout
|
|
63
|
+
loop do
|
|
64
|
+
import = get_import(import_id)
|
|
65
|
+
return import if %w[succeeded failed].include?(import[:status])
|
|
66
|
+
raise Error, "timed out after #{timeout}s waiting for import #{import_id}" if Time.now >= deadline
|
|
67
|
+
|
|
68
|
+
sleep(poll_interval)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The organization's active templates, newest first (scope
|
|
73
|
+
# templates:read). Returns the array of template hashes.
|
|
74
|
+
def list_templates
|
|
75
|
+
request(:get, "/templates").fetch(:templates)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Mints a hosted URL where a patient fills the form (scope
|
|
79
|
+
# fill_links:write). +external_ref+ is an opaque correlation id echoed on
|
|
80
|
+
# submissions and webhooks — it must never contain PHI.
|
|
81
|
+
def create_fill_link(template_id:, expires_in_days: nil, max_responses: nil, external_ref: nil)
|
|
82
|
+
request(:post, "/fill-links", body: {
|
|
83
|
+
template_id: template_id,
|
|
84
|
+
expires_in_days: expires_in_days,
|
|
85
|
+
max_responses: max_responses,
|
|
86
|
+
external_ref: external_ref
|
|
87
|
+
})
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A patient submission: structured +:answers+ plus correlation back to the
|
|
91
|
+
# fill link that produced it (scope submissions:read).
|
|
92
|
+
def get_submission(submission_id)
|
|
93
|
+
request(:get, "/submissions/#{encode(submission_id)}")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The completed, pixel-exact PDF as a binary string (scope
|
|
97
|
+
# submissions:read). Works even while +completed_pdf_status+ is "pending"
|
|
98
|
+
# (renders on demand — just slower).
|
|
99
|
+
def download_submission_pdf(submission_id)
|
|
100
|
+
request(:get, "/submissions/#{encode(submission_id)}/pdf", raw: true)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A time-limited signed URL (~10 minutes) that downloads the completed PDF
|
|
104
|
+
# without any Authorization header (scope submissions:read) — safe to hand
|
|
105
|
+
# onward without embedding your API key. Raises
|
|
106
|
+
# EasyDocForms::PDFPendingError while the frozen artifact isn't ready;
|
|
107
|
+
# fall back to #download_submission_pdf.
|
|
108
|
+
def get_submission_pdf_link(submission_id)
|
|
109
|
+
request(:get, "/submissions/#{encode(submission_id)}/pdf-link")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Registers a webhook delivery URL (scope webhooks:manage). The returned
|
|
113
|
+
# +:secret+ (whsec_*) is shown exactly once — store it; it verifies the
|
|
114
|
+
# X-EDF-Signature header (see EasyDocForms::Webhook). +events+ filters
|
|
115
|
+
# deliveries; nil or empty = all events.
|
|
116
|
+
def create_webhook(url:, events: nil)
|
|
117
|
+
request(:post, "/webhooks", body: { url: url, events: events })
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Active subscriptions, newest first, without secrets (scope
|
|
121
|
+
# webhooks:manage). Returns the array of webhook hashes.
|
|
122
|
+
def list_webhooks
|
|
123
|
+
request(:get, "/webhooks").fetch(:webhooks)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Deactivates a subscription (scope webhooks:manage).
|
|
127
|
+
def delete_webhook(webhook_id)
|
|
128
|
+
request(:delete, "/webhooks/#{encode(webhook_id)}")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Synchronously delivers one signed test event so you can verify your
|
|
132
|
+
# receiver end to end (scope webhooks:manage). Returns
|
|
133
|
+
# { delivered:, status_code: } — delivery failure is reported there, not
|
|
134
|
+
# raised.
|
|
135
|
+
def test_webhook(webhook_id)
|
|
136
|
+
request(:post, "/webhooks/#{encode(webhook_id)}/test")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def encode(id)
|
|
142
|
+
URI.encode_uri_component(id.to_s)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def request(method, path, body: nil, raw: false)
|
|
146
|
+
uri = URI.parse(@base_url + path)
|
|
147
|
+
req = build_request(method, uri, body, raw)
|
|
148
|
+
res = Net::HTTP.start(
|
|
149
|
+
uri.host, uri.port,
|
|
150
|
+
use_ssl: uri.scheme == "https",
|
|
151
|
+
open_timeout: @open_timeout, read_timeout: @read_timeout
|
|
152
|
+
) { |http| http.request(req) }
|
|
153
|
+
|
|
154
|
+
status = res.code.to_i
|
|
155
|
+
raise APIError.from_response(status, res.body) unless (200..299).cover?(status)
|
|
156
|
+
return res.body if raw
|
|
157
|
+
|
|
158
|
+
JSON.parse(res.body, symbolize_names: true)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def build_request(method, uri, body, raw)
|
|
162
|
+
req =
|
|
163
|
+
case method
|
|
164
|
+
when :get then Net::HTTP::Get.new(uri)
|
|
165
|
+
when :post then Net::HTTP::Post.new(uri)
|
|
166
|
+
when :delete then Net::HTTP::Delete.new(uri)
|
|
167
|
+
else raise ArgumentError, "unsupported method #{method}"
|
|
168
|
+
end
|
|
169
|
+
req["Authorization"] = "Bearer #{@api_key}"
|
|
170
|
+
req["User-Agent"] = USER_AGENT
|
|
171
|
+
req["Accept"] = raw ? "application/pdf" : "application/json"
|
|
172
|
+
if body
|
|
173
|
+
req["Content-Type"] = "application/json"
|
|
174
|
+
req.body = JSON.generate(body.compact)
|
|
175
|
+
end
|
|
176
|
+
req
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasyDocForms
|
|
4
|
+
# Base class for every error this gem raises.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a webhook delivery fails signature verification.
|
|
8
|
+
class SignatureVerificationError < Error; end
|
|
9
|
+
|
|
10
|
+
# An API-level failure: the request reached EasyDocForms and was rejected.
|
|
11
|
+
# Transport failures raise their ordinary Ruby exceptions instead.
|
|
12
|
+
class APIError < Error
|
|
13
|
+
# Machine-readable codes on authorization failures.
|
|
14
|
+
PARTNER_API_NOT_ENABLED = "PARTNER_API_NOT_ENABLED"
|
|
15
|
+
SCOPE_REQUIRED = "SCOPE_REQUIRED"
|
|
16
|
+
|
|
17
|
+
attr_reader :status_code, :code, :hint, :completed_pdf_status
|
|
18
|
+
|
|
19
|
+
def initialize(message, status_code:, code: nil, hint: nil, completed_pdf_status: nil)
|
|
20
|
+
super(message)
|
|
21
|
+
@status_code = status_code
|
|
22
|
+
@code = code
|
|
23
|
+
@hint = hint
|
|
24
|
+
@completed_pdf_status = completed_pdf_status
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Builds the right error subclass from an HTTP error response.
|
|
28
|
+
def self.from_response(status_code, body)
|
|
29
|
+
parsed = begin
|
|
30
|
+
JSON.parse(body.to_s)
|
|
31
|
+
rescue JSON::ParserError
|
|
32
|
+
{}
|
|
33
|
+
end
|
|
34
|
+
parsed = {} unless parsed.is_a?(Hash)
|
|
35
|
+
message = parsed["error"] || "HTTP #{status_code}"
|
|
36
|
+
attrs = {
|
|
37
|
+
status_code: status_code,
|
|
38
|
+
code: parsed["code"],
|
|
39
|
+
hint: parsed["hint"],
|
|
40
|
+
completed_pdf_status: parsed["completed_pdf_status"]
|
|
41
|
+
}
|
|
42
|
+
klass =
|
|
43
|
+
case status_code
|
|
44
|
+
when 401 then AuthenticationError
|
|
45
|
+
when 403 then PermissionError
|
|
46
|
+
when 404 then NotFoundError
|
|
47
|
+
when 409 then parsed["completed_pdf_status"] == "pending" ? PDFPendingError : APIError
|
|
48
|
+
when 429 then RateLimitError
|
|
49
|
+
else APIError
|
|
50
|
+
end
|
|
51
|
+
klass.new(message, **attrs)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 401: missing, invalid, or revoked API key.
|
|
56
|
+
class AuthenticationError < APIError; end
|
|
57
|
+
|
|
58
|
+
# 403: the key authenticated but may not do this — +code+ is
|
|
59
|
+
# SCOPE_REQUIRED (key lacks the route's scope) or PARTNER_API_NOT_ENABLED
|
|
60
|
+
# (organization not enrolled in the partner API).
|
|
61
|
+
class PermissionError < APIError; end
|
|
62
|
+
|
|
63
|
+
# 404: no such resource in your organization.
|
|
64
|
+
class NotFoundError < APIError; end
|
|
65
|
+
|
|
66
|
+
# 409 on submission_pdf_link: the frozen completed PDF is not available
|
|
67
|
+
# (yet). Fall back to Client#download_submission_pdf, which renders on
|
|
68
|
+
# demand.
|
|
69
|
+
class PDFPendingError < APIError; end
|
|
70
|
+
|
|
71
|
+
# 429: rate limited — back off and retry.
|
|
72
|
+
class RateLimitError < APIError; end
|
|
73
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module EasyDocForms
|
|
7
|
+
# Verifies webhook deliveries against a subscription's whsec_* secret
|
|
8
|
+
# (returned once by Client#create_webhook).
|
|
9
|
+
#
|
|
10
|
+
# Every delivery is signed:
|
|
11
|
+
#
|
|
12
|
+
# X-EDF-Signature: t=<unix>,v1=<hex hmac-sha256(secret, "<t>.<raw body>")>
|
|
13
|
+
#
|
|
14
|
+
# Verify over the RAW request body, before any JSON parsing:
|
|
15
|
+
#
|
|
16
|
+
# event = EasyDocForms::Webhook.construct_event(
|
|
17
|
+
# payload: request.body.read,
|
|
18
|
+
# header: request.headers["X-EDF-Signature"],
|
|
19
|
+
# secret: ENV["EASYDOCFORMS_WEBHOOK_SECRET"]
|
|
20
|
+
# )
|
|
21
|
+
# case event[:event]
|
|
22
|
+
# when "submission.created" then ... # event[:data][:submission_id]
|
|
23
|
+
# end
|
|
24
|
+
module Webhook
|
|
25
|
+
HEADER = "X-EDF-Signature"
|
|
26
|
+
DEFAULT_TOLERANCE = 300 # seconds
|
|
27
|
+
|
|
28
|
+
# Verifies a delivery's signature and timestamp. Returns true, or raises
|
|
29
|
+
# EasyDocForms::SignatureVerificationError. +payload+ must be the raw
|
|
30
|
+
# request body string; +tolerance+ bounds |now - t| (replay window).
|
|
31
|
+
def self.verify!(payload:, header:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now)
|
|
32
|
+
timestamp, signatures = parse_header(header)
|
|
33
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
|
|
34
|
+
unless signatures.any? { |signature| secure_compare(signature, expected) }
|
|
35
|
+
raise SignatureVerificationError, "no v1 signature matches the payload"
|
|
36
|
+
end
|
|
37
|
+
if (now.to_i - timestamp).abs > tolerance
|
|
38
|
+
raise SignatureVerificationError, "timestamp outside the #{tolerance}s tolerance"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Verifies a delivery, then parses and returns its envelope as a Hash with
|
|
45
|
+
# symbol keys: { event:, timestamp:, org_id:, data: }.
|
|
46
|
+
def self.construct_event(payload:, header:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now)
|
|
47
|
+
verify!(payload: payload, header: header, secret: secret, tolerance: tolerance, now: now)
|
|
48
|
+
JSON.parse(payload, symbolize_names: true)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Computes an X-EDF-Signature header value — for building authentic
|
|
52
|
+
# fixtures in your own tests.
|
|
53
|
+
def self.sign(payload:, secret:, timestamp: Time.now.to_i)
|
|
54
|
+
"t=#{timestamp},v1=#{OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Splits "t=<unix>,v1=<hex>[,v1=<hex>...]". Unknown items are ignored for
|
|
58
|
+
# forward compatibility; multiple v1 values are all tried (secret
|
|
59
|
+
# rotation).
|
|
60
|
+
def self.parse_header(header)
|
|
61
|
+
timestamp = nil
|
|
62
|
+
signatures = []
|
|
63
|
+
header.to_s.split(",").each do |item|
|
|
64
|
+
key, _, value = item.strip.partition("=")
|
|
65
|
+
case key
|
|
66
|
+
when "t"
|
|
67
|
+
raise SignatureVerificationError, "malformed timestamp" unless value.match?(/\A\d+\z/)
|
|
68
|
+
|
|
69
|
+
timestamp = value.to_i
|
|
70
|
+
when "v1"
|
|
71
|
+
signatures << value
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
raise SignatureVerificationError, "malformed #{HEADER} header" if timestamp.nil? || signatures.empty?
|
|
75
|
+
|
|
76
|
+
[timestamp, signatures]
|
|
77
|
+
end
|
|
78
|
+
private_class_method :parse_header
|
|
79
|
+
|
|
80
|
+
def self.secure_compare(a, b)
|
|
81
|
+
return false unless a.bytesize == b.bytesize
|
|
82
|
+
|
|
83
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
84
|
+
end
|
|
85
|
+
private_class_method :secure_compare
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/easydocforms.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "easydocforms/version"
|
|
4
|
+
require_relative "easydocforms/errors"
|
|
5
|
+
require_relative "easydocforms/client"
|
|
6
|
+
require_relative "easydocforms/webhook"
|
|
7
|
+
|
|
8
|
+
# EasyDocForms turns a blank PDF intake form into a hosted, mobile-friendly
|
|
9
|
+
# fillable form — and returns the completed, pixel-exact PDF plus structured
|
|
10
|
+
# JSON answers.
|
|
11
|
+
#
|
|
12
|
+
# client = EasyDocForms::Client.new(ENV["EASYDOCFORMS_API_KEY"])
|
|
13
|
+
# pong = client.ping
|
|
14
|
+
# # => { org_id: "...", key_name: "...", scopes: [...] }
|
|
15
|
+
#
|
|
16
|
+
# PHI boundary: imports are blank forms only (every import requires
|
|
17
|
+
# +blank_form_attestation: true+), +external_ref+ values must never contain
|
|
18
|
+
# PHI, and webhook payloads carry ids and retrieve URLs — never patient
|
|
19
|
+
# answers.
|
|
20
|
+
#
|
|
21
|
+
# API reference: https://easydocforms.com/docs/api
|
|
22
|
+
module EasyDocForms
|
|
23
|
+
DEFAULT_BASE_URL = "https://form.easydocforms.com/api/v1"
|
|
24
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Receives EasyDocForms webhook deliveries. Every delivery is verified
|
|
4
|
+
# against the subscription's signing secret before it is trusted.
|
|
5
|
+
#
|
|
6
|
+
# Webhook payloads are PHI-minimized by design: they carry ids and retrieve
|
|
7
|
+
# URLs, never patient answers. Fetch answers server-side with your API key.
|
|
8
|
+
class EasydocformsWebhooksController < ActionController::Base
|
|
9
|
+
skip_before_action :verify_authenticity_token
|
|
10
|
+
|
|
11
|
+
def create
|
|
12
|
+
event = EasyDocForms::Webhook.construct_event(
|
|
13
|
+
payload: request.body.read,
|
|
14
|
+
header: request.headers[EasyDocForms::Webhook::HEADER],
|
|
15
|
+
secret: Rails.application.credentials.dig(:easydocforms, :webhook_secret)
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
case event[:event]
|
|
19
|
+
when "import.completed"
|
|
20
|
+
# event[:data] => { import_id:, template_id:, page_count:, field_count:, review_required: }
|
|
21
|
+
when "import.failed"
|
|
22
|
+
# event[:data] => { import_id:, error: }
|
|
23
|
+
when "submission.created"
|
|
24
|
+
# event[:data] => { submission_id:, template_id:, fill_link_id:, external_ref:, retrieve_url: }
|
|
25
|
+
when "submission.pdf_ready"
|
|
26
|
+
# event[:data] => { submission_id:, template_id:, external_ref:, pdf_url: }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
head :ok # respond 2xx fast; deliveries are one-shot
|
|
30
|
+
rescue EasyDocForms::SignatureVerificationError
|
|
31
|
+
head :bad_request
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Easydocforms
|
|
6
|
+
module Generators
|
|
7
|
+
# rails generate easydocforms:webhook
|
|
8
|
+
#
|
|
9
|
+
# Creates a webhook receiver controller that verifies X-EDF-Signature and
|
|
10
|
+
# prints the route + credentials setup to finish wiring it up.
|
|
11
|
+
class WebhookGenerator < Rails::Generators::Base
|
|
12
|
+
source_root File.expand_path("templates", __dir__)
|
|
13
|
+
|
|
14
|
+
def create_controller
|
|
15
|
+
copy_file "webhooks_controller.rb", "app/controllers/easydocforms_webhooks_controller.rb"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def show_instructions
|
|
19
|
+
say <<~INSTRUCTIONS
|
|
20
|
+
|
|
21
|
+
EasyDocForms webhook receiver created. Two steps to finish:
|
|
22
|
+
|
|
23
|
+
1. Add the route (config/routes.rb):
|
|
24
|
+
|
|
25
|
+
post "/webhooks/easydocforms", to: "easydocforms_webhooks#create"
|
|
26
|
+
|
|
27
|
+
2. Store the signing secret (shown once when you register the webhook):
|
|
28
|
+
|
|
29
|
+
bin/rails credentials:edit
|
|
30
|
+
# easydocforms:
|
|
31
|
+
# webhook_secret: whsec_...
|
|
32
|
+
|
|
33
|
+
Then register the endpoint:
|
|
34
|
+
|
|
35
|
+
EasyDocForms::Client.new(ENV["EASYDOCFORMS_API_KEY"])
|
|
36
|
+
.create_webhook(url: "https://your-app.example.com/webhooks/easydocforms")
|
|
37
|
+
|
|
38
|
+
INSTRUCTIONS
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: easydocforms
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- EasyDocForms
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'EasyDocForms turns a blank PDF intake form into a hosted, mobile-friendly
|
|
13
|
+
fillable form and returns the completed, pixel-exact PDF plus structured JSON answers.
|
|
14
|
+
This gem wraps the Partner API: import a blank PDF, mint a hosted patient fill link,
|
|
15
|
+
receive signed webhooks, and retrieve submissions. Zero runtime dependencies.'
|
|
16
|
+
email:
|
|
17
|
+
- support@easydocforms.com
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- CHANGELOG.md
|
|
23
|
+
- LICENSE
|
|
24
|
+
- README.md
|
|
25
|
+
- lib/easydocforms.rb
|
|
26
|
+
- lib/easydocforms/client.rb
|
|
27
|
+
- lib/easydocforms/errors.rb
|
|
28
|
+
- lib/easydocforms/version.rb
|
|
29
|
+
- lib/easydocforms/webhook.rb
|
|
30
|
+
- lib/generators/easydocforms/webhook/templates/webhooks_controller.rb
|
|
31
|
+
- lib/generators/easydocforms/webhook/webhook_generator.rb
|
|
32
|
+
homepage: https://easydocforms.com
|
|
33
|
+
licenses:
|
|
34
|
+
- MIT
|
|
35
|
+
metadata:
|
|
36
|
+
homepage_uri: https://easydocforms.com
|
|
37
|
+
source_code_uri: https://github.com/easydocforms/easydocforms-ruby
|
|
38
|
+
documentation_uri: https://easydocforms.com/docs/api
|
|
39
|
+
changelog_uri: https://github.com/easydocforms/easydocforms-ruby/blob/main/CHANGELOG.md
|
|
40
|
+
bug_tracker_uri: https://github.com/easydocforms/easydocforms-ruby/issues
|
|
41
|
+
rubygems_mfa_required: 'true'
|
|
42
|
+
rdoc_options: []
|
|
43
|
+
require_paths:
|
|
44
|
+
- lib
|
|
45
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '3.1'
|
|
50
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0'
|
|
55
|
+
requirements: []
|
|
56
|
+
rubygems_version: 4.0.8
|
|
57
|
+
specification_version: 4
|
|
58
|
+
summary: Ruby SDK for the EasyDocForms Partner API — turn blank PDF intake forms into
|
|
59
|
+
hosted fillable forms and get back completed, pixel-exact PDFs.
|
|
60
|
+
test_files: []
|