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,432 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative '../contract'
|
|
6
|
+
|
|
7
|
+
module Contractful
|
|
8
|
+
module Contract
|
|
9
|
+
# Recorded-sequence test helper (REQ-N02) — the testing counterpart to
|
|
10
|
+
# the contract layer.
|
|
11
|
+
#
|
|
12
|
+
# Contracts deliberately do NOT express ordering, timing or cross-call
|
|
13
|
+
# state machines. This helper closes that gap: a sequence fixture (a
|
|
14
|
+
# YAML/JSON file under the consumer's control, or a Hash) drives a
|
|
15
|
+
# contract client through N recorded interactions with WebMock, and the
|
|
16
|
+
# run fails on the FIRST contract violation with the step number
|
|
17
|
+
# attached.
|
|
18
|
+
#
|
|
19
|
+
# require 'contractful/contract/sequence' # test-only, opt-in
|
|
20
|
+
#
|
|
21
|
+
# sequence = Contractful::Contract::Sequence.load(client, 'order_lifecycle.yml')
|
|
22
|
+
# sequence.after_step do |index, step, response|
|
|
23
|
+
# # stateful rules: token refresh, rate-limit windows, ...
|
|
24
|
+
# end
|
|
25
|
+
# sequence.run # => [#<Result step 1>, ...] or raises Sequence::Failure
|
|
26
|
+
#
|
|
27
|
+
# Fixture shape:
|
|
28
|
+
#
|
|
29
|
+
# name: order lifecycle
|
|
30
|
+
# steps:
|
|
31
|
+
# - call: create_order
|
|
32
|
+
# params: { quantity: 10 }
|
|
33
|
+
# response: { status: 201, body: { order_id: 'o-1' } }
|
|
34
|
+
# - call: get_order
|
|
35
|
+
# params: { order_id: 'o-1' }
|
|
36
|
+
# response: { status: 200, body: { order_id: 'o-1' } }
|
|
37
|
+
# expect_request: # optional outgoing assertions
|
|
38
|
+
# path: /orders/o-1
|
|
39
|
+
# query_include: { verbose: 'true' }
|
|
40
|
+
# headers_include: { X-Custom: v }
|
|
41
|
+
# json_include: { quantity: 10 }
|
|
42
|
+
#
|
|
43
|
+
# Each step stubs its recorded response (via WebMock), invokes the
|
|
44
|
+
# contract method, and lets the contract's own response validation run:
|
|
45
|
+
# a recorded response the contract does not declare is a violation at
|
|
46
|
+
# that step. Request assertions verify the OUTGOING side too, so a
|
|
47
|
+
# fixture can pin ordering-sensitive details (query params, headers,
|
|
48
|
+
# payload subsets).
|
|
49
|
+
module Sequence
|
|
50
|
+
# Raised when WebMock is missing or disabled.
|
|
51
|
+
class Error < Contractful::Contract::Error; end
|
|
52
|
+
|
|
53
|
+
# Raised at load time when a fixture violates the schema (unknown
|
|
54
|
+
# contract names, malformed steps, unknown assertion keys).
|
|
55
|
+
class InvalidFixture < Error; end
|
|
56
|
+
|
|
57
|
+
# Raised at run time on the first violated step. +step+ is the
|
|
58
|
+
# 1-based step number, +call+ the contract name, +cause+ the original
|
|
59
|
+
# exception (contract violation, HTTP error, failed assertion or hook
|
|
60
|
+
# failure).
|
|
61
|
+
class Failure < Error
|
|
62
|
+
attr_reader :step, :call, :cause
|
|
63
|
+
|
|
64
|
+
def initialize(step, call, cause = nil, message = nil)
|
|
65
|
+
@step = step
|
|
66
|
+
@call = call
|
|
67
|
+
@cause = cause
|
|
68
|
+
detail = message || cause&.message || cause&.class&.name
|
|
69
|
+
super("step #{step} (#{call}): #{detail}")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# One executed step.
|
|
74
|
+
Result = Struct.new(:step, :call, :params, :response, :request, keyword_init: true)
|
|
75
|
+
|
|
76
|
+
KNOWN_ASSERTIONS = %i[path query_include headers_include json_include].freeze
|
|
77
|
+
|
|
78
|
+
module_function
|
|
79
|
+
|
|
80
|
+
# Load a fixture (path or Hash) against a client and validate it.
|
|
81
|
+
#
|
|
82
|
+
# @param client a contract client instance (includes Contractful::Contract)
|
|
83
|
+
# @param source [String, Pathname, Hash] fixture path or parsed fixture
|
|
84
|
+
# @return [Player]
|
|
85
|
+
def load(client, source)
|
|
86
|
+
fixture = source.is_a?(Hash) ? deep_symbolize(source) : parse_fixture(source)
|
|
87
|
+
steps = validate_fixture!(fixture, client)
|
|
88
|
+
Player.new(client, fixture[:name], steps)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Convenience: load + run.
|
|
92
|
+
#
|
|
93
|
+
# @return [Array<Result>]
|
|
94
|
+
def run(client, source, &hook_block)
|
|
95
|
+
player = load(client, source)
|
|
96
|
+
player.after_step(&hook_block) if hook_block
|
|
97
|
+
player.run
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parse_fixture(source)
|
|
101
|
+
text = source.respond_to?(:read) ? source.read : File.read(source.to_s)
|
|
102
|
+
deep_symbolize(YAML.safe_load(text, aliases: true) ||
|
|
103
|
+
raise(InvalidFixture, 'empty fixture'))
|
|
104
|
+
rescue Psych::SyntaxError => e
|
|
105
|
+
raise InvalidFixture, "invalid YAML fixture: #{e.message}"
|
|
106
|
+
rescue Errno::ENOENT
|
|
107
|
+
raise InvalidFixture, "no such fixture file: #{source}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_fixture!(fixture, client)
|
|
111
|
+
unless fixture.is_a?(Hash) && fixture[:steps].is_a?(Array)
|
|
112
|
+
raise InvalidFixture,
|
|
113
|
+
"fixture must be a Hash with a 'steps' array, got #{fixture.class}"
|
|
114
|
+
end
|
|
115
|
+
raise InvalidFixture, 'fixture declares no steps' if fixture[:steps].empty?
|
|
116
|
+
|
|
117
|
+
fixture[:steps].each_with_index do |raw, index|
|
|
118
|
+
step = index + 1
|
|
119
|
+
raise InvalidFixture, "step #{step}: every step needs a 'call'" unless raw.is_a?(Hash) && raw[:call]
|
|
120
|
+
|
|
121
|
+
call = raw[:call].to_sym
|
|
122
|
+
unless client.class.contract?(call)
|
|
123
|
+
raise InvalidFixture,
|
|
124
|
+
"step #{step}: #{call.inspect} is not a contract on " \
|
|
125
|
+
"#{client.class}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
response = raw[:response]
|
|
129
|
+
unless response.is_a?(Hash)
|
|
130
|
+
raise InvalidFixture,
|
|
131
|
+
"step #{step} (#{call}): a 'response' Hash is required — " \
|
|
132
|
+
'record what the vendor actually returned'
|
|
133
|
+
end
|
|
134
|
+
status = response.fetch(:status, 200)
|
|
135
|
+
unless status.is_a?(Integer) && (100..599).cover?(status)
|
|
136
|
+
raise InvalidFixture,
|
|
137
|
+
"step #{step} (#{call}): response status must be an " \
|
|
138
|
+
"Integer in 100..599, got #{status.inspect}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
assert_fixture(response[:body], step, call, 'response body')
|
|
142
|
+
|
|
143
|
+
next unless (expected = raw[:expect_request])
|
|
144
|
+
unless expected.is_a?(Hash)
|
|
145
|
+
raise InvalidFixture,
|
|
146
|
+
"step #{step} (#{call}): expect_request must be a Hash"
|
|
147
|
+
end
|
|
148
|
+
unknown = expected.keys.map(&:to_sym) - KNOWN_ASSERTIONS
|
|
149
|
+
next if unknown.empty?
|
|
150
|
+
|
|
151
|
+
raise InvalidFixture,
|
|
152
|
+
"step #{step} (#{call}): unknown expect_request keys " \
|
|
153
|
+
"#{unknown.map(&:inspect).join(', ')} (known: " \
|
|
154
|
+
"#{KNOWN_ASSERTIONS.map(&:inspect).join(', ')})"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
fixture[:steps].map { |raw| normalize_step(raw) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def assert_fixture(body, step, call, label)
|
|
161
|
+
return if body.nil? || body.is_a?(String) || body.is_a?(Hash) || body.is_a?(Array)
|
|
162
|
+
|
|
163
|
+
raise InvalidFixture,
|
|
164
|
+
"step #{step} (#{call}): #{label} must be a String (raw) or " \
|
|
165
|
+
"Hash/Array (JSON), got #{body.class}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def normalize_step(raw)
|
|
169
|
+
{
|
|
170
|
+
call: raw[:call].to_sym,
|
|
171
|
+
params: deep_symbolize(raw[:params] || {}),
|
|
172
|
+
response: {
|
|
173
|
+
status: raw[:response].fetch(:status, 200),
|
|
174
|
+
headers: stringify_keys(raw[:response][:headers] || {}),
|
|
175
|
+
body: raw[:response][:body]
|
|
176
|
+
},
|
|
177
|
+
expect_request: raw[:expect_request] &&
|
|
178
|
+
deep_symbolize(raw[:expect_request])
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def deep_symbolize(value)
|
|
183
|
+
case value
|
|
184
|
+
when Hash
|
|
185
|
+
value.each_with_object({}) do |(k, v), h|
|
|
186
|
+
h[k.to_sym] = deep_symbolize(v)
|
|
187
|
+
end
|
|
188
|
+
when Array
|
|
189
|
+
value.map { |v| deep_symbolize(v) }
|
|
190
|
+
else
|
|
191
|
+
value
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def stringify_keys(hash)
|
|
196
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v.to_s }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# --- the player -------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
# Drives the client through the validated steps. Hooks registered with
|
|
202
|
+
# {#before_step} / {#after_step} carry the stateful rules that
|
|
203
|
+
# contracts cannot express (token refresh sequencing, rate-limit
|
|
204
|
+
# windows, cross-step invariants).
|
|
205
|
+
class Player
|
|
206
|
+
attr_reader :client, :name, :steps
|
|
207
|
+
|
|
208
|
+
def initialize(client, name, steps)
|
|
209
|
+
@client = client
|
|
210
|
+
@name = name
|
|
211
|
+
@steps = steps
|
|
212
|
+
@before_hooks = []
|
|
213
|
+
@after_hooks = []
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Register a hook run before each step: block.call(step_number, step).
|
|
217
|
+
def before_step(&block)
|
|
218
|
+
@before_hooks << block
|
|
219
|
+
self
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Register a hook run after each validated step:
|
|
223
|
+
# block.call(step_number, step, response).
|
|
224
|
+
def after_step(&block)
|
|
225
|
+
@after_hooks << block
|
|
226
|
+
self
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Execute all steps in order. Returns the Array of {Result}. Raises
|
|
230
|
+
# {Failure} on the first violated step; nothing after it runs.
|
|
231
|
+
def run
|
|
232
|
+
webmock!
|
|
233
|
+
results = []
|
|
234
|
+
@steps.each_with_index do |step, index|
|
|
235
|
+
number = index + 1
|
|
236
|
+
results << run_step(number, step)
|
|
237
|
+
end
|
|
238
|
+
results
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
private
|
|
242
|
+
|
|
243
|
+
def webmock!
|
|
244
|
+
unless defined?(WebMock)
|
|
245
|
+
raise Error,
|
|
246
|
+
'the sequence helper needs the webmock gem — add it to your ' \
|
|
247
|
+
"test group and require 'webmock/rspec' (or 'webmock')"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
enabled = false
|
|
251
|
+
WebMock::HttpLibAdapterRegistry.instance.each_adapter do |_name, adapter|
|
|
252
|
+
enabled = true if adapter.respond_to?(:enabled?) && adapter.enabled?
|
|
253
|
+
end
|
|
254
|
+
return if enabled
|
|
255
|
+
|
|
256
|
+
raise Error,
|
|
257
|
+
'WebMock is loaded but not enabled — call WebMock.enable! ' \
|
|
258
|
+
"(or require 'webmock/rspec') before running a sequence"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def run_step(number, step)
|
|
262
|
+
@before_hooks.each { |hook| safe(hook, number, step) { hook.call(number, step) } }
|
|
263
|
+
|
|
264
|
+
definition = client.class.contract_for(step[:call])
|
|
265
|
+
register_stub!(definition, step)
|
|
266
|
+
|
|
267
|
+
before_counts = signature_counts
|
|
268
|
+
response = begin
|
|
269
|
+
client.public_send(step[:call], **step[:params])
|
|
270
|
+
rescue StandardError => e
|
|
271
|
+
raise Failure.new(number, step[:call], e)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
request = verify_requests!(number, step, definition, before_counts)
|
|
275
|
+
verify_expectations!(number, step, request)
|
|
276
|
+
|
|
277
|
+
@after_hooks.each do |hook|
|
|
278
|
+
safe(hook, number, step) { hook.call(number, step, response) }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
Result.new(step: number, call: step[:call], params: step[:params],
|
|
282
|
+
response: response, request: request)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Hooks are stateful user rules; a raise inside one is a step
|
|
286
|
+
# failure, not a crash of the player.
|
|
287
|
+
def safe(_hook, number, step)
|
|
288
|
+
yield
|
|
289
|
+
rescue StandardError => e
|
|
290
|
+
raise Failure.new(number, step[:call], e, "hook failed: #{e.message}")
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Stub the recorded response. The URL matcher is the base + the path
|
|
294
|
+
# template with placeholders widened to [^/]+ — query-agnostic on
|
|
295
|
+
# purpose (defaults and coercion produce query strings that recorded
|
|
296
|
+
# fixtures did not pin down; use expect_request to pin them).
|
|
297
|
+
def register_stub!(definition, step)
|
|
298
|
+
base = client.__contract_base.to_s
|
|
299
|
+
path = Regexp.escape(definition.path_template)
|
|
300
|
+
.gsub(/:[a-zA-Z_][a-zA-Z0-9_]*/, '[^/]+')
|
|
301
|
+
pattern = Regexp.escape(base.sub(%r{/+\z}, '')) + path
|
|
302
|
+
response = step[:response]
|
|
303
|
+
headers = response[:headers].dup
|
|
304
|
+
body = response[:body]
|
|
305
|
+
if (body.is_a?(Hash) || body.is_a?(Array)) &&
|
|
306
|
+
!headers.keys.any? { |k| k.to_s.casecmp('content-type').zero? }
|
|
307
|
+
headers['Content-Type'] = 'application/json'
|
|
308
|
+
end
|
|
309
|
+
rendered = body.is_a?(Hash) || body.is_a?(Array) ? JSON.generate(body) : body
|
|
310
|
+
|
|
311
|
+
WebMock::API.stub_request(definition.verb, /\A#{pattern}(?:\?.*)?\z/)
|
|
312
|
+
.to_return(status: response[:status],
|
|
313
|
+
headers: headers,
|
|
314
|
+
body: rendered || '')
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Snapshot of issued requests: signature => times. WebMock counts
|
|
318
|
+
# identical signatures, preserving first-occurrence order, which is
|
|
319
|
+
# enough to attribute a request window to a step (retries of the
|
|
320
|
+
# same request collapse into one signature with a higher count).
|
|
321
|
+
def signature_counts
|
|
322
|
+
counts = {}
|
|
323
|
+
WebMock::RequestRegistry.instance.requested_signatures.each do |signature, times|
|
|
324
|
+
counts[signature] = times
|
|
325
|
+
end
|
|
326
|
+
counts
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def verify_requests!(number, step, definition, before_counts)
|
|
330
|
+
after_counts = signature_counts
|
|
331
|
+
new_in_window = after_counts.select do |signature, count|
|
|
332
|
+
count > before_counts.fetch(signature, 0)
|
|
333
|
+
end
|
|
334
|
+
if new_in_window.empty?
|
|
335
|
+
raise Failure.new(number, step[:call], nil,
|
|
336
|
+
'no HTTP request was made for this step')
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
signature = new_in_window.keys.first
|
|
340
|
+
expected_verb = definition.verb
|
|
341
|
+
return signature if signature.method.to_s.downcase == expected_verb.to_s
|
|
342
|
+
|
|
343
|
+
raise Failure.new(
|
|
344
|
+
number, step[:call], nil,
|
|
345
|
+
"expected a #{expected_verb.to_s.upcase} request but the client " \
|
|
346
|
+
"issued #{signature.method.to_s.upcase} to #{signature.uri}"
|
|
347
|
+
)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def verify_expectations!(number, step, signature)
|
|
351
|
+
expected = step[:expect_request]
|
|
352
|
+
return unless expected
|
|
353
|
+
|
|
354
|
+
if expected[:path]
|
|
355
|
+
actual = signature.uri.path
|
|
356
|
+
unless actual == expected[:path]
|
|
357
|
+
raise Failure.new(number, step[:call], nil,
|
|
358
|
+
"expected request path #{expected[:path].inspect}, " \
|
|
359
|
+
"got #{actual.inspect}")
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
if (wanted = expected[:query_include])
|
|
364
|
+
actual = {}
|
|
365
|
+
URI.decode_www_form(signature.uri.query.to_s).each do |key, value|
|
|
366
|
+
(actual[key] ||= []) << value
|
|
367
|
+
end
|
|
368
|
+
missing = subset_violations(wanted, actual)
|
|
369
|
+
unless missing.empty?
|
|
370
|
+
raise Failure.new(number, step[:call], nil,
|
|
371
|
+
"query params missing or wrong: #{missing.join('; ')} " \
|
|
372
|
+
"(actual query: #{signature.uri.query.inspect})")
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
if (wanted = expected[:headers_include])
|
|
377
|
+
actual = Sequence.stringify_keys(signature.headers || {})
|
|
378
|
+
missing = subset_violations(wanted, actual)
|
|
379
|
+
unless missing.empty?
|
|
380
|
+
raise Failure.new(number, step[:call], nil,
|
|
381
|
+
"request headers missing or wrong: #{missing.join('; ')}")
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
if (wanted = expected[:json_include])
|
|
386
|
+
actual = parse_request_body(number, step, signature)
|
|
387
|
+
missing = deep_subset_violations(wanted, actual)
|
|
388
|
+
unless missing.empty?
|
|
389
|
+
raise Failure.new(number, step[:call], nil,
|
|
390
|
+
"request payload missing or wrong: #{missing.join('; ')}")
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def subset_violations(wanted, actual)
|
|
396
|
+
wanted.filter_map do |key, value|
|
|
397
|
+
actual_value = actual[key.to_s]
|
|
398
|
+
"expected #{key}=#{value.inspect}" unless actual_value && Array(actual_value).include?(value.to_s)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def deep_subset_violations(wanted, actual, prefix = nil)
|
|
403
|
+
wanted.filter_map do |key, value|
|
|
404
|
+
dotted = prefix ? "#{prefix}.#{key}" : key.to_s
|
|
405
|
+
if value.is_a?(Hash)
|
|
406
|
+
next "expected #{dotted} inside a JSON object, got #{actual.class}" unless actual.is_a?(Hash)
|
|
407
|
+
|
|
408
|
+
nested = actual.key?(key.to_s) ? actual[key.to_s] : actual[key]
|
|
409
|
+
deep_subset_violations(value, nested, dotted)
|
|
410
|
+
elsif actual.is_a?(Hash) && (actual.key?(key.to_s) || actual.key?(key))
|
|
411
|
+
found = actual.key?(key.to_s) ? actual[key.to_s] : actual[key]
|
|
412
|
+
next unless found != value
|
|
413
|
+
|
|
414
|
+
"expected #{dotted}=#{value.inspect}, got #{found.inspect}"
|
|
415
|
+
else
|
|
416
|
+
"expected #{dotted} in the request payload"
|
|
417
|
+
end
|
|
418
|
+
end.flatten
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def parse_request_body(number, step, signature)
|
|
422
|
+
raw = signature.body.to_s
|
|
423
|
+
JSON.parse(raw)
|
|
424
|
+
rescue JSON::ParserError
|
|
425
|
+
raise Failure.new(number, step[:call], nil,
|
|
426
|
+
'json_include assertion requires a JSON request ' \
|
|
427
|
+
"payload, got #{raw.empty? ? 'an empty body' : raw[0, 60].inspect}")
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Contractful
|
|
4
|
+
module Contract
|
|
5
|
+
module Swagger2
|
|
6
|
+
# Servers, definitions and security. Swagger 2.0 keeps these at the top
|
|
7
|
+
# level; OpenAPI 3.0 keeps the reusable parts under components.
|
|
8
|
+
module Components
|
|
9
|
+
# Swagger 2.0 OAuth2 flow names, mapped to OpenAPI 3.0 flow names.
|
|
10
|
+
FLOWS = {
|
|
11
|
+
'implicit' => 'implicit', 'password' => 'password',
|
|
12
|
+
'application' => 'clientCredentials', 'accessCode' => 'authorizationCode'
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def servers(root)
|
|
18
|
+
url = root['host'] ? "#{scheme(root)}://#{root['host']}#{root['basePath']}" : root['basePath'].to_s
|
|
19
|
+
url.empty? ? [] : [{ 'url' => url }]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# https when offered, otherwise the first listed scheme.
|
|
23
|
+
def scheme(root)
|
|
24
|
+
schemes = Swagger2.list(root['schemes'])
|
|
25
|
+
schemes.include?('https') ? 'https' : (schemes.first || 'https')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def components(root)
|
|
29
|
+
out = {}
|
|
30
|
+
out['schemas'] = root['definitions'] if root['definitions'].is_a?(Hash)
|
|
31
|
+
definitions = root['securityDefinitions']
|
|
32
|
+
out['securitySchemes'] = security_schemes(definitions) if definitions.is_a?(Hash)
|
|
33
|
+
out.merge(global_components(root))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def global_components(root)
|
|
37
|
+
out = {}
|
|
38
|
+
out['parameters'] = global_parameters(root['parameters']) if root['parameters'].is_a?(Hash)
|
|
39
|
+
out['responses'] = global_responses(root) if root['responses'].is_a?(Hash)
|
|
40
|
+
out
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Body parameters have no OpenAPI 3.0 parameter form; they are left out.
|
|
44
|
+
def global_parameters(params)
|
|
45
|
+
params.each_with_object({}) do |(name, param), out|
|
|
46
|
+
out[name] = Parameters.convert(param) if param.is_a?(Hash) && param['in'] != 'body'
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def global_responses(root)
|
|
51
|
+
produces = Swagger2.list(root['produces'])
|
|
52
|
+
root['responses'].each_with_object({}) do |(name, response), out|
|
|
53
|
+
out[name] = Operations.convert_response(response, produces) if response.is_a?(Hash)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def security_schemes(definitions)
|
|
58
|
+
definitions.each_with_object({}) do |(name, definition), out|
|
|
59
|
+
scheme = definition.is_a?(Hash) ? security_scheme(definition) : nil
|
|
60
|
+
out[name] = scheme if scheme
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def security_scheme(definition)
|
|
65
|
+
case definition['type']
|
|
66
|
+
when 'basic' then { 'type' => 'http', 'scheme' => 'basic' }
|
|
67
|
+
when 'apiKey' then { 'type' => 'apiKey', 'name' => definition['name'], 'in' => definition['in'] }
|
|
68
|
+
when 'oauth2' then oauth_scheme(definition)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def oauth_scheme(definition)
|
|
73
|
+
flow = FLOWS[definition['flow'].to_s]
|
|
74
|
+
return nil unless flow
|
|
75
|
+
|
|
76
|
+
settings = { 'scopes' => definition['scopes'].is_a?(Hash) ? definition['scopes'] : {} }
|
|
77
|
+
settings['authorizationUrl'] = definition['authorizationUrl'] if definition['authorizationUrl']
|
|
78
|
+
settings['tokenUrl'] = definition['tokenUrl'] if definition['tokenUrl']
|
|
79
|
+
{ 'type' => 'oauth2', 'flows' => { flow => settings } }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Contractful
|
|
4
|
+
module Contract
|
|
5
|
+
module Swagger2
|
|
6
|
+
# Paths, operations and responses. Each Swagger 2.0 operation becomes an
|
|
7
|
+
# OpenAPI 3.0 operation with the same identity fields.
|
|
8
|
+
module Operations
|
|
9
|
+
HTTP_METHODS = %w[get put post patch delete head options].freeze
|
|
10
|
+
# Swagger 2.0 fields that have no operation-level OpenAPI 3.0 form.
|
|
11
|
+
DROPPED = %w[parameters consumes produces responses schemes].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def convert_paths(root)
|
|
16
|
+
paths = root['paths'].is_a?(Hash) ? root['paths'] : {}
|
|
17
|
+
paths.each_with_object({}) do |(path, item), out|
|
|
18
|
+
out[path] = convert_item(root, item) if item.is_a?(Hash)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def convert_item(root, item)
|
|
23
|
+
shared = Swagger2.list(item['parameters'])
|
|
24
|
+
item.each_with_object({}) do |(key, value), ops|
|
|
25
|
+
if HTTP_METHODS.include?(key.to_s)
|
|
26
|
+
ops[key] = convert_operation(root, value, shared)
|
|
27
|
+
elsif key.to_s.start_with?('x-')
|
|
28
|
+
ops[key] = value
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def convert_operation(root, operation, shared)
|
|
34
|
+
return {} unless operation.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
consumes = media_types(root, operation, 'consumes')
|
|
37
|
+
produces = media_types(root, operation, 'produces')
|
|
38
|
+
params = Parameters.merge(root, shared + Swagger2.list(operation['parameters']))
|
|
39
|
+
out = operation.except(*DROPPED).merge('parameters' => Parameters.locational(params))
|
|
40
|
+
body = Parameters.request_body(params, consumes)
|
|
41
|
+
out['requestBody'] = body if body
|
|
42
|
+
out.merge('responses' => convert_responses(operation['responses'], produces))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The operation's own media types, or the document-level ones.
|
|
46
|
+
def media_types(root, operation, key)
|
|
47
|
+
own = Swagger2.list(operation[key])
|
|
48
|
+
own.empty? ? Swagger2.list(root[key]) : own
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def convert_responses(responses, produces)
|
|
52
|
+
return {} unless responses.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
responses.each_with_object({}) do |(code, response), out|
|
|
55
|
+
next unless response.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
out[code.to_s] = response['$ref'] ? { '$ref' => response['$ref'] } : convert_response(response, produces)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def convert_response(response, produces)
|
|
62
|
+
out = { 'description' => response['description'].to_s }
|
|
63
|
+
if response['schema'].is_a?(Hash)
|
|
64
|
+
media = produces.first || 'application/json'
|
|
65
|
+
out['content'] = { media => { 'schema' => response_schema(response['schema']) } }
|
|
66
|
+
end
|
|
67
|
+
out['headers'] = convert_headers(response['headers']) if response['headers'].is_a?(Hash)
|
|
68
|
+
out
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def convert_headers(headers)
|
|
72
|
+
headers.transform_values { |header| { 'schema' => Parameters.simple_schema(header) } }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def response_schema(schema)
|
|
76
|
+
schema['type'] == 'file' ? { 'type' => 'string', 'format' => 'binary' } : schema
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Contractful
|
|
4
|
+
module Contract
|
|
5
|
+
module Swagger2
|
|
6
|
+
# Path, query and header parameters keep a schema, which Swagger 2.0 keeps
|
|
7
|
+
# inline. Body and formData parameters become one requestBody.
|
|
8
|
+
module Parameters
|
|
9
|
+
LOCATIONS = %w[path query header].freeze
|
|
10
|
+
SCHEMA_KEYS = %w[type format items enum default minimum maximum exclusiveMinimum exclusiveMaximum
|
|
11
|
+
minLength maxLength pattern minItems maxItems uniqueItems multipleOf].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Path-level and operation-level parameters. The operation wins on the
|
|
16
|
+
# same name and location, and parameter references resolve to their
|
|
17
|
+
# definition.
|
|
18
|
+
def merge(root, raw)
|
|
19
|
+
raw.each_with_object({}) do |node, merged|
|
|
20
|
+
param = resolve(root, node)
|
|
21
|
+
merged[[param['in'], param['name']]] = param if param.is_a?(Hash)
|
|
22
|
+
end.values
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def resolve(root, node)
|
|
26
|
+
match = %r{\A#/components/parameters/(.+)\z}.match(node.is_a?(Hash) ? node['$ref'].to_s : '')
|
|
27
|
+
return node unless match
|
|
28
|
+
|
|
29
|
+
global = root['parameters']
|
|
30
|
+
global.is_a?(Hash) ? global[match[1]] : nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def locational(params)
|
|
34
|
+
params.select { |param| LOCATIONS.include?(param['in']) }.map { |param| convert(param) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def convert(param)
|
|
38
|
+
out = { 'name' => param['name'], 'in' => param['in'] }
|
|
39
|
+
out['required'] = true if param['required'] == true || param['in'] == 'path'
|
|
40
|
+
out['description'] = param['description'] if param['description']
|
|
41
|
+
out['schema'] = simple_schema(param)
|
|
42
|
+
out
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A Swagger 2.0 non-body parameter carries its schema inline.
|
|
46
|
+
def simple_schema(node)
|
|
47
|
+
schema = node.slice(*SCHEMA_KEYS)
|
|
48
|
+
if schema['type'] == 'file'
|
|
49
|
+
schema['type'] = 'string'
|
|
50
|
+
schema['format'] = 'binary'
|
|
51
|
+
end
|
|
52
|
+
schema['items'] = simple_schema(schema['items']) if schema['items'].is_a?(Hash)
|
|
53
|
+
schema
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def request_body(params, consumes)
|
|
57
|
+
body = params.find { |param| param['in'] == 'body' }
|
|
58
|
+
return body_content(body, consumes) if body
|
|
59
|
+
|
|
60
|
+
form = params.select { |param| param['in'] == 'formData' }
|
|
61
|
+
form_content(form, consumes) unless form.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def body_content(body, consumes)
|
|
65
|
+
media = consumes.first || 'application/json'
|
|
66
|
+
{ 'required' => body['required'] == true,
|
|
67
|
+
'content' => { media => { 'schema' => body['schema'] || {} } } }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def form_content(form, consumes)
|
|
71
|
+
media = multipart?(form, consumes) ? 'multipart/form-data' : 'application/x-www-form-urlencoded'
|
|
72
|
+
{ 'content' => { media => { 'schema' => form_schema(form) } } }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A file field selects multipart; otherwise the body is urlencoded.
|
|
76
|
+
def multipart?(form, consumes)
|
|
77
|
+
consumes.include?('multipart/form-data') || form.any? { |param| param['type'] == 'file' }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def form_schema(form)
|
|
81
|
+
schema = { 'type' => 'object', 'properties' => form.to_h { |param| [param['name'], form_property(param)] } }
|
|
82
|
+
required = form.select { |param| param['required'] == true }.map { |param| param['name'] }
|
|
83
|
+
required.empty? ? schema : schema.merge('required' => required)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def form_property(param)
|
|
87
|
+
schema = simple_schema(param)
|
|
88
|
+
param['description'] ? schema.merge('description' => param['description']) : schema
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|