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