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 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
+ [![Gem Version](https://badge.fury.io/rb/easydocforms.svg)](https://rubygems.org/gems/easydocforms)
4
+ [![CI](https://github.com/easydocforms/easydocforms-ruby/actions/workflows/ci.yml/badge.svg)](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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyDocForms
4
+ VERSION = "0.1.0"
5
+ 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
@@ -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: []