contractful 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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/API.md +142 -0
  3. data/ARCHITECTURE.md +134 -0
  4. data/AUTHORS +106 -0
  5. data/CODE_OF_CONDUCT.md +128 -0
  6. data/LICENSE +21 -0
  7. data/README.md +1290 -0
  8. data/SECURITY.md +67 -0
  9. data/bin/contractful +128 -0
  10. data/history.md +801 -0
  11. data/lib/contractful/abstract_response.rb +301 -0
  12. data/lib/contractful/circuit_breaker.rb +221 -0
  13. data/lib/contractful/connection_pool.rb +166 -0
  14. data/lib/contractful/contract/auth.rb +257 -0
  15. data/lib/contractful/contract/config.rb +93 -0
  16. data/lib/contractful/contract/definition.rb +295 -0
  17. data/lib/contractful/contract/dsl.rb +501 -0
  18. data/lib/contractful/contract/executor.rb +492 -0
  19. data/lib/contractful/contract/openapi_import.rb +1135 -0
  20. data/lib/contractful/contract/pagination.rb +218 -0
  21. data/lib/contractful/contract/param.rb +505 -0
  22. data/lib/contractful/contract/postman_import/auth.rb +39 -0
  23. data/lib/contractful/contract/postman_import/body.rb +87 -0
  24. data/lib/contractful/contract/postman_import/operation.rb +107 -0
  25. data/lib/contractful/contract/postman_import/scope.rb +37 -0
  26. data/lib/contractful/contract/postman_import/url.rb +80 -0
  27. data/lib/contractful/contract/postman_import.rb +164 -0
  28. data/lib/contractful/contract/response_field.rb +167 -0
  29. data/lib/contractful/contract/sequence.rb +432 -0
  30. data/lib/contractful/contract/swagger2/components.rb +84 -0
  31. data/lib/contractful/contract/swagger2/operations.rb +81 -0
  32. data/lib/contractful/contract/swagger2/parameters.rb +93 -0
  33. data/lib/contractful/contract/swagger2/references.rb +55 -0
  34. data/lib/contractful/contract/swagger2.rb +55 -0
  35. data/lib/contractful/contract.rb +85 -0
  36. data/lib/contractful/exceptions.rb +328 -0
  37. data/lib/contractful/notifications/active_support.rb +40 -0
  38. data/lib/contractful/notifications.rb +138 -0
  39. data/lib/contractful/pacer.rb +161 -0
  40. data/lib/contractful/params_array.rb +85 -0
  41. data/lib/contractful/payload.rb +340 -0
  42. data/lib/contractful/platform.rb +52 -0
  43. data/lib/contractful/rate_limit.rb +133 -0
  44. data/lib/contractful/raw_response.rb +70 -0
  45. data/lib/contractful/request.rb +1483 -0
  46. data/lib/contractful/resource.rb +189 -0
  47. data/lib/contractful/response.rb +109 -0
  48. data/lib/contractful/utils.rb +202 -0
  49. data/lib/contractful/version.rb +11 -0
  50. data/lib/contractful/windows/root_certs.rb +107 -0
  51. data/lib/contractful/windows.rb +9 -0
  52. data/lib/contractful.rb +233 -0
  53. metadata +244 -0
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contractful
4
+ module Contract
5
+ module Swagger2
6
+ # Swagger 2.0 keeps definitions, parameters and responses in top-level
7
+ # sections; OpenAPI 3.0 keeps them under components. Local $refs follow.
8
+ module References
9
+ SECTIONS = { 'definitions' => 'schemas', 'parameters' => 'parameters', 'responses' => 'responses' }.freeze
10
+
11
+ module_function
12
+
13
+ # Copy a document with its local $refs translated. Memoized by identity,
14
+ # so YAML aliases and cycles copy once and terminate.
15
+ def translate(node, memo = {}.compare_by_identity)
16
+ case node
17
+ when Hash then translate_hash(node, memo)
18
+ when Array then translate_array(node, memo)
19
+ else node
20
+ end
21
+ end
22
+
23
+ def translate_hash(node, memo)
24
+ return memo[node] if memo.key?(node)
25
+
26
+ out = memo[node] = {}
27
+ node.each do |key, value|
28
+ out[key] = key == '$ref' && value.is_a?(String) ? translate_ref(value) : translate(value, memo)
29
+ end
30
+ out
31
+ end
32
+
33
+ def translate_array(node, memo)
34
+ return memo[node] if memo.key?(node)
35
+
36
+ out = memo[node] = []
37
+ node.each { |value| out << translate(value, memo) }
38
+ out
39
+ end
40
+
41
+ def translate_ref(ref)
42
+ ref.start_with?('#') ? "##{translate_pointer(ref[1..])}" : ref
43
+ end
44
+
45
+ def translate_pointer(fragment)
46
+ return nil if fragment.nil?
47
+
48
+ fragment.sub(%r{\A/(definitions|parameters|responses)(?=/|\z)}) do
49
+ "/components/#{SECTIONS.fetch(Regexp.last_match(1))}"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'swagger2/references'
4
+ require_relative 'swagger2/parameters'
5
+ require_relative 'swagger2/operations'
6
+ require_relative 'swagger2/components'
7
+
8
+ module Contractful
9
+ module Contract
10
+ # Converts a Swagger 2.0 document into the OpenAPI 3.0 shape that
11
+ # {OpenAPIImport} reads. The conversion is structural and covers the
12
+ # constructs the importer maps: paths and operations, parameters (path,
13
+ # query, header, body and formData), responses, definitions and security.
14
+ # Everything else is copied through unchanged.
15
+ #
16
+ # Opt-in, like {OpenAPIImport}: it is loaded by
17
+ # <tt>require 'contractful/contract/openapi_import'</tt>.
18
+ module Swagger2
19
+ module_function
20
+
21
+ # @return [Boolean] true for a Hash that declares a Swagger 2.x version
22
+ def swagger?(doc)
23
+ doc.is_a?(Hash) && doc['swagger'].to_s.start_with?('2.')
24
+ end
25
+
26
+ # Convert a Swagger 2.0 document (a Hash) into an OpenAPI 3.0.3 Hash. The
27
+ # input is not modified.
28
+ def convert(doc)
29
+ root = References.translate(doc)
30
+ { 'openapi' => '3.0.3', 'x-contractful-source' => 'Swagger 2.0' }
31
+ .merge(document_fields(root))
32
+ .merge(root.select { |key, _| key.to_s.start_with?('x-') })
33
+ end
34
+
35
+ def document_fields(root)
36
+ fields = { 'servers' => Components.servers(root), 'paths' => Operations.convert_paths(root) }
37
+ fields['info'] = root['info'] if root['info']
38
+ fields['security'] = root['security'] if root['security']
39
+ components = Components.components(root)
40
+ fields['components'] = components unless components.empty?
41
+ fields
42
+ end
43
+
44
+ # Map a Swagger 2.0 $ref fragment to its OpenAPI 3.0 location, for refs
45
+ # from other files into a converted document.
46
+ def translate_pointer(fragment)
47
+ References.translate_pointer(fragment)
48
+ end
49
+
50
+ def list(value)
51
+ value.is_a?(Array) ? value : []
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Contractful
6
+ # Contract-first architecture for contractful.
7
+ #
8
+ # Instead of hand-writing request code, you declare a *contract* — the HTTP
9
+ # shape of an API (verb, path, typed parameters, expected responses) — and
10
+ # metaprogramming generates the client methods, path builders, validation
11
+ # and introspection for you:
12
+ #
13
+ # class GitHubAPI
14
+ # include Contractful::Contract
15
+ #
16
+ # base 'https://api.github.com'
17
+ # default_headers 'Accept' => 'application/vnd.github+json'
18
+ #
19
+ # contract :list_users, 'GET /users' do
20
+ # param :page, Integer, default: 1
21
+ # param :per_page, Integer, inclusion: (1..100), default: 30
22
+ # end
23
+ #
24
+ # contract :create_issue, 'POST /repos/:owner/:repo/issues' do
25
+ # param :owner, String, required: true
26
+ # param :repo, String, required: true
27
+ # param :title, String, required: true
28
+ # end
29
+ # end
30
+ #
31
+ # api = GitHubAPI.new
32
+ # api.list_users(page: 2)
33
+ # api.create_issue(owner: 'octo', repo: 'hello', title: 'Hi')
34
+ #
35
+ # The contract *is* the implementation: nothing about the request is
36
+ # written imperatively. Violations raise precise errors from the
37
+ # {Contract::Error} hierarchy instead of producing malformed requests.
38
+ module Contract
39
+ # Root of all contract errors (definition mistakes, param violations,
40
+ # response mismatches, misconfiguration).
41
+ class Error < StandardError; end
42
+
43
+ # Raised at definition time when a contract, param or macro is invalid.
44
+ class InvalidDefinition < Error; end
45
+
46
+ # Raised at execution time when no base URL is configured.
47
+ class MissingBaseUrl < Error; end
48
+
49
+ # Base class for parameter violations (missing, unknown, invalid).
50
+ class ParamError < Error; end
51
+
52
+ # A required param was not provided.
53
+ class MissingParam < ParamError; end
54
+
55
+ # A param was provided that the contract does not declare (strict mode).
56
+ class UnknownParam < ParamError; end
57
+
58
+ # A param value could not be coerced/validated against its declared type.
59
+ class InvalidParam < ParamError; end
60
+
61
+ # The HTTP response did not satisfy the contract's response declaration
62
+ # (unexpected status code, body type mismatch or failed verify_response
63
+ # block). The offending +response+ is attached for inspection.
64
+ class UnexpectedResponse < Error
65
+ attr_reader :response
66
+
67
+ def initialize(message = nil, response: nil)
68
+ @response = response
69
+ super(message)
70
+ end
71
+ end
72
+
73
+ # Sentinel for "no default value declared" (distinct from `default: nil`).
74
+ NO_DEFAULT = Object.new.freeze
75
+ end
76
+ end
77
+
78
+ require_relative 'contract/param'
79
+ require_relative 'contract/response_field'
80
+ require_relative 'contract/auth'
81
+ require_relative 'contract/pagination'
82
+ require_relative 'contract/definition'
83
+ require_relative 'contract/config'
84
+ require_relative 'contract/executor'
85
+ require_relative 'contract/dsl'
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Root namespace: exceptions raised for non-2xx responses and request
4
+ # failures, each carrying the originating response when one exists.
5
+ module Contractful
6
+ # Hash of HTTP status code => message.
7
+ #
8
+ # 1xx: Informational - Request received, continuing process
9
+ # 2xx: Success - The action was successfully received, understood, and
10
+ # accepted
11
+ # 3xx: Redirection - Further action must be taken in order to complete the
12
+ # request
13
+ # 4xx: Client Error - The request contains bad syntax or cannot be fulfilled
14
+ # 5xx: Server Error - The server failed to fulfill an apparently valid
15
+ # request
16
+ #
17
+ # @see
18
+ # http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
19
+ #
20
+ STATUSES = { 100 => 'Continue',
21
+ 101 => 'Switching Protocols',
22
+ 102 => 'Processing', # WebDAV
23
+
24
+ 200 => 'OK',
25
+ 201 => 'Created',
26
+ 202 => 'Accepted',
27
+ 203 => 'Non-Authoritative Information', # http/1.1
28
+ 204 => 'No Content',
29
+ 205 => 'Reset Content',
30
+ 206 => 'Partial Content',
31
+ 207 => 'Multi-Status', # WebDAV
32
+ 208 => 'Already Reported', # RFC5842
33
+ 226 => 'IM Used', # RFC3229
34
+
35
+ 300 => 'Multiple Choices',
36
+ 301 => 'Moved Permanently',
37
+ 302 => 'Found',
38
+ 303 => 'See Other', # http/1.1
39
+ 304 => 'Not Modified',
40
+ 305 => 'Use Proxy', # http/1.1
41
+ 306 => 'Switch Proxy', # no longer used
42
+ 307 => 'Temporary Redirect', # http/1.1
43
+ 308 => 'Permanent Redirect', # RFC7538
44
+
45
+ 400 => 'Bad Request',
46
+ 401 => 'Unauthorized',
47
+ 402 => 'Payment Required',
48
+ 403 => 'Forbidden',
49
+ 404 => 'Not Found',
50
+ 405 => 'Method Not Allowed',
51
+ 406 => 'Not Acceptable',
52
+ 407 => 'Proxy Authentication Required',
53
+ 408 => 'Request Timeout',
54
+ 409 => 'Conflict',
55
+ 410 => 'Gone',
56
+ 411 => 'Length Required',
57
+ 412 => 'Precondition Failed',
58
+ 413 => 'Payload Too Large', # RFC7231 (renamed, see below)
59
+ 414 => 'URI Too Long', # RFC7231 (renamed, see below)
60
+ 415 => 'Unsupported Media Type',
61
+ 416 => 'Range Not Satisfiable', # RFC7233 (renamed, see below)
62
+ 417 => 'Expectation Failed',
63
+ 418 => 'I\'m A Teapot', # RFC2324
64
+ 421 => 'Too Many Connections From This IP',
65
+ 422 => 'Unprocessable Entity', # WebDAV
66
+ 423 => 'Locked', # WebDAV
67
+ 424 => 'Failed Dependency', # WebDAV
68
+ 425 => 'Unordered Collection', # WebDAV
69
+ 426 => 'Upgrade Required',
70
+ 428 => 'Precondition Required', # RFC6585
71
+ 429 => 'Too Many Requests', # RFC6585
72
+ 431 => 'Request Header Fields Too Large', # RFC6585
73
+ 449 => 'Retry With', # Microsoft
74
+ 450 => 'Blocked By Windows Parental Controls', # Microsoft
75
+
76
+ 500 => 'Internal Server Error',
77
+ 501 => 'Not Implemented',
78
+ 502 => 'Bad Gateway',
79
+ 503 => 'Service Unavailable',
80
+ 504 => 'Gateway Timeout',
81
+ 505 => 'HTTP Version Not Supported',
82
+ 506 => 'Variant Also Negotiates',
83
+ 507 => 'Insufficient Storage', # WebDAV
84
+ 508 => 'Loop Detected', # RFC5842
85
+ 509 => 'Bandwidth Limit Exceeded', # Apache
86
+ 510 => 'Not Extended',
87
+ 511 => 'Network Authentication Required' } # RFC6585
88
+
89
+ STATUSES_COMPATIBILITY = {
90
+ # The RFCs all specify "Not Found", but "Resource Not Found" was used in
91
+ # earlier Contractful releases.
92
+ 404 => ['ResourceNotFound'],
93
+
94
+ # HTTP 413 was renamed to "Payload Too Large" in RFC7231.
95
+ 413 => ['RequestEntityTooLarge'],
96
+
97
+ # HTTP 414 was renamed to "URI Too Long" in RFC7231.
98
+ 414 => ['RequestURITooLong'],
99
+
100
+ # HTTP 416 was renamed to "Range Not Satisfiable" in RFC7233.
101
+ 416 => ['RequestedRangeNotSatisfiable']
102
+ }
103
+
104
+ # This is the base Contractful exception class. Rescue it if you want to
105
+ # catch any exception that your request might raise
106
+ # You can get the status code by e.http_code, or see anything about the
107
+ # response via e.response.
108
+ # For example, the entire result body (which is
109
+ # probably an HTML error page) is e.response.
110
+ class Exception < RuntimeError
111
+ attr_accessor :response, :original_exception
112
+ attr_writer :message
113
+
114
+ def initialize response = nil, initial_response_code = nil
115
+ super()
116
+ @response = response
117
+ @message = nil
118
+ @initial_response_code = initial_response_code
119
+ end
120
+
121
+ def http_code
122
+ # return integer for compatibility
123
+ if @response
124
+ @response.code.to_i
125
+ else
126
+ @initial_response_code
127
+ end
128
+ end
129
+
130
+ def http_headers
131
+ @response&.headers
132
+ end
133
+
134
+ def http_body
135
+ @response&.body
136
+ end
137
+
138
+ # Part of the error model: is this failure transient (worth retrying)
139
+ # or terminal? Exceptions without a response are terminal by default;
140
+ # subclasses override (see {Exceptions::RETRIABLE_STATUSES} for the
141
+ # status classification of response-bearing errors).
142
+ def retryable?
143
+ false
144
+ end
145
+
146
+ def to_s
147
+ message
148
+ end
149
+
150
+ def message
151
+ @message || default_message
152
+ end
153
+
154
+ def default_message
155
+ self.class.name
156
+ end
157
+ end
158
+
159
+ # Compatibility
160
+ class ExceptionWithResponse < Contractful::Exception
161
+ # Retryable when the HTTP status itself classifies as transient
162
+ # (408/425/429 and the usual 5xx gateway/server errors — see
163
+ # {Exceptions::RETRIABLE_STATUSES}). Every other status is terminal.
164
+ def retryable?
165
+ Exceptions::RETRIABLE_STATUSES.include?(http_code)
166
+ end
167
+ end
168
+
169
+ # The request failed with an error code not managed by the code
170
+ class RequestFailed < ExceptionWithResponse
171
+ def default_message
172
+ "HTTP status code #{http_code}"
173
+ end
174
+
175
+ def to_s
176
+ message
177
+ end
178
+ end
179
+
180
+ # Contractful exception classes. TODO: move all exceptions into this module.
181
+ #
182
+ # We will a create an exception for each status code, see
183
+ # http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
184
+ #
185
+ module Exceptions
186
+ # Map http status codes to the corresponding exception class
187
+ EXCEPTIONS_MAP = {}
188
+
189
+ # HTTP statuses whose failures are TRANSIENT rather than terminal — the
190
+ # retryable side of the error model. 408 (request timeout), 425 (too
191
+ # early), 429 (rate limited) and the standard gateway/server breakdowns.
192
+ # Everything else (401, 404, 422, 501, ...) is terminal: retrying will
193
+ # not fix a bad token, a missing resource or a bad payload.
194
+ RETRIABLE_STATUSES = [408, 425, 429, 500, 502, 503, 504].freeze
195
+
196
+ # Network-level failure classes that classify as retryable. NOTE:
197
+ # ::Timeout::Error is referenced with a top-level scope because
198
+ # Contractful::Exceptions::Timeout shadows the Timeout module here.
199
+ def self.network_errors
200
+ @network_errors ||= begin
201
+ require 'net/http'
202
+ require 'timeout'
203
+ [
204
+ SocketError,
205
+ EOFError,
206
+ Errno::ECONNRESET,
207
+ Errno::ECONNABORTED,
208
+ Errno::EPIPE,
209
+ Errno::ECONNREFUSED,
210
+ Errno::EHOSTUNREACH,
211
+ Errno::ENETUNREACH,
212
+ ::Timeout::Error,
213
+ Net::OpenTimeout,
214
+ Net::ReadTimeout,
215
+ Net::WriteTimeout
216
+ ].freeze
217
+ end
218
+ end
219
+
220
+ # Classify a failure as retryable (transient) or terminal.
221
+ #
222
+ # Accepts an Integer HTTP status, a Contractful::Exception instance, or
223
+ # any other exception object (checked against {network_errors}).
224
+ #
225
+ # Exceptions.retryable?(429) # => true
226
+ # Exceptions.retryable?(404) # => false
227
+ # Exceptions.retryable?(Contractful::NotFound.new) # => false
228
+ # Exceptions.retryable?(SocketError.new('boom')) # => true
229
+ def self.retryable?(candidate)
230
+ case candidate
231
+ when Integer
232
+ RETRIABLE_STATUSES.include?(candidate)
233
+ when Contractful::Exception
234
+ candidate.retryable?
235
+ else
236
+ network_errors.any? { |klass| candidate.is_a?(klass) }
237
+ end
238
+ end
239
+ end
240
+
241
+ # Create HTTP status exception classes
242
+ STATUSES.each_pair do |code, message|
243
+ klass = Class.new(RequestFailed) do
244
+ send(:define_method, :default_message) { "#{"#{http_code} " if http_code}#{message}" }
245
+ end
246
+ klass_constant = const_set(message.delete(' \-\''), klass)
247
+ Exceptions::EXCEPTIONS_MAP[code] = klass_constant
248
+ end
249
+
250
+ # Create HTTP status exception classes used for backwards compatibility
251
+ STATUSES_COMPATIBILITY.each_pair do |code, compat_list|
252
+ klass = Exceptions::EXCEPTIONS_MAP.fetch(code)
253
+ compat_list.each do |old_name|
254
+ const_set(old_name, klass)
255
+ end
256
+ end
257
+
258
+ module Exceptions
259
+ # We have to split the Exceptions module like we do here because the
260
+ # EXCEPTIONS_MAP is under Exceptions, but we depend on
261
+ # Contractful::RequestTimeout below.
262
+
263
+ # Base class for request timeouts.
264
+ #
265
+ # NB: Previous releases of contractful would raise RequestTimeout both for
266
+ # HTTP 408 responses and for actual connection timeouts.
267
+ class Timeout < Contractful::RequestTimeout
268
+ def initialize(message = nil, original_exception = nil)
269
+ super(nil, nil)
270
+ self.message = message if message
271
+ self.original_exception = original_exception if original_exception
272
+ end
273
+
274
+ # A network timeout never carries an HTTP status, so the status-based
275
+ # classification cannot see it — network timeouts are transient by
276
+ # definition.
277
+ def retryable?
278
+ true
279
+ end
280
+ end
281
+
282
+ # Timeout when connecting to a server. Typically wraps Net::OpenTimeout (in
283
+ # ruby 2.0 or greater).
284
+ class OpenTimeout < Timeout
285
+ def default_message
286
+ 'Timed out connecting to server'
287
+ end
288
+ end
289
+
290
+ # Timeout when reading from a server. Typically wraps Net::ReadTimeout (in
291
+ # ruby 2.0 or greater).
292
+ class ReadTimeout < Timeout
293
+ def default_message
294
+ 'Timed out reading data from server'
295
+ end
296
+ end
297
+
298
+ # Timeout when writing to a server. Typically wraps Net::WriteTimeout.
299
+ class WriteTimeout < Timeout
300
+ def default_message
301
+ 'Timed out writing data to server'
302
+ end
303
+ end
304
+ end
305
+
306
+ # The server broke the connection prior to the request completing. Usually
307
+ # this means it crashed, or sometimes that your network connection was
308
+ # severed before it could complete.
309
+ class ServerBrokeConnection < Contractful::Exception
310
+ def initialize(message = 'Server broke connection')
311
+ super(nil, nil)
312
+ self.message = message
313
+ end
314
+
315
+ def retryable?
316
+ true
317
+ end
318
+ end
319
+
320
+ # Raised when an HTTPS response fails certificate verification while
321
+ # verify_ssl is enabled.
322
+ class SSLCertificateNotVerified < Contractful::Exception
323
+ def initialize(message = 'SSL certificate not verified')
324
+ super(nil, nil)
325
+ self.message = message
326
+ end
327
+ end
328
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional bridge from contractful's internal notification hub to
4
+ # ActiveSupport::Notifications (the Rails-ecosystem standard). Loading this
5
+ # file — never loaded by <tt>require 'contractful'</tt> — forwards every
6
+ # {Contractful::Notifications} event to AS::Notifications so Rails
7
+ # subscribers, LogSubscribers and APM agents receive them natively:
8
+ #
9
+ # require 'contractful'
10
+ # require 'contractful/notifications/active_support'
11
+ #
12
+ # ActiveSupport::Notifications.subscribe(/^contractful\./) do |*args|
13
+ # event = ActiveSupport::Notifications::Event.new(*args)
14
+ # Rails.logger.info "#{event.name} #{event.payload.inspect} (#{event.duration}ms)"
15
+ # end
16
+ #
17
+ # Each event is forwarded as <tt>contractful.<name></tt> with the event
18
+ # payload as instrument payload (plus the monotonic <tt>:at</tt> timestamp).
19
+ # Unsubscribe from the internal hub with
20
+ # <tt>Contractful::Notifications.clear!</tt> to detach the bridge.
21
+ #
22
+ # Requires the +activesupport+ gem (>= 6.0) at load time; it is an optional
23
+ # integration and never a runtime dependency of the gem itself.
24
+
25
+ # 'active_support' rather than just 'active_support/notifications': since
26
+ # ActiveSupport 7.1 the notifications module depends on
27
+ # IsolatedExecutionState and friends and is not self-sufficient standalone.
28
+ begin
29
+ require 'active_support'
30
+ rescue LoadError => e
31
+ raise LoadError,
32
+ "contractful's ActiveSupport bridge requires the activesupport gem " \
33
+ "(add `gem 'activesupport'` to your Gemfile): #{e.message}"
34
+ end
35
+
36
+ Contractful::Notifications.subscribe do |event|
37
+ ActiveSupport::Notifications.instrument(
38
+ "contractful.#{event[:name]}", **event[:payload], at: event[:at]
39
+ )
40
+ end