clicksign-ruby-sdk 0.1.1

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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +535 -0
  3. data/lib/clicksign/client.rb +143 -0
  4. data/lib/clicksign/configuration.rb +29 -0
  5. data/lib/clicksign/error_handler.rb +56 -0
  6. data/lib/clicksign/errors.rb +53 -0
  7. data/lib/clicksign/instrumentation.rb +32 -0
  8. data/lib/clicksign/json_api/atomic_results_parser.rb +61 -0
  9. data/lib/clicksign/json_api/bulk_operations_client.rb +95 -0
  10. data/lib/clicksign/json_api/operations/bulk_requirement.rb +89 -0
  11. data/lib/clicksign/json_api/operations.rb +38 -0
  12. data/lib/clicksign/json_api/parser.rb +31 -0
  13. data/lib/clicksign/json_api/query_builder.rb +45 -0
  14. data/lib/clicksign/json_api/serializer.rb +14 -0
  15. data/lib/clicksign/resource.rb +263 -0
  16. data/lib/clicksign/resources/acceptance_term/whatsapp.rb +12 -0
  17. data/lib/clicksign/resources/access_control_list.rb +35 -0
  18. data/lib/clicksign/resources/auto_signature/term.rb +12 -0
  19. data/lib/clicksign/resources/envelope_bulk_creation.rb +9 -0
  20. data/lib/clicksign/resources/folder.rb +22 -0
  21. data/lib/clicksign/resources/group.rb +21 -0
  22. data/lib/clicksign/resources/membership.rb +21 -0
  23. data/lib/clicksign/resources/notarial/bulk_requirement.rb +67 -0
  24. data/lib/clicksign/resources/notarial/document.rb +40 -0
  25. data/lib/clicksign/resources/notarial/envelope.rb +80 -0
  26. data/lib/clicksign/resources/notarial/event.rb +20 -0
  27. data/lib/clicksign/resources/notarial/requirement.rb +63 -0
  28. data/lib/clicksign/resources/notarial/signature_watcher.rb +39 -0
  29. data/lib/clicksign/resources/notarial/signer.rb +56 -0
  30. data/lib/clicksign/resources/template.rb +13 -0
  31. data/lib/clicksign/resources/template_field.rb +9 -0
  32. data/lib/clicksign/resources/user.rb +15 -0
  33. data/lib/clicksign/resources/webhook.rb +9 -0
  34. data/lib/clicksign/services.rb +35 -0
  35. data/lib/clicksign/version.rb +5 -0
  36. data/lib/clicksign/webhook.rb +41 -0
  37. data/lib/clicksign.rb +73 -0
  38. metadata +81 -0
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksign
4
+ module ErrorHandler
5
+ ERROR_MAP = {
6
+ [401, 403] => AuthenticationError,
7
+ [404] => NotFoundError,
8
+ [400, 422] => ValidationError,
9
+ [409] => ConflictError,
10
+ [429] => RateLimitError,
11
+ }.freeze
12
+
13
+ def self.call(response)
14
+ return nil if (200..299).cover?(response.code.to_i)
15
+
16
+ metadata = extract_metadata(response)
17
+ message = extract_message(response)
18
+ error_class = error_class_for(response.code.to_i)
19
+ raise error_class.new(message, **metadata)
20
+ end
21
+
22
+ def self.error_class_for(code)
23
+ ERROR_MAP.each { |codes, klass| return klass if codes.include?(code) }
24
+ return ServerError if (500..599).cover?(code)
25
+
26
+ Error
27
+ end
28
+
29
+ def self.extract_metadata(response)
30
+ {
31
+ status_code: response.code.to_i,
32
+ request_id: response['x-request-id'],
33
+ response_body: response.body,
34
+ response_headers: response.each_header.to_h,
35
+ }
36
+ end
37
+
38
+ def self.extract_message(response)
39
+ return response.message if response.body.nil? || response.body.empty?
40
+
41
+ extract_from_json(response)
42
+ rescue JSON::ParserError
43
+ response.message
44
+ end
45
+
46
+ def self.extract_from_json(response)
47
+ body = JSON.parse(response.body)
48
+ return response.message unless body.is_a?(Hash)
49
+
50
+ errors = body['errors']
51
+ return response.message unless errors.is_a?(Array)
52
+
53
+ errors.filter_map { |e| e['detail'] || e['title'] }.join(', ')
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksign
4
+ class Error < StandardError
5
+ attr_reader :status_code, :request_id, :response_body, :response_headers
6
+
7
+ def initialize(message = nil, status_code: nil, request_id: nil,
8
+ response_body: nil, response_headers: {})
9
+ super(message)
10
+ @status_code = status_code
11
+ @request_id = request_id
12
+ @response_body = response_body
13
+ @response_headers = response_headers || {}
14
+ end
15
+
16
+ def retryable?
17
+ false
18
+ end
19
+ end
20
+
21
+ class AuthenticationError < Error; end
22
+ class NotFoundError < Error; end
23
+ class ValidationError < Error; end
24
+ class ConflictError < Error; end
25
+
26
+ class RateLimitError < Error
27
+ def retryable?
28
+ true
29
+ end
30
+
31
+ def rate_limit_remaining
32
+ response_headers['x-ratelimit-remaining']&.to_i
33
+ end
34
+
35
+ def rate_limit_reset
36
+ response_headers['x-ratelimit-reset']
37
+ end
38
+ end
39
+
40
+ class ServerError < Error
41
+ def retryable?
42
+ true
43
+ end
44
+ end
45
+
46
+ class TimeoutError < Error
47
+ def retryable?
48
+ true
49
+ end
50
+ end
51
+
52
+ class WebhookSignatureError < Error; end
53
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksign
4
+ module Instrumentation
5
+ EVENTS = %i[request retry error].freeze
6
+
7
+ @callbacks = Hash.new { |h, k| h[k] = [] }
8
+
9
+ class << self
10
+ def on(event, &block)
11
+ unless EVENTS.include?(event)
12
+ raise ArgumentError, "Unknown event: #{event}. Valid: #{EVENTS.join(', ')}"
13
+ end
14
+
15
+ @callbacks[event] << block
16
+ end
17
+
18
+ def publish(event, payload)
19
+ @callbacks[event].each do |cb|
20
+ cb.call(payload)
21
+ rescue StandardError
22
+ # Callbacks must not affect the request — errors are silently ignored.
23
+ end
24
+ end
25
+
26
+ # Removes all registered callbacks — intended for test teardown.
27
+ def clear
28
+ @callbacks = Hash.new { |h, k| h[k] = [] }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksign
4
+ module JsonApi
5
+ module AtomicResultsParser
6
+ module_function
7
+
8
+ def parse(raw, envelope_id:, operations:)
9
+ raw ||= {}
10
+ raise ValidationError, format_errors(raw['errors']) if envelope_errors?(raw)
11
+
12
+ Array(raw['atomic:results']).each_with_index.map do |slot, index|
13
+ build_operation_result(
14
+ slot: slot,
15
+ index: index,
16
+ op: operations.dig(index, 'op'),
17
+ envelope_id: envelope_id,
18
+ )
19
+ end
20
+ end
21
+
22
+ def envelope_errors?(raw)
23
+ raw.key?('errors') && !raw.key?('atomic:results')
24
+ end
25
+
26
+ def build_operation_result(slot:, index:, op:, envelope_id:)
27
+ errors = slot['errors']
28
+ requirement = build_requirement(slot['data'], envelope_id: envelope_id)
29
+
30
+ Resources::Notarial::BulkRequirement::OperationResult.new(
31
+ index: index,
32
+ op: op,
33
+ requirement: requirement,
34
+ errors: errors,
35
+ raw: slot,
36
+ )
37
+ end
38
+
39
+ def build_requirement(data, envelope_id:)
40
+ return nil if data.nil? || data.empty?
41
+
42
+ Resources::Notarial::Requirement.send(
43
+ :build_instance,
44
+ {
45
+ 'id' => data['id'],
46
+ 'type' => data['type'],
47
+ 'attributes' => data.fetch('attributes', {}),
48
+ 'relationships' => data.fetch('relationships', {}),
49
+ },
50
+ parent_id: envelope_id,
51
+ )
52
+ end
53
+
54
+ def format_errors(errors)
55
+ return 'Validation failed' unless errors.is_a?(Array)
56
+
57
+ errors.filter_map { |e| e['detail'] || e['title'] }.join(', ')
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+
7
+ module Clicksign
8
+ module JsonApi
9
+ class BulkOperationsClient
10
+ HEADERS = {
11
+ 'Content-Type' => 'application/vnd.api+json',
12
+ 'Accept' => 'application/vnd.api+json',
13
+ }.freeze
14
+
15
+ def initialize(api_key:, base_url:, open_timeout: 2, read_timeout: 10,
16
+ write_timeout: 10, max_retries: 0)
17
+ @api_key = api_key
18
+ @base_url = base_url
19
+ @open_timeout = open_timeout
20
+ @read_timeout = read_timeout
21
+ @write_timeout = write_timeout
22
+ @max_retries = max_retries
23
+ end
24
+
25
+ def post(path, body:)
26
+ response = perform_post(path, body)
27
+ parsed = parse_response_body(response) || {}
28
+
29
+ return parsed if parsed.key?('atomic:results')
30
+
31
+ ErrorHandler.call(response)
32
+ parsed
33
+ end
34
+
35
+ private
36
+
37
+ def perform_post(path, body)
38
+ uri = build_uri(path)
39
+ request = build_request(uri, body)
40
+ execute_with_retry(request, uri)
41
+ end
42
+
43
+ def build_request(uri, body)
44
+ request = Net::HTTP::Post.new(uri, headers)
45
+ request.body = body.to_json
46
+ request
47
+ end
48
+
49
+ def execute_with_retry(request, uri)
50
+ attempts = 0
51
+ begin
52
+ attempts += 1
53
+ safe_http_post(request, uri)
54
+ rescue Clicksign::TimeoutError => e
55
+ raise unless e.retryable? && attempts <= @max_retries
56
+
57
+ sleep([0.5 * (2**(attempts - 1)), 30].min)
58
+ retry
59
+ end
60
+ end
61
+
62
+ def safe_http_post(request, uri)
63
+ http_post(request, uri)
64
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED => e
65
+ raise TimeoutError, e.message, e.backtrace
66
+ end
67
+
68
+ def http_post(request, uri)
69
+ Net::HTTP.start(uri.host, uri.port,
70
+ use_ssl: uri.scheme == 'https',
71
+ open_timeout: @open_timeout,
72
+ read_timeout: @read_timeout,
73
+ write_timeout: @write_timeout) do |http|
74
+ http.request(request)
75
+ end
76
+ end
77
+
78
+ def headers
79
+ HEADERS.merge('Authorization' => @api_key)
80
+ end
81
+
82
+ def build_uri(path)
83
+ URI.parse("#{@base_url}#{path}")
84
+ end
85
+
86
+ def parse_response_body(response)
87
+ return nil if response.body.nil? || response.body.empty?
88
+
89
+ JSON.parse(response.body)
90
+ rescue JSON::ParserError
91
+ nil
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksign
4
+ module JsonApi
5
+ class Operations
6
+ class BulkRequirement < Operations
7
+ VALID_KINDS = %w[initials manuscript].freeze
8
+
9
+ def add_agree(signer_id:, document_id:, role:)
10
+ validate_ids!(signer_id, document_id)
11
+ raise ArgumentError, 'role is required' if role.to_s.empty?
12
+
13
+ add(
14
+ data: build_add_data(
15
+ signer_id: signer_id,
16
+ document_id: document_id,
17
+ attributes: { action: 'agree', role: role },
18
+ ),
19
+ )
20
+ end
21
+
22
+ def add_provide_evidence(signer_id:, document_id:, auth:)
23
+ validate_ids!(signer_id, document_id)
24
+ raise ArgumentError, 'auth is required' if auth.to_s.empty?
25
+
26
+ add(
27
+ data: build_add_data(
28
+ signer_id: signer_id,
29
+ document_id: document_id,
30
+ attributes: { action: 'provide_evidence', auth: auth },
31
+ ),
32
+ )
33
+ end
34
+
35
+ def add_rubricate(signer_id:, document_id:, pages: nil, rubric_field: nil,
36
+ kind: nil)
37
+ validate_ids!(signer_id, document_id)
38
+ if pages.nil? && rubric_field.nil?
39
+ raise ArgumentError, 'pages or rubric_field is required'
40
+ end
41
+ if kind && !VALID_KINDS.include?(kind.to_s)
42
+ raise ArgumentError, "kind must be one of: #{VALID_KINDS.join(', ')}"
43
+ end
44
+
45
+ add(
46
+ data: build_add_data(
47
+ signer_id: signer_id,
48
+ document_id: document_id,
49
+ attributes: { action: 'rubricate', pages: pages,
50
+ rubric_field: rubric_field, kind: kind },
51
+ ),
52
+ )
53
+ end
54
+
55
+ def remove(requirement_id:)
56
+ raise ArgumentError, 'requirement_id is required' if requirement_id.to_s.empty?
57
+
58
+ super(ref: { type: 'requirements', id: requirement_id })
59
+ end
60
+
61
+ private
62
+
63
+ def validate_ids!(signer_id, document_id)
64
+ raise ArgumentError, 'signer_id is required' if signer_id.to_s.empty?
65
+ raise ArgumentError, 'document_id is required' if document_id.to_s.empty?
66
+ end
67
+
68
+ def build_add_data(signer_id:, document_id:, attributes:)
69
+ {
70
+ type: 'requirements',
71
+ attributes: compact_attributes(attributes),
72
+ relationships: {
73
+ signer: { data: { type: 'signers', id: signer_id.to_s } },
74
+ document: { data: { type: 'documents', id: document_id.to_s } },
75
+ },
76
+ }
77
+ end
78
+
79
+ def compact_attributes(attrs)
80
+ attrs.each_with_object({}) do |(key, value), result|
81
+ next if value.nil?
82
+
83
+ result[key.to_s] = value.is_a?(Symbol) ? value.to_s : value
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksign
4
+ module JsonApi
5
+ class Operations
6
+ def initialize
7
+ @entries = []
8
+ end
9
+
10
+ def add(data:)
11
+ @entries << { 'op' => 'add', 'data' => stringify(data) }
12
+ self
13
+ end
14
+
15
+ def remove(ref:)
16
+ @entries << { 'op' => 'remove', 'ref' => stringify(ref) }
17
+ self
18
+ end
19
+
20
+ def to_h
21
+ { 'atomic:operations' => @entries }
22
+ end
23
+
24
+ attr_reader :entries
25
+
26
+ private
27
+
28
+ def stringify(value)
29
+ case value
30
+ when Hash then value.transform_keys(&:to_s).transform_values { |v| stringify(v) }
31
+ when Array then value.map { |v| stringify(v) }
32
+ when Symbol then value.to_s
33
+ else value
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksign
4
+ module JsonApi
5
+ module Parser
6
+ def self.parse(raw)
7
+ raw_data = raw['data']
8
+ data = case raw_data
9
+ when Array then raw_data.map { |item| build(item) }
10
+ when Hash then [build(raw_data)]
11
+ else []
12
+ end
13
+
14
+ included = Array(raw['included'])
15
+ .select { |item| item.is_a?(Hash) && item['type'] }
16
+ .map { |item| build(item) }
17
+
18
+ { data: data, included: included }
19
+ end
20
+
21
+ def self.build(item)
22
+ {
23
+ 'id' => item['id'],
24
+ 'type' => item['type'],
25
+ 'attributes' => item.fetch('attributes', {}),
26
+ 'relationships' => item.fetch('relationships', {}),
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksign
4
+ module JsonApi
5
+ class QueryBuilder
6
+ def initialize
7
+ @params = {}
8
+ end
9
+
10
+ def filter(**filters)
11
+ filters.each { |k, v| @params["filter[#{k}]"] = v }
12
+ self
13
+ end
14
+
15
+ def include(*types)
16
+ @params['include'] = types.join(',')
17
+ self
18
+ end
19
+
20
+ def order(field)
21
+ @params['sort'] = field.to_s
22
+ self
23
+ end
24
+
25
+ def page(number)
26
+ @params['page[number]'] = number
27
+ self
28
+ end
29
+
30
+ def per(size)
31
+ @params['page[size]'] = size
32
+ self
33
+ end
34
+
35
+ def fields(**types)
36
+ types.each { |type, list| @params["fields[#{type}]"] = Array(list).join(',') }
37
+ self
38
+ end
39
+
40
+ def to_params
41
+ @params.dup
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksign
4
+ module JsonApi
5
+ module Serializer
6
+ def self.dump(type:, attributes:, id: nil, relationships: {})
7
+ data = { type: type, attributes: attributes }
8
+ data[:id] = id if id
9
+ data[:relationships] = relationships unless relationships.empty?
10
+ { data: data }
11
+ end
12
+ end
13
+ end
14
+ end