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,501 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Contract layer: declarative, first-class API definitions compiled into
4
+ # real methods (see docs/adr/0002 and 0003).
5
+ module Contractful
6
+ # Declaration DSL and metaprogramming machinery for contract clients.
7
+ module Contract
8
+ # Metaprogramming machinery injected into classes that include
9
+ # {Contractful::Contract}.
10
+ #
11
+ # {ClassMethods} provides the declaration macros (+base+, +contract+, ...)
12
+ # and generates one real instance method per contract via +define_method+
13
+ # (plus a <tt>#{name}_url</tt> path builder). {InstanceMethods} provides
14
+ # the small runtime surface the {Executor} needs.
15
+ module ClassMethods
16
+ # Configure the base URL all contract paths are resolved against.
17
+ # Trailing slashes are stripped.
18
+ def base(url)
19
+ unless url.is_a?(String) && url.match?(%r{\Ahttps?://}i)
20
+ raise InvalidDefinition,
21
+ "base must be an absolute http(s) URL, got #{url.inspect}"
22
+ end
23
+ __own_contract_config.base_url = url.sub(%r{/+\z}, '')
24
+ end
25
+
26
+ # Declare default headers merged into every request. Called with no
27
+ # arguments it returns the headers accumulated down the ancestor chain.
28
+ def default_headers(headers = nil)
29
+ if headers.nil?
30
+ merged = {}
31
+ __contract_config_chain.reverse_each do |config|
32
+ merged.update(config.default_headers) if config.default_headers
33
+ end
34
+ merged
35
+ else
36
+ __own_contract_config.default_headers = headers
37
+ end
38
+ end
39
+
40
+ # Toggle strict param checking (default: strict). In strict mode,
41
+ # providing params the contract does not declare raises {UnknownParam}.
42
+ def strict_params(flag = true)
43
+ __own_contract_config.strict_params = flag
44
+ end
45
+
46
+ # Default payload style for non-query params: +:json+ or +:form+.
47
+ # Per-contract `payload_style` inside the block wins over this.
48
+ def payload_style(style)
49
+ __own_contract_config.payload_style = style
50
+ end
51
+
52
+ # Declare the authentication scheme(s) for this client. The contract is
53
+ # the source of truth for HOW to authenticate; you supply only the
54
+ # credentials (literals or callables — callables are invoked per
55
+ # request so tokens stay fresh):
56
+ #
57
+ # auth bearer: -> { ENV['API_TOKEN'] }
58
+ # auth api_key: { name: 'access-token', value: -> { token } }
59
+ # auth api_key: [
60
+ # { name: 'access-token', value: -> { token } },
61
+ # { name: 'client-id', value: client_id },
62
+ # ]
63
+ # auth basic: -> { [user, pass] }
64
+ #
65
+ # Called with no arguments it returns the resolved Auth object (or
66
+ # nil/false). Individual contracts can opt out with
67
+ # <tt>auth false</tt> inside the contract block.
68
+
69
+ def auth(**schemes)
70
+ return __merged_scalar(:auth) if schemes.empty?
71
+
72
+ __own_contract_config.auth = Contract::Auth.new(**schemes)
73
+ end
74
+
75
+ # Header carrying the idempotency key (default 'Idempotency-Key').
76
+ def idempotency_header(name = nil)
77
+ return __merged_scalar(:idempotency_header) || 'Idempotency-Key' if name.nil?
78
+
79
+ __own_contract_config.idempotency_header = name
80
+ end
81
+
82
+ # Generator producing idempotency keys (default: SecureRandom.uuid).
83
+ def idempotency_generator(generator = nil)
84
+ if generator.nil?
85
+ return __merged_scalar(:idempotency_generator) ||
86
+ -> { ::SecureRandom.uuid }
87
+ end
88
+ __own_contract_config.idempotency_generator = generator
89
+ end
90
+
91
+ # Register a callback executed before the HTTP request is sent. The
92
+ # block receives a mutable context Hash:
93
+ #
94
+ # { definition:, method:, url:, headers:, payload:, query: }
95
+ #
96
+ # Mutating the context changes the outgoing request (for example
97
+ # injecting Authorization headers or rewriting the URL). Callbacks
98
+ # accumulate down the inheritance chain, root-most first.
99
+ def before_request(&block)
100
+ __own_contract_config.add_callback(:before_request, block)
101
+ end
102
+
103
+ # Register a callback executed after a contract-valid response is
104
+ # received (after response validation, before the contract method
105
+ # returns). The block receives the Contractful::Response; return values
106
+ # are ignored. Callbacks accumulate down the inheritance chain,
107
+ # root-most first.
108
+ def after_response(&block)
109
+ __own_contract_config.add_callback(:after_response, block)
110
+ end
111
+
112
+ # Generate the five standard REST contracts for a collection. The
113
+ # contracts are registered and method-generated exactly like hand
114
+ # written ones — they show up in introspection and get `*_url`
115
+ # builders for free:
116
+ #
117
+ # resource :posts
118
+ #
119
+ # is equivalent to declaring:
120
+ #
121
+ # contract :list_posts, 'GET /posts'
122
+ # contract :get_post, 'GET /posts/:id'
123
+ # contract :create_post, 'POST /posts'
124
+ # contract :update_post, 'PATCH /posts/:id'
125
+ # contract :delete_post, 'DELETE /posts/:id'
126
+ #
127
+ # Generated CRUD contracts are non-strict: undeclared params flow
128
+ # through to the query string / payload, since CRUD endpoints take
129
+ # free-form attributes. Declare contracts by hand instead when you want
130
+ # strict validation for a specific operation.
131
+ #
132
+ # @param plural [Symbol] collection name (also the default path segment)
133
+ # @param path [String] override the collection path (default "/#{plural}")
134
+ # @param only [Array<Symbol>] restrict to a subset of
135
+ # %i[index show create update destroy]
136
+ # @param except [Array<Symbol>] exclude some of the actions
137
+ # @param update_with [Symbol] verb for the update action (:patch or :put)
138
+ # @param singular [Symbol] override the auto-singularized member name
139
+ #
140
+ # @return [Hash] action name => generated contract name
141
+ def resource(plural, path: nil, only: nil, except: nil,
142
+ update_with: :patch, singular: nil)
143
+ plural = plural.to_sym
144
+ unless plural.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
145
+ raise InvalidDefinition,
146
+ "resource name #{plural.inspect} is not a valid identifier"
147
+ end
148
+ unless %i[patch put].include?(update_with.to_sym)
149
+ raise InvalidDefinition,
150
+ "update_with must be :patch or :put, got #{update_with.inspect}"
151
+ end
152
+ raise InvalidDefinition, 'resource accepts :only or :except, not both' if only && except
153
+
154
+ member = (singular || __singularize(plural)).to_sym
155
+ collection_path = path || "/#{plural}"
156
+ unless collection_path.start_with?('/')
157
+ raise InvalidDefinition,
158
+ "resource path #{collection_path.inspect} must start with \"/\""
159
+ end
160
+ member_path = "#{collection_path.sub(%r{/+\z}, '')}/:id"
161
+
162
+ actions = {
163
+ index: ["GET #{collection_path}", :"list_#{plural}"],
164
+ show: ["GET #{member_path}", :"get_#{member}"],
165
+ create: ["POST #{collection_path}", :"create_#{member}"],
166
+ update: ["#{update_with.to_s.upcase} #{member_path}", :"update_#{member}"],
167
+ destroy: ["DELETE #{member_path}", :"delete_#{member}"]
168
+ }
169
+ actions = __filter_resource_actions(actions, only, except)
170
+
171
+ actions.each_value do |request_line, name|
172
+ contract(name, request_line) { strict_params false }
173
+ end
174
+ actions.transform_values(&:last)
175
+ end
176
+
177
+ # Declare a contract. The request line is <tt>'VERB /path'</tt>; the
178
+ # optional block is instance_eval'd against the new {Definition}.
179
+ #
180
+ # @return [Definition] the registered definition
181
+ def contract(name, request_line, description: nil, &block)
182
+ name = name.to_sym
183
+ validate_contract_name!(name)
184
+
185
+ verb, path = parse_request_line(request_line)
186
+ definition = Definition.new(name, verb, path, description: description, &block)
187
+
188
+ __own_contract_config.contracts[name] = definition
189
+ __define_contract_methods(definition)
190
+ definition
191
+ end
192
+
193
+ # --- reflection / introspection ------------------------------------------
194
+
195
+ # All contracts visible from this class, merged down the ancestor chain
196
+ # (nearest definition wins). Returns a defensive copy.
197
+ def contracts
198
+ __merged_contracts.dup
199
+ end
200
+
201
+ # Names of all contracts visible from this class.
202
+ def contract_names
203
+ __merged_contracts.keys
204
+ end
205
+
206
+ # Does this class (or an ancestor) declare a contract named +name+?
207
+ def contract?(name)
208
+ __merged_contracts.key?(name.to_sym)
209
+ end
210
+
211
+ # The {Definition} for +name+, or nil when unknown.
212
+ def contract_for(name)
213
+ __merged_contracts[name.to_sym]
214
+ end
215
+
216
+ # --- machinery (used by Executor / introspection) -----------------------
217
+
218
+ # The config object owned by *this* class (created on demand).
219
+ def __own_contract_config
220
+ @__contract_config ||= Config.new
221
+ end
222
+
223
+ # Config objects along the ancestor chain, self first.
224
+ def __contract_config_chain
225
+ chain = []
226
+ klass = self
227
+ while klass.is_a?(Class)
228
+ if klass.instance_variable_defined?(:@__contract_config)
229
+ chain << klass.instance_variable_get(:@__contract_config)
230
+ end
231
+ klass = klass.superclass
232
+ end
233
+ chain
234
+ end
235
+
236
+ # Nearest non-nil scalar setting down the chain (base_url,
237
+ # strict_params, payload_style).
238
+ def __merged_scalar(setting)
239
+ __contract_config_chain.each do |config|
240
+ value = config.public_send(setting)
241
+ return value unless value.nil?
242
+ end
243
+ nil
244
+ end
245
+
246
+ # Contracts merged down the ancestor chain, root-most first (nearest
247
+ # definition wins on name collisions).
248
+ def __merged_contracts
249
+ merged = {}
250
+ __contract_config_chain.reverse_each do |config|
251
+ merged.update(config.contracts)
252
+ end
253
+ merged
254
+ end
255
+
256
+ # Callbacks of one kind, accumulated root-first.
257
+ def __merged_callbacks(kind)
258
+ __contract_config_chain.reverse_each.flat_map { |c| c.callbacks[kind] }
259
+ end
260
+
261
+ private
262
+
263
+ def parse_request_line(line)
264
+ match = line.to_s.match(
265
+ %r{\A\s*(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\s+(/\S*)\s*\z}i
266
+ )
267
+ unless match
268
+ raise InvalidDefinition,
269
+ "invalid request line #{line.inspect} — expected " \
270
+ "'VERB /path', e.g. 'GET /users' or 'POST /repos/:owner/:repo/issues'"
271
+ end
272
+ [match[1].downcase.to_sym, match[2]]
273
+ end
274
+
275
+ def validate_contract_name!(name)
276
+ unless name.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*[?!]?\z/)
277
+ raise InvalidDefinition,
278
+ "contract name #{name.inspect} is not a valid method name"
279
+ end
280
+ return unless RESERVED_NAMES.include?(name)
281
+
282
+ raise InvalidDefinition,
283
+ "contract name #{name.inspect} is reserved and cannot be used"
284
+
285
+ end
286
+
287
+ # Filter resource actions according to :only / :except (already
288
+ # validated: they are never both present).
289
+ def __filter_resource_actions(actions, only, except)
290
+ if only
291
+ wanted = Array(only).map(&:to_sym)
292
+ unknown = wanted - actions.keys
293
+ unless unknown.empty?
294
+ raise InvalidDefinition,
295
+ "unknown resource actions #{unknown.map(&:inspect).join(', ')} " \
296
+ "(known: #{actions.keys.map(&:inspect).join(', ')})"
297
+ end
298
+ actions.select { |action, _| wanted.include?(action) }
299
+ elsif except
300
+ unwanted = Array(except).map(&:to_sym)
301
+ unknown = unwanted - actions.keys
302
+ unless unknown.empty?
303
+ raise InvalidDefinition,
304
+ "unknown resource actions #{unknown.map(&:inspect).join(', ')} " \
305
+ "(known: #{actions.keys.map(&:inspect).join(', ')})"
306
+ end
307
+ actions.reject { |action, _| unwanted.include?(action) }
308
+ else
309
+ actions
310
+ end
311
+ end
312
+
313
+ IRREGULAR_SINGULARS = {
314
+ people: 'person', children: 'child', men: 'man', women: 'woman',
315
+ teeth: 'tooth', feet: 'foot', mice: 'mouse', geese: 'goose'
316
+ }.freeze
317
+
318
+ # Deliberately small singularization for the `resource` macro: covers
319
+ # regular English plurals and the most common irregulars. Override with
320
+ # the `singular:` option when the guess is wrong.
321
+ def __singularize(word)
322
+ w = word.to_s
323
+ return IRREGULAR_SINGULARS[w.to_sym] if IRREGULAR_SINGULARS.key?(w.to_sym)
324
+
325
+ if w.match?(/ies\z/)
326
+ w.sub(/ies\z/, 'y')
327
+ elsif w.match?(/(ss|x|z|ch|sh)es\z/) || w.match?(/ses\z/)
328
+ # both branches strip a trailing 'es' (order-preserving merge of
329
+ # previously duplicated branches)
330
+ w.sub(/es\z/, '')
331
+ elsif w.match?(/[^s]s\z/)
332
+ w.sub(/s\z/, '')
333
+ else
334
+ w
335
+ end
336
+ end
337
+
338
+ # Generate the client methods for a contract:
339
+ #
340
+ # name -> execute the contract
341
+ # name_url -> expand path params and return the full URL
342
+ def __define_contract_methods(definition)
343
+ name = definition.name
344
+
345
+ define_method(name) do |**params|
346
+ Contract::Executor.call(self, definition, params)
347
+ end
348
+
349
+ define_method(:"#{name}_url") do |**params|
350
+ Contract::Executor.build_url(self, definition, params)
351
+ end
352
+
353
+ return unless definition.pagination
354
+
355
+ # Metaprogrammed pagination: every paginated contract gets an
356
+ # enumerator of validated pages plus a flat collector, driven by the
357
+ # contract's own pagination declaration.
358
+ define_method(:"#{name}_pages") do |**params|
359
+ Contract::Pagination.each_page(self, definition, params)
360
+ end
361
+
362
+ define_method(:"all_#{name}") do |**params|
363
+ Contract::Pagination.all_items(self, definition, params)
364
+ end
365
+ end
366
+ end
367
+
368
+ # Instance methods mixed into contract client classes: convenience
369
+ # dispatch (`call_*`, `*_contract`, `has_*?`) on top of the generated
370
+ # methods.
371
+ module InstanceMethods
372
+ # Client-level overrides. They take precedence over the class-level
373
+ # configuration; if the class defines its own #initialize it wins (call
374
+ # <tt>super(base:, headers:)</tt> to keep both).
375
+ def initialize(base: nil, headers: nil)
376
+ @__base_override = base
377
+ @__header_overrides = headers || {}
378
+ end
379
+
380
+ # --- reflection / introspection ----------------------------------------
381
+
382
+ # @see ClassMethods#contracts
383
+ def contracts
384
+ self.class.contracts
385
+ end
386
+
387
+ # @see ClassMethods#contract_names
388
+ def contract_names
389
+ self.class.contract_names
390
+ end
391
+
392
+ # @see ClassMethods#contract?
393
+ def contract?(name)
394
+ self.class.contract?(name)
395
+ end
396
+
397
+ # @see ClassMethods#contract_for
398
+ def contract_for(name)
399
+ self.class.contract_for(name)
400
+ end
401
+
402
+ # Dynamic dispatch for contract conveniences. Real methods (including
403
+ # generated contract methods) always take precedence, so a contract
404
+ # literally named +call_ping+ is unaffected by the +call_*+ pattern.
405
+ #
406
+ # client.call_list_posts(**params) # alias of client.list_posts
407
+ # client.list_posts_contract # => the Definition
408
+ # client.has_list_posts? # => contract presence predicate
409
+ # (false when unknown, never raises)
410
+ def method_missing(name, *args, **kwargs, &block)
411
+ string = name.to_s
412
+
413
+ if (match = string.match(/\Acall_(.+)\z/)) &&
414
+ (definition = self.class.contract_for(match[1]))
415
+ # Generated contract methods only accept **kwargs; a positional
416
+ # arg in a call_* dispatch is almost always a typo (caller meant
417
+ # to pass keyword args but wrote `client.call_list_posts(1)`).
418
+ # The previous implementation silently dropped *args, so the
419
+ # request went out with empty params — a successful but wrong
420
+ # request. Raise so callers find out immediately.
421
+ unless args.empty?
422
+ raise ArgumentError,
423
+ "#{name} accepts keyword arguments only (pass them as " \
424
+ "#{name}(#{args.map.with_index { |_v, i| "arg#{i}" }.join(', ')}) " \
425
+ '=> use keywords instead)'
426
+ end
427
+
428
+ return Contract::Executor.call(self, definition, kwargs)
429
+ end
430
+
431
+ if (match = string.match(/\A(.+)_contract\z/)) &&
432
+ (definition = self.class.contract_for(match[1]))
433
+ return definition
434
+ end
435
+
436
+ if (match = string.match(/\Ahas_(.+)\?\z/))
437
+ # Predicate semantics: unknown contracts are simply absent.
438
+ return self.class.contract?(match[1])
439
+ end
440
+
441
+ super
442
+ end
443
+
444
+ def respond_to_missing?(name, include_private = false)
445
+ string = name.to_s
446
+
447
+ if (match = string.match(/\Acall_(.+)\z/)) && self.class.contract?(match[1])
448
+ return true
449
+ end
450
+ if (match = string.match(/\A(.+)_contract\z/)) && self.class.contract?(match[1])
451
+ return true
452
+ end
453
+ return true if string.match?(/\Ahas_.+\?\z/)
454
+
455
+ super
456
+ end
457
+
458
+ # Base URL for this instance (constructor override > class config).
459
+ def __contract_base
460
+ @__base_override || self.class.__merged_scalar(:base_url) ||
461
+ raise(MissingBaseUrl,
462
+ 'no base URL configured — use `base "https://api.example.com"` ' \
463
+ 'or pass base: to the constructor')
464
+ end
465
+
466
+ # Merge class default headers (root-first), instance header overrides
467
+ # and per-call _headers into one effective Hash.
468
+ def __contract_effective_headers(call_headers)
469
+ merged = self.class.default_headers
470
+ merged.update(@__header_overrides) if @__header_overrides
471
+ merged.update(call_headers) if call_headers
472
+ merged
473
+ end
474
+
475
+ # Run the accumulated before_request callbacks with the mutable
476
+ # execution context (root-most callbacks first).
477
+ def __run_before_callbacks(ctx)
478
+ self.class.__merged_callbacks(:before_request).each { |cb| cb.call(ctx) }
479
+ end
480
+
481
+ # Run the accumulated after_response callbacks with the response.
482
+ def __run_after_callbacks(response)
483
+ self.class.__merged_callbacks(:after_response).each { |cb| cb.call(response) }
484
+ end
485
+ end
486
+
487
+ RESERVED_NAMES = %i[
488
+ initialize inspect to_s class object_id method_missing respond_to_missing?
489
+ == equal? hash freeze frozen? dup clone send public_send
490
+ ].freeze
491
+
492
+ class << self
493
+ # Called when the DSL module is included in a client class: extend the
494
+ # class with the declaration macros and include the runtime methods.
495
+ def included(base)
496
+ base.extend(ClassMethods)
497
+ base.include(InstanceMethods)
498
+ end
499
+ end
500
+ end
501
+ end