anvil-ruby 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/AGENTS.md +123 -0
- data/API_COVERAGE.md +294 -0
- data/CHANGELOG.md +34 -0
- data/CLAUDE_README.md +98 -0
- data/GITHUB_ACTIONS_QUICKREF.md +174 -0
- data/Gemfile.lock +112 -0
- data/Gemfile.minimal +9 -0
- data/LICENSE +21 -0
- data/PROJECT_CONTEXT.md +196 -0
- data/README.md +445 -0
- data/anvil-ruby.gemspec +66 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/create_signature_direct.rb +142 -0
- data/create_signature_packet.rb +232 -0
- data/debug_env.rb +28 -0
- data/examples/create_signature.rb +194 -0
- data/examples/fill_pdf.rb +89 -0
- data/examples/generate_pdf.rb +347 -0
- data/examples/verify_webhook.rb +281 -0
- data/lib/anvil/client.rb +216 -0
- data/lib/anvil/configuration.rb +87 -0
- data/lib/anvil/env_loader.rb +30 -0
- data/lib/anvil/errors.rb +95 -0
- data/lib/anvil/rate_limiter.rb +66 -0
- data/lib/anvil/resources/base.rb +100 -0
- data/lib/anvil/resources/pdf.rb +171 -0
- data/lib/anvil/resources/signature.rb +517 -0
- data/lib/anvil/resources/webform.rb +154 -0
- data/lib/anvil/resources/webhook.rb +201 -0
- data/lib/anvil/resources/workflow.rb +169 -0
- data/lib/anvil/response.rb +98 -0
- data/lib/anvil/version.rb +5 -0
- data/lib/anvil.rb +88 -0
- data/quickstart_signature.rb +220 -0
- data/test_api_connection.rb +143 -0
- data/test_etch_signature.rb +230 -0
- data/test_gem.rb +72 -0
- data/test_signature.rb +281 -0
- data/test_signature_with_template.rb +112 -0
- metadata +247 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anvil
|
|
4
|
+
class Webform < Resources::Base
|
|
5
|
+
def id
|
|
6
|
+
attributes[:eid] || attributes[:id]
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def eid
|
|
10
|
+
attributes[:eid]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def name
|
|
14
|
+
attributes[:name]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def slug
|
|
18
|
+
attributes[:slug]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fields
|
|
22
|
+
Array(attributes[:fields])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Submit data to this webform
|
|
26
|
+
#
|
|
27
|
+
# @param data [Hash] The form data to submit
|
|
28
|
+
# @return [Hash] The submission result
|
|
29
|
+
def submit(data: {})
|
|
30
|
+
result = client.graphql(self.class.send(:create_submission_mutation), variables: {
|
|
31
|
+
forgeEid: eid,
|
|
32
|
+
input: data
|
|
33
|
+
})
|
|
34
|
+
raise APIError, "Failed to submit form data: #{eid}" unless result && result[:createSubmission]
|
|
35
|
+
|
|
36
|
+
result[:createSubmission]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get submissions for this webform
|
|
40
|
+
#
|
|
41
|
+
# @param limit [Integer] Number of submissions to return
|
|
42
|
+
# @param offset [Integer] Offset for pagination
|
|
43
|
+
# @return [Array<Hash>] The form submissions
|
|
44
|
+
def submissions(limit: 10, offset: 0)
|
|
45
|
+
result = client.graphql(self.class.send(:submissions_query), variables: {
|
|
46
|
+
forgeEid: eid,
|
|
47
|
+
limit: limit,
|
|
48
|
+
offset: offset
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if result && result[:forgeSubmissions]
|
|
52
|
+
Array(result[:forgeSubmissions])
|
|
53
|
+
else
|
|
54
|
+
[]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Reload from API
|
|
59
|
+
def reload!
|
|
60
|
+
refreshed = self.class.find(eid, client: client)
|
|
61
|
+
@attributes = refreshed.attributes
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class << self
|
|
66
|
+
# Create a new webform
|
|
67
|
+
#
|
|
68
|
+
# @param name [String] Name of the form
|
|
69
|
+
# @param fields [Array<Hash>] Form field definitions
|
|
70
|
+
# @param options [Hash] Additional options
|
|
71
|
+
# @return [Webform] The created webform
|
|
72
|
+
def create(name:, fields: [], **options)
|
|
73
|
+
api_key = options.delete(:api_key)
|
|
74
|
+
client = api_key ? Client.new(api_key: api_key) : self.client
|
|
75
|
+
|
|
76
|
+
payload = { name: name, fields: fields }
|
|
77
|
+
payload[:slug] = options[:slug] if options[:slug]
|
|
78
|
+
|
|
79
|
+
result = client.graphql(create_forge_mutation, variables: { input: payload })
|
|
80
|
+
raise APIError, "Failed to create webform: #{result}" unless result && result[:createForge]
|
|
81
|
+
|
|
82
|
+
new(result[:createForge], client: client)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Find a webform by EID
|
|
86
|
+
#
|
|
87
|
+
# @param form_eid [String] The webform EID
|
|
88
|
+
# @param client [Client] Optional client instance
|
|
89
|
+
# @return [Webform] The webform
|
|
90
|
+
def find(form_eid, client: nil)
|
|
91
|
+
client ||= self.client
|
|
92
|
+
|
|
93
|
+
result = client.graphql(forge_query, variables: { eid: form_eid })
|
|
94
|
+
raise NotFoundError, "Webform not found: #{form_eid}" unless result && result[:forge]
|
|
95
|
+
|
|
96
|
+
new(result[:forge], client: client)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def create_forge_mutation
|
|
102
|
+
<<~GRAPHQL
|
|
103
|
+
mutation CreateForge($input: JSON) {
|
|
104
|
+
createForge(variables: $input) {
|
|
105
|
+
eid
|
|
106
|
+
name
|
|
107
|
+
slug
|
|
108
|
+
fields
|
|
109
|
+
createdAt
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
GRAPHQL
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def forge_query
|
|
116
|
+
<<~GRAPHQL
|
|
117
|
+
query GetForge($eid: String!) {
|
|
118
|
+
forge(eid: $eid) {
|
|
119
|
+
eid
|
|
120
|
+
name
|
|
121
|
+
slug
|
|
122
|
+
fields
|
|
123
|
+
createdAt
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
GRAPHQL
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def create_submission_mutation
|
|
130
|
+
<<~GRAPHQL
|
|
131
|
+
mutation CreateSubmission($forgeEid: String!, $input: JSON) {
|
|
132
|
+
createSubmission(forgeEid: $forgeEid, variables: $input) {
|
|
133
|
+
eid
|
|
134
|
+
data
|
|
135
|
+
createdAt
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
GRAPHQL
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def submissions_query
|
|
142
|
+
<<~GRAPHQL
|
|
143
|
+
query GetForgeSubmissions($forgeEid: String!, $limit: Int, $offset: Int) {
|
|
144
|
+
forgeSubmissions(forgeEid: $forgeEid, limit: $limit, offset: $offset) {
|
|
145
|
+
eid
|
|
146
|
+
data
|
|
147
|
+
createdAt
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
GRAPHQL
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
module Anvil
|
|
6
|
+
class Webhook < Resources::Base
|
|
7
|
+
# Webhook actions/events
|
|
8
|
+
ACTIONS = %w[
|
|
9
|
+
weldCreate
|
|
10
|
+
forgeComplete
|
|
11
|
+
weldComplete
|
|
12
|
+
signerComplete
|
|
13
|
+
signerUpdateStatus
|
|
14
|
+
etchPacketComplete
|
|
15
|
+
documentGroupCreate
|
|
16
|
+
webhookTest
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :raw_payload, :token
|
|
20
|
+
|
|
21
|
+
def initialize(payload:, token: nil, **options)
|
|
22
|
+
@raw_payload = payload.is_a?(String) ? payload : payload.to_json
|
|
23
|
+
@token = token
|
|
24
|
+
|
|
25
|
+
begin
|
|
26
|
+
parsed = JSON.parse(@raw_payload, symbolize_names: true)
|
|
27
|
+
super(parsed, **options)
|
|
28
|
+
rescue JSON::ParserError => e
|
|
29
|
+
raise WebhookError, "Invalid webhook payload: #{e.message}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def action
|
|
34
|
+
attributes[:action]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def data
|
|
38
|
+
attributes[:data]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def timestamp
|
|
42
|
+
attributes[:timestamp] || attributes[:created_at]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Verify the webhook token
|
|
46
|
+
def valid?(expected_token = nil)
|
|
47
|
+
expected_token ||= Anvil.configuration.webhook_token
|
|
48
|
+
|
|
49
|
+
raise WebhookVerificationError, 'No webhook token configured' if expected_token.nil? || expected_token.empty?
|
|
50
|
+
|
|
51
|
+
return false unless token
|
|
52
|
+
|
|
53
|
+
# Constant-time comparison to prevent timing attacks
|
|
54
|
+
secure_compare(token, expected_token)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def valid!
|
|
58
|
+
raise WebhookVerificationError, 'Invalid webhook token' unless valid?
|
|
59
|
+
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Check if data is encrypted
|
|
64
|
+
def encrypted?
|
|
65
|
+
data.is_a?(String) && data.match?(%r{^[A-Za-z0-9+/=]+$})
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Decrypt the webhook data (requires RSA private key)
|
|
69
|
+
def decrypt(private_key_path = nil)
|
|
70
|
+
return data unless encrypted?
|
|
71
|
+
|
|
72
|
+
private_key_path ||= ENV.fetch('ANVIL_RSA_PRIVATE_KEY_PATH', nil)
|
|
73
|
+
|
|
74
|
+
unless private_key_path && File.exist?(private_key_path)
|
|
75
|
+
raise WebhookError, 'Private key not found for decrypting webhook data'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
begin
|
|
79
|
+
private_key = OpenSSL::PKey::RSA.new(File.read(private_key_path))
|
|
80
|
+
encrypted_data = Base64.decode64(data)
|
|
81
|
+
decrypted = private_key.private_decrypt(encrypted_data, OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING)
|
|
82
|
+
JSON.parse(decrypted, symbolize_names: true)
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
raise WebhookError, "Failed to decrypt webhook data: #{e.message}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Helper methods for specific webhook types
|
|
89
|
+
def workflow_created?
|
|
90
|
+
action == 'weldCreate'
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def webform_complete?
|
|
94
|
+
action == 'forgeComplete'
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def workflow_complete?
|
|
98
|
+
action == 'weldComplete'
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def signer_complete?
|
|
102
|
+
action == 'signerComplete'
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def signer_status_updated?
|
|
106
|
+
action == 'signerUpdateStatus'
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def signature_packet_complete?
|
|
110
|
+
action == 'etchPacketComplete'
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def document_group_created?
|
|
114
|
+
action == 'documentGroupCreate'
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def test?
|
|
118
|
+
action == 'webhookTest'
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Extract specific data based on webhook type
|
|
122
|
+
def signer_eid
|
|
123
|
+
data[:signerEid] if signer_complete? || signer_status_updated?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def packet_eid
|
|
127
|
+
data[:packetEid] if signature_packet_complete? || signer_complete?
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def workflow_eid
|
|
131
|
+
data[:weldEid] || data[:eid] if workflow_created? || workflow_complete?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def webform_eid
|
|
135
|
+
data[:forgeEid] if webform_complete?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def signer_status
|
|
139
|
+
data[:status] if signer_status_updated?
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def signer_name
|
|
143
|
+
data[:signerName] if signer_complete? || signer_status_updated?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def signer_email
|
|
147
|
+
data[:signerEmail] if signer_complete? || signer_status_updated?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
class << self
|
|
151
|
+
# Verify and parse a webhook request
|
|
152
|
+
#
|
|
153
|
+
# @param request [ActionDispatch::Request, Rack::Request] The incoming request
|
|
154
|
+
# @return [Webhook] The parsed and verified webhook
|
|
155
|
+
def from_request(request)
|
|
156
|
+
payload = request.body.read
|
|
157
|
+
token = extract_token(request)
|
|
158
|
+
|
|
159
|
+
webhook = new(payload: payload, token: token)
|
|
160
|
+
webhook.valid!
|
|
161
|
+
webhook
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Create a test webhook for development
|
|
165
|
+
def create_test(action: 'webhookTest', data: {})
|
|
166
|
+
payload = {
|
|
167
|
+
action: action,
|
|
168
|
+
data: data,
|
|
169
|
+
timestamp: Time.now.iso8601
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
new(
|
|
173
|
+
payload: payload.to_json,
|
|
174
|
+
token: Anvil.configuration.webhook_token
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def extract_token(request)
|
|
181
|
+
# Token can be in header or params
|
|
182
|
+
request.headers['X-Anvil-Token'] ||
|
|
183
|
+
request.params['token'] ||
|
|
184
|
+
request.params['anvil_token']
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
def secure_compare(str1, str2)
|
|
191
|
+
return false unless str1.bytesize == str2.bytesize
|
|
192
|
+
|
|
193
|
+
l = str1.unpack('C*')
|
|
194
|
+
r = str2.unpack('C*')
|
|
195
|
+
result = 0
|
|
196
|
+
|
|
197
|
+
l.zip(r).each { |x, y| result |= x ^ y }
|
|
198
|
+
result.zero?
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anvil
|
|
4
|
+
class Workflow < Resources::Base
|
|
5
|
+
def id
|
|
6
|
+
attributes[:eid] || attributes[:id]
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def eid
|
|
10
|
+
attributes[:eid]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def name
|
|
14
|
+
attributes[:name]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def status
|
|
18
|
+
attributes[:status]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def slug
|
|
22
|
+
attributes[:slug]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def published?
|
|
26
|
+
status == 'published'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def draft?
|
|
30
|
+
status == 'draft'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Start the workflow with initial data
|
|
34
|
+
#
|
|
35
|
+
# @param data [Hash] The data to start the workflow with
|
|
36
|
+
# @return [Hash] The submission data
|
|
37
|
+
def start(data: {})
|
|
38
|
+
result = client.graphql(self.class.send(:create_weld_data_mutation), variables: {
|
|
39
|
+
eid: eid,
|
|
40
|
+
input: data
|
|
41
|
+
})
|
|
42
|
+
raise APIError, "Failed to start workflow: #{eid}" unless result && result[:createWeldData]
|
|
43
|
+
|
|
44
|
+
result[:createWeldData]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get submissions for this workflow
|
|
48
|
+
#
|
|
49
|
+
# @param limit [Integer] Number of submissions to return
|
|
50
|
+
# @param offset [Integer] Offset for pagination
|
|
51
|
+
# @return [Array<Hash>] The workflow submissions
|
|
52
|
+
def submissions(limit: 10, offset: 0)
|
|
53
|
+
result = client.graphql(self.class.send(:weld_data_query), variables: {
|
|
54
|
+
eid: eid,
|
|
55
|
+
limit: limit,
|
|
56
|
+
offset: offset
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
if result && result[:weldData]
|
|
60
|
+
Array(result[:weldData])
|
|
61
|
+
else
|
|
62
|
+
[]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Reload from API
|
|
67
|
+
def reload!
|
|
68
|
+
refreshed = self.class.find(eid, client: client)
|
|
69
|
+
@attributes = refreshed.attributes
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class << self
|
|
74
|
+
# Create a new workflow
|
|
75
|
+
#
|
|
76
|
+
# @param name [String] Name of the workflow
|
|
77
|
+
# @param options [Hash] Additional options (forges, casts, slug)
|
|
78
|
+
# @return [Workflow] The created workflow
|
|
79
|
+
def create(name:, **options)
|
|
80
|
+
api_key = options.delete(:api_key)
|
|
81
|
+
client = api_key ? Client.new(api_key: api_key) : self.client
|
|
82
|
+
|
|
83
|
+
payload = { name: name }
|
|
84
|
+
payload[:slug] = options[:slug] if options[:slug]
|
|
85
|
+
payload[:forges] = options[:forges] if options[:forges]
|
|
86
|
+
payload[:casts] = options[:casts] if options[:casts]
|
|
87
|
+
|
|
88
|
+
result = client.graphql(create_weld_mutation, variables: { input: payload })
|
|
89
|
+
raise APIError, "Failed to create workflow: #{result}" unless result && result[:createWeld]
|
|
90
|
+
|
|
91
|
+
new(result[:createWeld], client: client)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Find a workflow by EID
|
|
95
|
+
#
|
|
96
|
+
# @param workflow_eid [String] The workflow EID
|
|
97
|
+
# @param client [Client] Optional client instance
|
|
98
|
+
# @return [Workflow] The workflow
|
|
99
|
+
def find(workflow_eid, client: nil)
|
|
100
|
+
client ||= self.client
|
|
101
|
+
|
|
102
|
+
result = client.graphql(weld_query, variables: { eid: workflow_eid })
|
|
103
|
+
raise NotFoundError, "Workflow not found: #{workflow_eid}" unless result && result[:weld]
|
|
104
|
+
|
|
105
|
+
new(result[:weld], client: client)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def create_weld_mutation
|
|
111
|
+
<<~GRAPHQL
|
|
112
|
+
mutation CreateWeld($input: JSON) {
|
|
113
|
+
createWeld(variables: $input) {
|
|
114
|
+
eid
|
|
115
|
+
name
|
|
116
|
+
slug
|
|
117
|
+
status
|
|
118
|
+
createdAt
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
GRAPHQL
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def weld_query
|
|
125
|
+
<<~GRAPHQL
|
|
126
|
+
query GetWeld($eid: String!) {
|
|
127
|
+
weld(eid: $eid) {
|
|
128
|
+
eid
|
|
129
|
+
name
|
|
130
|
+
slug
|
|
131
|
+
status
|
|
132
|
+
createdAt
|
|
133
|
+
forges {
|
|
134
|
+
eid
|
|
135
|
+
name
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
GRAPHQL
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def create_weld_data_mutation
|
|
143
|
+
<<~GRAPHQL
|
|
144
|
+
mutation CreateWeldData($eid: String!, $input: JSON) {
|
|
145
|
+
createWeldData(weldEid: $eid, variables: $input) {
|
|
146
|
+
eid
|
|
147
|
+
status
|
|
148
|
+
createdAt
|
|
149
|
+
data
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
GRAPHQL
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def weld_data_query
|
|
156
|
+
<<~GRAPHQL
|
|
157
|
+
query GetWeldData($eid: String!, $limit: Int, $offset: Int) {
|
|
158
|
+
weldData(weldEid: $eid, limit: $limit, offset: $offset) {
|
|
159
|
+
eid
|
|
160
|
+
status
|
|
161
|
+
createdAt
|
|
162
|
+
data
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
GRAPHQL
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anvil
|
|
4
|
+
class Response
|
|
5
|
+
attr_reader :http_response, :raw_body, :code, :headers
|
|
6
|
+
|
|
7
|
+
def initialize(http_response)
|
|
8
|
+
@http_response = http_response
|
|
9
|
+
@code = http_response.code.to_i
|
|
10
|
+
@raw_body = http_response.body
|
|
11
|
+
@headers = extract_headers(http_response)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def success?
|
|
15
|
+
code >= 200 && code < 300
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def error?
|
|
19
|
+
!success?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def body
|
|
23
|
+
return raw_body if binary?
|
|
24
|
+
|
|
25
|
+
@body ||= begin
|
|
26
|
+
JSON.parse(raw_body, symbolize_names: true)
|
|
27
|
+
rescue JSON::ParserError
|
|
28
|
+
raw_body
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def data
|
|
33
|
+
return raw_body if binary?
|
|
34
|
+
|
|
35
|
+
if body.is_a?(Hash)
|
|
36
|
+
body[:data] || body
|
|
37
|
+
else
|
|
38
|
+
body
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def errors
|
|
43
|
+
return [] unless error? && body.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
body[:errors] || body[:fields] || []
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def error_message
|
|
49
|
+
return nil unless error?
|
|
50
|
+
|
|
51
|
+
if errors.any?
|
|
52
|
+
errors.map { |e| e[:message] || e['message'] }.join(', ')
|
|
53
|
+
elsif body.is_a?(Hash) && body[:message]
|
|
54
|
+
body[:message]
|
|
55
|
+
else
|
|
56
|
+
"HTTP #{code} Error"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Rate limiting headers
|
|
61
|
+
def rate_limit
|
|
62
|
+
headers['x-ratelimit-limit']&.to_i
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def rate_limit_remaining
|
|
66
|
+
headers['x-ratelimit-remaining']&.to_i
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def rate_limit_reset
|
|
70
|
+
reset = headers['x-ratelimit-reset']&.to_i
|
|
71
|
+
Time.at(reset) if reset
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def retry_after
|
|
75
|
+
headers['retry-after']&.to_i
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def content_type
|
|
79
|
+
headers['content-type'] || ''
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def binary?
|
|
83
|
+
content_type.include?('application/pdf') ||
|
|
84
|
+
content_type.include?('application/octet-stream') ||
|
|
85
|
+
content_type.include?('application/zip')
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def extract_headers(response)
|
|
91
|
+
headers = {}
|
|
92
|
+
response.each_header do |key, value|
|
|
93
|
+
headers[key.downcase] = value
|
|
94
|
+
end
|
|
95
|
+
headers
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/anvil.rb
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'base64'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require 'date'
|
|
8
|
+
|
|
9
|
+
require_relative 'anvil/version'
|
|
10
|
+
require_relative 'anvil/configuration'
|
|
11
|
+
require_relative 'anvil/errors'
|
|
12
|
+
require_relative 'anvil/response'
|
|
13
|
+
require_relative 'anvil/client'
|
|
14
|
+
require_relative 'anvil/rate_limiter'
|
|
15
|
+
require_relative 'anvil/resources/base'
|
|
16
|
+
require_relative 'anvil/resources/pdf'
|
|
17
|
+
require_relative 'anvil/resources/signature'
|
|
18
|
+
require_relative 'anvil/resources/webhook'
|
|
19
|
+
require_relative 'anvil/resources/workflow'
|
|
20
|
+
require_relative 'anvil/resources/webform'
|
|
21
|
+
|
|
22
|
+
module Anvil
|
|
23
|
+
class << self
|
|
24
|
+
attr_accessor :configuration
|
|
25
|
+
|
|
26
|
+
def configure
|
|
27
|
+
self.configuration ||= Configuration.new
|
|
28
|
+
yield(configuration) if block_given?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset_configuration!
|
|
32
|
+
self.configuration = Configuration.new
|
|
33
|
+
@default_client = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Convenience methods for quick configuration
|
|
37
|
+
def api_key=(key)
|
|
38
|
+
configure { |config| config.api_key = key }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def api_key
|
|
42
|
+
configuration&.api_key || ENV.fetch('ANVIL_API_KEY', nil)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def environment=(env)
|
|
46
|
+
configure { |config| config.environment = env }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def environment
|
|
50
|
+
configuration&.environment || :production
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def development?
|
|
54
|
+
environment == :development
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def production?
|
|
58
|
+
environment == :production
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Execute a GraphQL query
|
|
62
|
+
#
|
|
63
|
+
# @param query [String] The GraphQL query string
|
|
64
|
+
# @param variables [Hash] Variables to pass to the query
|
|
65
|
+
# @return [Hash] The query result data
|
|
66
|
+
def query(query:, variables: {})
|
|
67
|
+
default_client.query(query: query, variables: variables)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Execute a GraphQL mutation
|
|
71
|
+
#
|
|
72
|
+
# @param mutation [String] The GraphQL mutation string
|
|
73
|
+
# @param variables [Hash] Variables to pass to the mutation
|
|
74
|
+
# @return [Hash] The mutation result data
|
|
75
|
+
def mutation(mutation:, variables: {})
|
|
76
|
+
default_client.mutation(mutation: mutation, variables: variables)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def default_client
|
|
82
|
+
@default_client ||= Client.new
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Initialize with default configuration
|
|
88
|
+
Anvil.reset_configuration!
|