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,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
|