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