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.
- checksums.yaml +7 -0
- data/API.md +142 -0
- data/ARCHITECTURE.md +134 -0
- data/AUTHORS +106 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/LICENSE +21 -0
- data/README.md +1290 -0
- data/SECURITY.md +67 -0
- data/bin/contractful +128 -0
- data/history.md +801 -0
- data/lib/contractful/abstract_response.rb +301 -0
- data/lib/contractful/circuit_breaker.rb +221 -0
- data/lib/contractful/connection_pool.rb +166 -0
- data/lib/contractful/contract/auth.rb +257 -0
- data/lib/contractful/contract/config.rb +93 -0
- data/lib/contractful/contract/definition.rb +295 -0
- data/lib/contractful/contract/dsl.rb +501 -0
- data/lib/contractful/contract/executor.rb +492 -0
- data/lib/contractful/contract/openapi_import.rb +1135 -0
- data/lib/contractful/contract/pagination.rb +218 -0
- data/lib/contractful/contract/param.rb +505 -0
- data/lib/contractful/contract/postman_import/auth.rb +39 -0
- data/lib/contractful/contract/postman_import/body.rb +87 -0
- data/lib/contractful/contract/postman_import/operation.rb +107 -0
- data/lib/contractful/contract/postman_import/scope.rb +37 -0
- data/lib/contractful/contract/postman_import/url.rb +80 -0
- data/lib/contractful/contract/postman_import.rb +164 -0
- data/lib/contractful/contract/response_field.rb +167 -0
- data/lib/contractful/contract/sequence.rb +432 -0
- data/lib/contractful/contract/swagger2/components.rb +84 -0
- data/lib/contractful/contract/swagger2/operations.rb +81 -0
- data/lib/contractful/contract/swagger2/parameters.rb +93 -0
- data/lib/contractful/contract/swagger2/references.rb +55 -0
- data/lib/contractful/contract/swagger2.rb +55 -0
- data/lib/contractful/contract.rb +85 -0
- data/lib/contractful/exceptions.rb +328 -0
- data/lib/contractful/notifications/active_support.rb +40 -0
- data/lib/contractful/notifications.rb +138 -0
- data/lib/contractful/pacer.rb +161 -0
- data/lib/contractful/params_array.rb +85 -0
- data/lib/contractful/payload.rb +340 -0
- data/lib/contractful/platform.rb +52 -0
- data/lib/contractful/rate_limit.rb +133 -0
- data/lib/contractful/raw_response.rb +70 -0
- data/lib/contractful/request.rb +1483 -0
- data/lib/contractful/resource.rb +189 -0
- data/lib/contractful/response.rb +109 -0
- data/lib/contractful/utils.rb +202 -0
- data/lib/contractful/version.rb +11 -0
- data/lib/contractful/windows/root_certs.rb +107 -0
- data/lib/contractful/windows.rb +9 -0
- data/lib/contractful.rb +233 -0
- 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
|