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,505 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contractful
4
+ module Contract
5
+ # A single typed parameter declared inside a contract definition.
6
+ #
7
+ # Params carry the contract-first metadata: expected type, whether the
8
+ # value is required, a default value and an allowed set/range of values.
9
+ # When a contract is executed, every provided value flows through
10
+ # {Param#process!}, which coerces and validates it or raises
11
+ # {InvalidParam} with a precise message.
12
+ #
13
+ # Supported types:
14
+ #
15
+ # * +String+ (+:string+) — accepts String and Symbol
16
+ # * +Integer+ (+:integer+) — accepts Integer, integral Float, numeric String
17
+ # * +Float+ (+:float+) — accepts Integer, Float, numeric String
18
+ # * +:boolean+ — accepts true/false, "true"/"false" spellings, 1/0
19
+ # * +Array+ (+:array+) — accepts Array; other values are wrapped
20
+ # * +Hash+ (+:hash+) — accepts Hash only
21
+ # * any +Class+ — +is_a?+ check first, then +Class#contractful_coerce+,
22
+ # then a Kernel cast attempt (e.g. +Float+)
23
+ # * any +Proc+ — custom coercion; its return value is used
24
+ #
25
+ # With <tt>coerce: false</tt> the value must already match the declared
26
+ # type exactly (no conversion is attempted).
27
+ #
28
+ # Beyond type and inclusion, params can carry the closed-schema
29
+ # constraints known from JSON Schema / OpenAPI (these are what make a
30
+ # request schema "closed" — bad input is rejected before the network
31
+ # call is ever attempted):
32
+ #
33
+ # * +min:+ / +max:+ — inclusive numeric (or any Comparable) bounds,
34
+ # the counterpart of JSON Schema's
35
+ # +minimum+ / +maximum+
36
+ # * +length:+ — exact length (Integer) or a Range of valid
37
+ # lengths for String/Array/Hash values
38
+ # * +pattern:+ — a Regexp the String value must match
39
+ #
40
+ # Constraints are enforced after coercion, so
41
+ # <tt>param :quantity, Integer, min: 1</tt> rejects the string "0"
42
+ # exactly like the integer 0.
43
+ class Param
44
+ # Types that may be referenced with a symbol instead of a class.
45
+ SYMBOL_TYPES = {
46
+ string: String,
47
+ integer: Integer,
48
+ float: Float,
49
+ boolean: :boolean,
50
+ array: Array,
51
+ hash: Hash
52
+ }.freeze
53
+
54
+ BOOLEAN_TRUTHY = %w[true 1 on yes].freeze
55
+ BOOLEAN_FALSY = %w[false 0 off no].freeze
56
+
57
+ # Header and cookie names are tokens (RFC 9110 §5.6.2; RFC 6265 cookie-name).
58
+ HTTP_TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
59
+ # A header value may not hold control characters: CR or LF would split the
60
+ # header block.
61
+ HEADER_VALUE = /\A[^\x00-\x08\x0A-\x1F\x7F]*\z/
62
+ # RFC 6265 cookie-octet: printable ASCII without space, DQUOTE, comma,
63
+ # semicolon or backslash.
64
+ COOKIE_VALUE = /\A[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*\z/
65
+ # Scalar types a header or cookie value can carry.
66
+ TRANSPORT_TYPES = [String, Integer, Float, :boolean].freeze
67
+
68
+ attr_reader :name, :type, :inclusion, :description, :min, :max, :length, :pattern,
69
+ :header, :cookie
70
+
71
+ def initialize(name, type = nil, required: false, default: NO_DEFAULT,
72
+ inclusion: nil, coerce: true, description: nil,
73
+ min: nil, max: nil, length: nil, pattern: nil,
74
+ header: nil, cookie: nil)
75
+ @name = name.to_sym
76
+ @type = normalize_type(type)
77
+ @required = required ? true : false
78
+ @default = default
79
+ @inclusion = inclusion
80
+ @coerce = coerce ? true : false
81
+ @description = description
82
+ @min = min
83
+ @max = max
84
+ @length = length
85
+ @pattern = pattern
86
+ @header = header
87
+ @cookie = cookie
88
+ validate_definition!
89
+ validate_transport_definition!
90
+ end
91
+
92
+ # Where the value travels when it is not part of the path, query or
93
+ # payload: :header, :cookie or nil.
94
+ def location
95
+ return :header if @header
96
+ return :cookie if @cookie
97
+
98
+ nil
99
+ end
100
+
101
+ def required?
102
+ @required
103
+ end
104
+
105
+ def coerce?
106
+ @coerce
107
+ end
108
+
109
+ def has_default?
110
+ !@default.equal?(NO_DEFAULT)
111
+ end
112
+
113
+ # Whether the declared default is a Proc (resolved lazily at call time).
114
+ # Renderers need the raw declaration — {default_value} would resolve
115
+ # the proc and hide its laziness.
116
+ def lazy_default?
117
+ @default.is_a?(Proc)
118
+ end
119
+
120
+ # The declared default value. Procs are called so defaults can be
121
+ # computed lazily (for example <tt>default: -> { Time.now.to_i }</tt>).
122
+ def default_value
123
+ @default.is_a?(Proc) ? @default.call : @default
124
+ end
125
+
126
+ # Coerce and validate a value, returning the coerced result.
127
+ #
128
+ # @raise [InvalidParam] when the value cannot be accepted
129
+ def process!(value)
130
+ coerced = @coerce ? coerce_value(value) : strict_check(value)
131
+ validate_inclusion!(coerced)
132
+ validate_constraints!(coerced)
133
+ validate_transport!(coerced)
134
+ coerced
135
+ rescue InvalidParam => e
136
+ raise InvalidParam, "param :#{@name}: #{e.message}"
137
+ end
138
+
139
+ # Human-readable summary used in error messages and introspection.
140
+ def to_s
141
+ parts = [type_name]
142
+ parts << 'required' if @required
143
+ parts << "default=#{@default.inspect}" if has_default? && !@default.is_a?(Proc)
144
+ parts << "in #{inclusion.inspect}" if @inclusion
145
+ parts << ">= #{@min.inspect}" if @min
146
+ parts << "<= #{@max.inspect}" if @max
147
+ parts << "length #{@length.inspect}" if @length
148
+ parts << "matching #{@pattern.inspect}" if @pattern
149
+ parts.join(', ')
150
+ end
151
+
152
+ private
153
+
154
+ def normalize_type(type)
155
+ return nil if type.nil?
156
+
157
+ if type.is_a?(Symbol)
158
+ unless SYMBOL_TYPES.key?(type)
159
+ raise InvalidDefinition,
160
+ "unsupported param type #{type.inspect} for :#{@name} " \
161
+ "(supported symbols: #{SYMBOL_TYPES.keys.join(', ')})"
162
+ end
163
+ return SYMBOL_TYPES[type]
164
+ end
165
+
166
+ return :boolean if type.equal?(TrueClass) || type.equal?(FalseClass)
167
+
168
+ unless type.is_a?(Class) || type.is_a?(Proc)
169
+ raise InvalidDefinition,
170
+ "unsupported param type #{type.inspect} for :#{@name} " \
171
+ '(expected a Class, Proc or supported symbol)'
172
+ end
173
+
174
+ type
175
+ end
176
+
177
+ def validate_definition!
178
+ if @inclusion && !@inclusion.respond_to?(:include?)
179
+ raise InvalidDefinition,
180
+ "param :#{@name}: inclusion must respond to #include? " \
181
+ "(got #{@inclusion.inspect})"
182
+ end
183
+ if @min && !@min.respond_to?(:<=)
184
+ raise InvalidDefinition,
185
+ "param :#{@name}: min must be comparable, got #{@min.inspect}"
186
+ end
187
+ if @max && !@max.respond_to?(:>=)
188
+ raise InvalidDefinition,
189
+ "param :#{@name}: max must be comparable, got #{@max.inspect}"
190
+ end
191
+ if @length && !valid_length_constraint?(@length)
192
+ raise InvalidDefinition,
193
+ "param :#{@name}: length must be an Integer or an Integer " \
194
+ "Range, got #{@length.inspect}"
195
+ end
196
+ return unless @pattern && !@pattern.is_a?(Regexp)
197
+
198
+ raise InvalidDefinition,
199
+ "param :#{@name}: pattern must be a Regexp, got #{@pattern.inspect}"
200
+
201
+ end
202
+
203
+ def validate_transport_definition!
204
+ return unless location
205
+
206
+ raise InvalidDefinition, "param :#{@name}: use only one of header: or cookie:" if @header && @cookie
207
+ if location == :header && !(@header.is_a?(String) && HTTP_TOKEN.match?(@header))
208
+ raise InvalidDefinition,
209
+ "param :#{@name}: header name must be an HTTP token, got #{@header.inspect}"
210
+ end
211
+ if location == :cookie && !(@cookie.is_a?(String) && HTTP_TOKEN.match?(@cookie))
212
+ raise InvalidDefinition,
213
+ "param :#{@name}: cookie name must be an HTTP token, got #{@cookie.inspect}"
214
+ end
215
+ return if TRANSPORT_TYPES.include?(@type)
216
+
217
+ raise InvalidDefinition,
218
+ "param :#{@name}: header or cookie params must be scalar (String, Integer, Float or boolean)"
219
+ end
220
+
221
+ # A header or cookie value travels as text, so it must survive the wire.
222
+ def validate_transport!(value)
223
+ if location == :header && !HEADER_VALUE.match?(value.to_s)
224
+ raise InvalidParam, 'value cannot be sent as a header (it contains control characters)'
225
+ end
226
+ return unless location == :cookie && !COOKIE_VALUE.match?(value.to_s)
227
+
228
+ raise InvalidParam,
229
+ 'value cannot be sent as a cookie (it contains characters outside RFC 6265 cookie-octet)'
230
+ end
231
+
232
+ def valid_length_constraint?(constraint)
233
+ constraint.is_a?(Integer) ||
234
+ (constraint.is_a?(Range) && constraint.min.is_a?(Integer) &&
235
+ constraint.max.is_a?(Integer))
236
+ end
237
+
238
+ def type_name
239
+ case @type
240
+ when nil then 'untyped'
241
+ when :boolean then 'boolean'
242
+ when Proc then 'custom coercion'
243
+ when Class then @type.name || 'anonymous class'
244
+ else @type.to_s
245
+ end
246
+ end
247
+
248
+ def describe_value(value)
249
+ return 'nil' if value.nil?
250
+
251
+ inspected = value.inspect
252
+ inspected = inspected[0, 57] + '...' if inspected.length > 60
253
+ "#{value.class} (#{inspected})"
254
+ end
255
+
256
+ def validate_inclusion!(value)
257
+ return unless @inclusion
258
+
259
+ return if @inclusion.include?(value)
260
+
261
+ raise InvalidParam,
262
+ "value #{value.inspect} is not in #{@inclusion.inspect}"
263
+
264
+ end
265
+
266
+ # Closed-schema constraints, enforced after coercion: the contract
267
+ # rejects out-of-range, wrong-length and non-matching input before any
268
+ # HTTP request is built.
269
+ def validate_constraints!(value)
270
+ validate_bounds!(value)
271
+ validate_length!(value)
272
+ validate_pattern!(value)
273
+ end
274
+
275
+ def validate_bounds!(value)
276
+ if @min
277
+ unless value.respond_to?(:<)
278
+ raise InvalidParam,
279
+ "cannot enforce min #{@min.inspect} on #{value.class}"
280
+ end
281
+ begin
282
+ below = value < @min
283
+ rescue StandardError
284
+ raise InvalidParam,
285
+ "cannot compare #{value.inspect} with min #{@min.inspect}"
286
+ end
287
+ if below
288
+ raise InvalidParam,
289
+ "value #{value.inspect} is below minimum #{@min.inspect}"
290
+ end
291
+ end
292
+ return unless @max
293
+ unless value.respond_to?(:>)
294
+ raise InvalidParam,
295
+ "cannot enforce max #{@max.inspect} on #{value.class}"
296
+ end
297
+ begin
298
+ above = value > @max
299
+ rescue StandardError
300
+ raise InvalidParam,
301
+ "cannot compare #{value.inspect} with max #{@max.inspect}"
302
+ end
303
+ return unless above
304
+
305
+ raise InvalidParam,
306
+ "value #{value.inspect} is above maximum #{@max.inspect}"
307
+
308
+
309
+ end
310
+
311
+ def validate_length!(value)
312
+ return unless @length
313
+
314
+ unless value.respond_to?(:length)
315
+ raise InvalidParam,
316
+ "cannot enforce length on #{value.class} " \
317
+ '(does not respond to #length)'
318
+ end
319
+ actual = value.length
320
+ ok = @length.is_a?(Integer) ? actual == @length : @length.cover?(actual)
321
+ return if ok
322
+
323
+ raise InvalidParam,
324
+ "length #{actual} is outside #{@length.inspect}"
325
+
326
+ end
327
+
328
+ def validate_pattern!(value)
329
+ return unless @pattern
330
+
331
+ unless value.is_a?(String)
332
+ raise InvalidParam,
333
+ "pattern requires a String, got #{describe_value(value)}"
334
+ end
335
+ return if @pattern.match?(value)
336
+
337
+ raise InvalidParam,
338
+ "value #{value.inspect} does not match #{@pattern.inspect}"
339
+
340
+ end
341
+
342
+ # Coercion path: convert the value into the declared type where the
343
+ # conversion is unambiguous, otherwise reject it. Contract-first means
344
+ # we never silently mangle data: "abc" is not an Integer, and we say so.
345
+ #
346
+ # NOTE: comparisons use #equal? (identity) rather than case/===, because
347
+ # the type IS a class — `case @type when String` would match with
348
+ # String#=== semantics, which is not what we want here.
349
+ def coerce_value(value)
350
+ if @type.nil?
351
+ value
352
+ elsif @type.equal?(:boolean)
353
+ coerce_boolean(value)
354
+ elsif @type.equal?(String)
355
+ coerce_string(value)
356
+ elsif @type.equal?(Integer)
357
+ coerce_integer(value)
358
+ elsif @type.equal?(Float)
359
+ coerce_float(value)
360
+ elsif @type.equal?(Array)
361
+ value.is_a?(Array) ? value : [value]
362
+ elsif @type.equal?(Hash)
363
+ raise InvalidParam, "expected Hash, got #{describe_value(value)}" unless value.is_a?(Hash)
364
+
365
+ value
366
+ elsif @type.is_a?(Proc)
367
+ coerce_custom(value)
368
+ elsif @type.is_a?(Class)
369
+ coerce_class(value)
370
+ else
371
+ raise InvalidParam, "unsupported type #{type_name}"
372
+ end
373
+ end
374
+
375
+ def coerce_string(value)
376
+ case value
377
+ when String then value
378
+ when Symbol then value.to_s
379
+ else raise InvalidParam, "expected String, got #{describe_value(value)}"
380
+ end
381
+ end
382
+
383
+ def coerce_integer(value)
384
+ case value
385
+ when Integer then value
386
+ when Float
387
+ unless value.finite? && value == value.to_i
388
+ raise InvalidParam, "expected Integer, got #{describe_value(value)}"
389
+ end
390
+
391
+ value.to_i
392
+
393
+
394
+
395
+ when String
396
+ raise InvalidParam, "expected Integer, got #{describe_value(value)}" unless /\A[+-]?\d+\z/.match?(value)
397
+
398
+ # Base 10 explicitly: Kernel#Integer reads a leading zero as octal.
399
+ Integer(value, 10)
400
+
401
+
402
+
403
+ else
404
+ raise InvalidParam, "expected Integer, got #{describe_value(value)}"
405
+ end
406
+ end
407
+
408
+ def coerce_float(value)
409
+ case value
410
+ when Integer, Float then Float(value)
411
+ when String
412
+ begin
413
+ Float(value)
414
+ rescue ArgumentError
415
+ raise InvalidParam, "expected Float, got #{describe_value(value)}"
416
+ end
417
+ else
418
+ raise InvalidParam, "expected Float, got #{describe_value(value)}"
419
+ end
420
+ end
421
+
422
+ def coerce_boolean(value)
423
+ return value if value.equal?(true) || value.equal?(false)
424
+
425
+ if value.is_a?(String)
426
+ downcased = value.downcase
427
+ return true if BOOLEAN_TRUTHY.include?(downcased)
428
+ return false if BOOLEAN_FALSY.include?(downcased)
429
+ elsif value.is_a?(Integer) && [1, 0].include?(value)
430
+ return value == 1
431
+ end
432
+
433
+ raise InvalidParam, "expected boolean, got #{describe_value(value)}"
434
+ end
435
+
436
+ def coerce_custom(value)
437
+
438
+ @type.call(value)
439
+ rescue InvalidParam
440
+ raise
441
+ rescue StandardError => e
442
+ raise InvalidParam, "custom coercion failed: #{e.class}: #{e.message}"
443
+
444
+ end
445
+
446
+ def coerce_class(value)
447
+ return value if value.is_a?(@type)
448
+
449
+ if @type.respond_to?(:contractful_coerce)
450
+ begin
451
+ return @type.contractful_coerce(value)
452
+ rescue InvalidParam
453
+ raise
454
+ rescue StandardError => e
455
+ raise InvalidParam, "custom coercion failed: #{e.class}: #{e.message}"
456
+ end
457
+ end
458
+
459
+ # Last resort: a Kernel cast (e.g. Float("1.5")). The dispatch is
460
+ # deliberately bounded to Kernel's module functions — a class name is
461
+ # never used to call arbitrary methods on this object. Anonymous
462
+ # classes have no #name, so they skip the cast entirely and fall to
463
+ # the InvalidParam below.
464
+ cast_name = @type.name&.to_sym
465
+ if cast_name && Kernel.respond_to?(cast_name)
466
+ begin
467
+ cast = Kernel.public_send(cast_name, value)
468
+ return cast if cast.is_a?(@type)
469
+ rescue StandardError
470
+ nil
471
+ end
472
+ end
473
+
474
+ raise InvalidParam, "expected #{type_name}, got #{describe_value(value)}"
475
+ end
476
+
477
+ # Strict path (coerce: false): the value must already match exactly.
478
+ def strict_check(value)
479
+ if @type.nil?
480
+ value
481
+ elsif @type.equal?(:boolean)
482
+ unless value.equal?(true) || value.equal?(false)
483
+ raise InvalidParam, "expected boolean, got #{describe_value(value)}"
484
+ end
485
+
486
+ value
487
+
488
+
489
+
490
+ elsif @type.is_a?(Proc)
491
+ coerce_custom(value)
492
+ elsif @type.is_a?(Class)
493
+ raise InvalidParam, "expected #{type_name}, got #{describe_value(value)}" unless value.is_a?(@type)
494
+
495
+ value
496
+
497
+
498
+
499
+ else
500
+ raise InvalidParam, "unsupported strict type #{type_name}"
501
+ end
502
+ end
503
+ end
504
+ end
505
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contractful
4
+ module Contract
5
+ module PostmanImport
6
+ # Postman auth objects as commented-out auth hints, the way the OpenAPI import
7
+ # writes securitySchemes. Credentials are never invented: each hint reads its
8
+ # value from an environment variable named after the collection.
9
+ module Auth
10
+ module_function
11
+
12
+ def hints(auth, prefix)
13
+ return [] unless auth.is_a?(Hash)
14
+
15
+ case auth['type']
16
+ when 'bearer' then ["auth bearer: -> { ENV['#{prefix}_TOKEN'] } # Postman auth: bearer"]
17
+ when 'basic' then [basic_hint(prefix)]
18
+ when 'apikey' then apikey_hint(auth, prefix)
19
+ else []
20
+ end
21
+ end
22
+
23
+ def basic_hint(prefix)
24
+ "auth basic: -> { [ENV['#{prefix}_USER'], ENV['#{prefix}_PASSWORD']] } # Postman auth: basic"
25
+ end
26
+
27
+ def apikey_hint(auth, prefix)
28
+ settings = PostmanImport.list(auth['apikey']).grep(Hash)
29
+ .to_h { |entry| [entry['key'].to_s, entry['value'].to_s] }
30
+ return [] if settings['key'].to_s.empty?
31
+
32
+ location = settings['in'] == 'query' ? ', in: :query' : ''
33
+ ["auth api_key: { name: #{settings['key'].inspect}, value: -> { ENV['#{prefix}_API_KEY'] }#{location} } " \
34
+ '# Postman auth: apikey']
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Contractful
6
+ module Contract
7
+ module PostmanImport
8
+ # A Postman request body as contract params. Example values carry no schema,
9
+ # so body fields are untyped. A JSON example also turns off strict params for
10
+ # its operation, because the example may omit fields the API accepts.
11
+ module Body
12
+ module_function
13
+
14
+ def apply(definition, body, scope, label)
15
+ return unless body.is_a?(Hash)
16
+
17
+ case body['mode']
18
+ when 'raw' then raw(definition, body['raw'].to_s, scope, label)
19
+ when 'urlencoded' then form(definition, PostmanImport.enabled(body['urlencoded']), scope, label)
20
+ when 'formdata' then formdata(definition, PostmanImport.enabled(body['formdata']), scope, label)
21
+ when 'graphql' then graphql(definition, scope, label)
22
+ when nil, '', 'none' then nil
23
+ else scope.note("#{label}: body mode #{body['mode'].inspect} is not representable — skipped")
24
+ end
25
+ end
26
+
27
+ def raw(definition, text, scope, label)
28
+ return if text.strip.empty?
29
+
30
+ json = object_of(text)
31
+ return scope.note("#{label}: raw body is not a JSON object — declare params manually") unless json
32
+
33
+ definition.strict_params(false)
34
+ scope.inferred_body = true
35
+ declare(definition, json.keys, scope, label)
36
+ end
37
+
38
+ def object_of(text)
39
+ json = JSON.parse(text)
40
+ json.is_a?(Hash) ? json : nil
41
+ rescue JSON::ParserError
42
+ nil
43
+ end
44
+
45
+ def form(definition, entries, scope, label)
46
+ return if entries.empty?
47
+
48
+ definition.payload_style(:form)
49
+ declare(definition, entries.map { |entry| entry['key'] }, scope, label)
50
+ end
51
+
52
+ def formdata(definition, entries, scope, label)
53
+ if entries.any? { |entry| entry['type'] == 'file' }
54
+ return scope.note("#{label}: multipart body with file fields is not representable — skipped")
55
+ end
56
+
57
+ form(definition, entries, scope, label)
58
+ end
59
+
60
+ # The GraphQL body is a JSON object: {"query": ..., "variables": ...}.
61
+ def graphql(definition, scope, label)
62
+ if definition.params.key?(:query)
63
+ return scope.note("#{label}: a param named query already exists — GraphQL body skipped")
64
+ end
65
+
66
+ definition.param(:query, String, required: true)
67
+ definition.param(:variables, Hash)
68
+ end
69
+
70
+ def declare(definition, names, scope, label)
71
+ names.each { |name| declare_field(definition, name.to_s, scope, label) }
72
+ end
73
+
74
+ def declare_field(definition, name, scope, label)
75
+ unless name.match?(PostmanImport::IDENTIFIER)
76
+ return scope.note("#{label}: body field #{name.inspect} is not a valid identifier — skipped")
77
+ end
78
+ if definition.params.key?(name.to_sym)
79
+ return scope.note("#{label}: body field #{name.inspect} collides with another param — skipped")
80
+ end
81
+
82
+ definition.param(name.to_sym)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end