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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anvil
4
+ VERSION = '0.1.0'
5
+ 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!