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,492 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Contractful
6
+ module Contract
7
+ # Turns a {Definition} plus user-supplied params into a real HTTP request,
8
+ # executes it through the normal contractful machinery and returns the
9
+ # {Contractful::Response}.
10
+ #
11
+ # The pipeline is:
12
+ #
13
+ # 1. symbolize keys, split per-call meta options (+_headers+, ...)
14
+ # 2. validate & coerce params against the contract
15
+ # 3. expand the path template, route remaining params to query/payload
16
+ # 4. run before_request callbacks with a mutable context
17
+ # 5. execute via Contractful::Request
18
+ # 6. run after_response callbacks
19
+ module Executor
20
+ # Per-call meta options (underscore prefix keeps them out of the
21
+ # contract's param namespace).
22
+ META_OPTIONS = %i[
23
+ _headers _timeout _open_timeout _read_timeout _write_timeout
24
+ _persistent _max_retries _retry_interval _idempotency_key
25
+ _retry_jitter _retry_jitter_cap _retry_rng _breaker _pace
26
+ ].freeze
27
+
28
+ META_MAP = {
29
+ _timeout: :timeout,
30
+ _open_timeout: :open_timeout,
31
+ _read_timeout: :read_timeout,
32
+ _write_timeout: :write_timeout,
33
+ _persistent: :persistent,
34
+ _max_retries: :max_retries,
35
+ _retry_interval: :retry_interval,
36
+ _retry_jitter: :retry_jitter,
37
+ _retry_jitter_cap: :retry_jitter_cap,
38
+ _retry_rng: :retry_rng,
39
+ _breaker: :breaker,
40
+ _pace: :pace
41
+ }.freeze
42
+
43
+ # Verbs whose params are sent as a query string by default.
44
+ QUERY_VERBS = %i[get head delete options].freeze
45
+
46
+ module_function
47
+
48
+ # Execute a contract.
49
+ #
50
+ # @param instance a client instance (includes Contractful::Contract)
51
+ # @param definition [Definition]
52
+ # @param params [Hash] user-supplied params (String or Symbol keys)
53
+ # @return [Contractful::Response]
54
+ def call(instance, definition, params)
55
+ params = symbolize_keys(params)
56
+ meta, params = split_meta(params)
57
+ coerced = validate_params(instance, definition, params)
58
+ path, remaining = definition.expand_path(coerced)
59
+ url = build_full_url(instance.__contract_base, path)
60
+
61
+ verb = definition.verb
62
+ headers = instance.__contract_effective_headers(meta[:_headers])
63
+ transported, remaining = split_transported_params(definition, remaining)
64
+ headers = headers.merge(transported[:headers]) unless transported[:headers].empty?
65
+ payload, query, headers =
66
+ build_body_and_query(instance, definition, verb, remaining, headers)
67
+
68
+ ctx = {
69
+ definition: definition,
70
+ method: verb,
71
+ url: url,
72
+ headers: headers,
73
+ payload: payload,
74
+ query: query,
75
+ cookies: transported[:cookies]
76
+ }
77
+
78
+ apply_auth!(ctx, instance, definition)
79
+ apply_idempotency!(ctx, instance, definition, meta)
80
+
81
+ response = transmit(ctx, instance, definition, meta)
82
+
83
+ # OAuth-style refresh-on-401: one-flight refresh of the contract's
84
+ # token provider, then exactly one retry with the fresh credential.
85
+ provider = token_provider_for(instance, definition)
86
+ if response.code.to_i == 401 && provider && !ctx.key?(:__token_refreshed)
87
+ TokenRefresh.refresh(provider)
88
+ ctx[:__token_refreshed] = true
89
+ apply_auth!(ctx, instance, definition)
90
+ response = transmit(ctx, instance, definition, meta)
91
+ end
92
+
93
+ validate_response!(definition, response)
94
+ instance.__run_after_callbacks(response)
95
+ response
96
+ end
97
+
98
+ # Build the full URL for a contract (used by the generated
99
+ # <tt>#{name}_url</tt> methods). Only path params are required; other
100
+ # declared params are accepted but not appended.
101
+ def build_url(instance, definition, params)
102
+ params = symbolize_keys(params || {})
103
+ coerced = {}
104
+ definition.path_param_names.each do |key|
105
+ param = definition.params[key]
106
+ value = params.key?(key) && !params[key].nil? ? params[key] : nil
107
+ value = param.default_value if value.nil? && param&.has_default?
108
+ if value.nil?
109
+ raise MissingParam,
110
+ "path param :#{key} is required by " \
111
+ "#{definition.path_template.inspect} but was not provided"
112
+ end
113
+ coerced[key] = param ? param.process!(value) : value
114
+ end
115
+ path, = definition.expand_path(coerced)
116
+ build_full_url(instance.__contract_base, path)
117
+ end
118
+
119
+ # --- internals ---------------------------------------------------------
120
+
121
+ # Run the before-callbacks and send the request. Extracted so the
122
+ # refresh-on-401 flow can re-run the identical phase with fresh auth.
123
+ def transmit(ctx, instance, definition, meta)
124
+ instance.__run_before_callbacks(ctx)
125
+
126
+ request_args = {
127
+ method: ctx[:method],
128
+ url: ctx[:url],
129
+ headers: ctx[:headers],
130
+ payload: ctx[:payload]
131
+ }
132
+ request_args[:headers] = request_args[:headers].merge(params: ctx[:query]) if ctx[:query] && !ctx[:query].empty?
133
+ request_args[:cookies] = ctx[:cookies] if ctx[:cookies] && !ctx[:cookies].empty?
134
+ merge_meta_options!(request_args, meta)
135
+ request_args[:idempotent] = true if idempotent_call?(definition, meta)
136
+
137
+ execute_request(request_args, definition, instance)
138
+ end
139
+
140
+ def token_provider_for(instance, definition)
141
+ return nil if definition.auth.equal?(false)
142
+
143
+ # auth may be declared on the contract itself or on the class
144
+ [definition.auth, instance.class.__merged_scalar(:auth)].each do |auth|
145
+ next unless auth.is_a?(Contractful::Contract::Auth)
146
+
147
+ provider = auth.token_provider
148
+ return provider if provider
149
+ end
150
+ nil
151
+ end
152
+
153
+ # Execute the request. When the contract declares expected error
154
+ # statuses (4xx/5xx) or a token provider can recover from a 401, the
155
+ # default contractful exception behavior is suppressed and every
156
+ # response is judged by the contract instead.
157
+ def execute_request(request_args, definition, instance)
158
+ if declares_error_status?(definition) || token_provider_for(instance, definition)
159
+ Contractful::Request.execute(request_args) do |response|
160
+ handle_suppressed(response)
161
+ end
162
+ else
163
+ Contractful::Request.execute(request_args)
164
+ end
165
+ end
166
+
167
+ # Does the contract declare any 4xx/5xx status (directly or via range)?
168
+ def declares_error_status?(definition)
169
+ return false unless definition.response_statuses
170
+
171
+ definition.response_statuses.any? do |status|
172
+ if status.is_a?(Range)
173
+ status.min <= 599 && status.max >= 400
174
+ else
175
+ status >= 400
176
+ end
177
+ end
178
+ end
179
+
180
+ # Mirror AbstractResponse#return! for redirects while suppressing the
181
+ # final error-raising: redirectable 30x responses are still followed
182
+ # (recursively), everything else is returned for contract validation.
183
+ def handle_suppressed(response)
184
+ case response.code
185
+ when 301, 302, 307
186
+ if %w[get head].include?(response.request.method)
187
+ response.follow_redirection { |redirected| handle_suppressed(redirected) }
188
+ else
189
+ response
190
+ end
191
+ when 303
192
+ response.follow_get_redirection { |redirected| handle_suppressed(redirected) }
193
+ else
194
+ response
195
+ end
196
+ end
197
+
198
+ # Validate the response against the contract's response declaration:
199
+ # declared statuses, JSON body type and verify_response blocks.
200
+ def validate_response!(definition, response)
201
+ # Execution suppresses the classic raise so that declared error statuses
202
+ # can come back as values. An error status the contract never declared
203
+ # must still raise, or it would reach the caller as a silent success.
204
+ response.return! if definition.response_statuses.nil? && response.code >= 400
205
+
206
+ return response unless needs_validation?(definition)
207
+
208
+ status = response.code
209
+ if definition.response_statuses &&
210
+ !definition.response_statuses.any? { |s| s === status }
211
+ raise UnexpectedResponse.new(
212
+ "unexpected status #{status} for #{definition.describe} " \
213
+ "(expected #{render_statuses(definition.response_statuses)})",
214
+ response: response
215
+ )
216
+ end
217
+
218
+ parsed = nil
219
+ if definition.response_type
220
+ parsed = parse_response_body(response, definition)
221
+ unless response_type_matches?(definition.response_type, parsed)
222
+ raise UnexpectedResponse.new(
223
+ "response body does not match #{definition.response_type_name} " \
224
+ "for #{definition.describe}",
225
+ response: response
226
+ )
227
+ end
228
+ end
229
+
230
+ unless definition.response_fields.empty?
231
+ parsed = parse_json_object(response, definition)
232
+ violations = definition.response_fields.flat_map do |_, field|
233
+ field.violations_for(parsed)
234
+ end
235
+ unless violations.empty?
236
+ raise UnexpectedResponse.new(
237
+ "response violates contract for #{definition.describe}: " \
238
+ "#{violations.join('; ')}",
239
+ response: response
240
+ )
241
+ end
242
+ end
243
+
244
+ if definition.response_verifier &&
245
+ !call_verifier(definition, definition.response_verifier, response, parsed)
246
+ raise UnexpectedResponse.new(
247
+ "verify_response block rejected the response for #{definition.describe}",
248
+ response: response
249
+ )
250
+ end
251
+
252
+ response
253
+ end
254
+
255
+ def needs_validation?(definition)
256
+ definition.response_statuses || definition.response_type ||
257
+ definition.response_verifier || !definition.response_fields.empty?
258
+ end
259
+
260
+ def render_statuses(statuses)
261
+ statuses.map { |s| s.is_a?(Range) ? s.inspect : s.to_s }.join(', ')
262
+ end
263
+
264
+ def parse_response_body(response, definition)
265
+ return response.body if definition.response_type.equal?(String)
266
+
267
+ body = response.body
268
+ if body.nil? || body.empty?
269
+ raise UnexpectedResponse.new(
270
+ "expected #{definition.response_type_name} but the response body " \
271
+ "is empty for #{definition.describe}",
272
+ response: response
273
+ )
274
+ end
275
+
276
+ begin
277
+ JSON.parse(body)
278
+ rescue JSON::ParserError => e
279
+ raise UnexpectedResponse.new(
280
+ "response body is not valid JSON for #{definition.describe}: #{e.message}",
281
+ response: response
282
+ )
283
+ end
284
+ end
285
+
286
+ # Parse the response body as a JSON object for response_field checks.
287
+ def parse_json_object(response, definition)
288
+ body = response.body
289
+ if body.nil? || body.empty?
290
+ raise UnexpectedResponse.new(
291
+ 'expected a JSON object but the response body is empty ' \
292
+ "for #{definition.describe}",
293
+ response: response
294
+ )
295
+ end
296
+
297
+ parsed = begin
298
+ JSON.parse(body)
299
+ rescue JSON::ParserError => e
300
+ raise UnexpectedResponse.new(
301
+ "response body is not valid JSON for #{definition.describe}: #{e.message}",
302
+ response: response
303
+ )
304
+ end
305
+ unless parsed.is_a?(Hash)
306
+ raise UnexpectedResponse.new(
307
+ "expected a JSON object but got #{parsed.class} " \
308
+ "for #{definition.describe} " \
309
+ '(response fields require an object body)',
310
+ response: response
311
+ )
312
+ end
313
+ parsed
314
+ end
315
+
316
+ def response_type_matches?(type, parsed)
317
+ return true if type.equal?(:json)
318
+ return true if type.equal?(String)
319
+
320
+ parsed.is_a?(type)
321
+ end
322
+
323
+ # Arity-aware verifier invocation: blocks that declare two parameters
324
+ # additionally receive the JSON-parsed body (parsed lazily).
325
+ def call_verifier(definition, block, response, parsed)
326
+ return block.call(response) unless block.arity >= 2
327
+
328
+ parsed = verifier_json(definition, response) if parsed.nil?
329
+ block.call(response, parsed)
330
+ end
331
+
332
+ # The JSON body for a two-parameter verifier. A body that is not JSON is an
333
+ # unexpected response: handing the block nil would let it mistake a failure
334
+ # for data.
335
+ def verifier_json(definition, response)
336
+ JSON.parse(response.body)
337
+ rescue JSON::ParserError, TypeError => e
338
+ raise UnexpectedResponse.new(
339
+ "verify_response needs a JSON body for #{definition.describe}: #{e.message}",
340
+ response: response
341
+ )
342
+ end
343
+
344
+ # Resolve the contract's auth scheme (unless the contract opted out
345
+ # with `auth false`) and merge its headers/query into the context.
346
+ # Auth runs before the before_request callbacks, so callbacks can
347
+ # still inspect or override the resolved credentials.
348
+ def apply_auth!(ctx, instance, definition)
349
+ return if definition.auth.equal?(false)
350
+
351
+ auth = instance.class.__merged_scalar(:auth)
352
+ return unless auth.is_a?(Auth)
353
+
354
+ resolved = auth.resolve
355
+ ctx[:headers] = ctx[:headers].merge(resolved[:headers]) unless resolved[:headers].empty?
356
+ return if resolved[:query].empty?
357
+
358
+ ctx[:query] = (ctx[:query] || {}).merge(resolved[:query])
359
+
360
+ end
361
+
362
+ # Inject an idempotency key when the contract is flagged `idempotent`
363
+ # or the caller supplied an explicit `_idempotency_key`. The key is
364
+ # generated once per logical call, so automatic retries of the same
365
+ # request reuse it (that is the entire point of the header).
366
+ def apply_idempotency!(ctx, instance, definition, meta)
367
+ explicit = meta[:_idempotency_key]
368
+ return unless explicit || definition.idempotent.equal?(true)
369
+
370
+ key = explicit || instance.class.idempotency_generator.call
371
+ header = instance.class.idempotency_header
372
+ ctx[:headers] = ctx[:headers].merge(header => key)
373
+ end
374
+
375
+ # A call is idempotent when the contract declares it or the caller pins
376
+ # a key. Both cases send a key the server can deduplicate on, which is
377
+ # what makes retrying a non-GET call safe.
378
+ def idempotent_call?(definition, meta)
379
+ definition.idempotent.equal?(true) || !meta[:_idempotency_key].nil?
380
+ end
381
+
382
+ def symbolize_keys(hash)
383
+ hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
384
+ end
385
+
386
+ def split_meta(params)
387
+ meta = {}
388
+ regular = {}
389
+ params.each do |key, value|
390
+ if key.to_s.start_with?('_')
391
+ unless META_OPTIONS.include?(key)
392
+ raise UnknownParam,
393
+ "unknown meta option #{key.inspect} " \
394
+ "(supported: #{META_OPTIONS.map(&:inspect).join(', ')})"
395
+ end
396
+ raise ParamError, "_headers must be a Hash, got #{value.inspect}" if key == :_headers && !value.is_a?(Hash)
397
+
398
+ meta[key] = value
399
+ else
400
+ regular[key] = value
401
+ end
402
+ end
403
+ [meta, regular]
404
+ end
405
+
406
+ def validate_params(instance, definition, params)
407
+ coerced = {}
408
+ definition.params.each do |name, param|
409
+ value = params[name]
410
+ if !value.nil?
411
+ coerced[name] = param.process!(value)
412
+ elsif param.has_default?
413
+ coerced[name] = param.process!(param.default_value)
414
+ elsif param.required?
415
+ raise MissingParam,
416
+ "param :#{name} is required by #{definition.describe}"
417
+ end
418
+ end
419
+
420
+ unknown = params.keys - definition.params.keys
421
+ if unknown.any?
422
+ if strict_params?(instance, definition)
423
+ raise UnknownParam,
424
+ "unknown param #{unknown.map { |k| ":#{k}" }.join(', ')} " \
425
+ "for #{definition.describe} " \
426
+ '(disable with strict_params false)'
427
+ end
428
+ unknown.each { |key| coerced[key] = params[key] }
429
+ end
430
+ coerced
431
+ end
432
+
433
+ def strict_params?(instance, definition = nil)
434
+ # Per-contract override wins over the class-level setting.
435
+ return definition.strict if definition && !definition.strict.nil?
436
+
437
+ value = instance.class.__merged_scalar(:strict_params)
438
+ value.nil? || value
439
+ end
440
+
441
+ def effective_payload_style(instance, definition, verb)
442
+ definition.payload_style ||
443
+ instance.class.__merged_scalar(:payload_style) ||
444
+ (QUERY_VERBS.include?(verb) ? :query : :json)
445
+ end
446
+
447
+ # Header and cookie params leave the path/query/payload routing. Their
448
+ # values come back keyed by wire name: a header hash and a cookie hash.
449
+ def split_transported_params(definition, remaining)
450
+ transported = { headers: {}, cookies: {} }
451
+ body = {}
452
+ remaining.each do |key, value|
453
+ param = definition.params[key]
454
+ case param&.location
455
+ when :header then transported[:headers][param.header] = value.to_s
456
+ when :cookie then transported[:cookies][param.cookie] = value.to_s
457
+ else body[key] = value
458
+ end
459
+ end
460
+ [transported, body]
461
+ end
462
+
463
+ def build_body_and_query(instance, definition, verb, remaining, headers)
464
+ return [nil, nil, headers] if remaining.empty?
465
+
466
+ style = effective_payload_style(instance, definition, verb)
467
+ if QUERY_VERBS.include?(verb) || style == :query
468
+ [nil, remaining, headers]
469
+ elsif style == :form
470
+ [remaining, nil, headers]
471
+ else # :json
472
+ unless headers.keys.any? { |k| k.to_s.casecmp('content-type').zero? }
473
+ headers = headers.merge('Content-Type' => 'application/json')
474
+ end
475
+ [JSON.generate(remaining), nil, headers]
476
+ end
477
+ end
478
+
479
+ def build_full_url(base, path)
480
+ base = base.sub(%r{/+\z}, '')
481
+ path = "/#{path}" unless path.start_with?('/')
482
+ "#{base}#{path}"
483
+ end
484
+
485
+ def merge_meta_options!(request_args, meta)
486
+ META_MAP.each do |meta_key, option_key|
487
+ request_args[option_key] = meta[meta_key] if meta.key?(meta_key)
488
+ end
489
+ end
490
+ end
491
+ end
492
+ end