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,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Contractful
|
|
4
|
+
module Contract
|
|
5
|
+
# Authentication schemes declared on a contract-first client.
|
|
6
|
+
#
|
|
7
|
+
# The contract is the source of truth for HOW to authenticate; the
|
|
8
|
+
# client supplies only the credentials. Values may be literals or
|
|
9
|
+
# callables — callables are invoked on every request so short-lived
|
|
10
|
+
# tokens stay fresh:
|
|
11
|
+
#
|
|
12
|
+
# auth bearer: -> { fetch_token } # Authorization: Bearer ...
|
|
13
|
+
# auth api_key: { name: 'access-token', value: token } # custom header
|
|
14
|
+
# auth api_key: [ # multiple keys at once
|
|
15
|
+
# { name: 'access-token', value: -> { token } },
|
|
16
|
+
# { name: 'client-id', value: client_id },
|
|
17
|
+
# ]
|
|
18
|
+
# auth basic: -> { [user, pass] } # Authorization: Basic ...
|
|
19
|
+
#
|
|
20
|
+
# api_key schemes can also live in the query string with
|
|
21
|
+
# <tt>in: :query</tt>.
|
|
22
|
+
#
|
|
23
|
+
# Auth is resolved right before the before_request callbacks run, so
|
|
24
|
+
# callbacks can still inspect or override the resolved credentials.
|
|
25
|
+
class Auth # rubocop:disable Metrics/ClassLength
|
|
26
|
+
VALID_LOCATIONS = %i[header query].freeze
|
|
27
|
+
|
|
28
|
+
attr_reader :schemes
|
|
29
|
+
|
|
30
|
+
def initialize(**schemes)
|
|
31
|
+
if schemes.empty?
|
|
32
|
+
raise InvalidDefinition,
|
|
33
|
+
'auth requires at least one scheme (bearer:, api_key:, basic: or token_provider:)'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@schemes = []
|
|
37
|
+
schemes.each do |name, value|
|
|
38
|
+
case name
|
|
39
|
+
when :bearer then add_bearer(value)
|
|
40
|
+
when :api_key then add_api_keys(value)
|
|
41
|
+
when :basic then add_basic(value)
|
|
42
|
+
when :token_provider then add_token_provider(value)
|
|
43
|
+
else
|
|
44
|
+
raise InvalidDefinition,
|
|
45
|
+
"unknown auth scheme #{name.inspect} " \
|
|
46
|
+
'(supported: :bearer, :api_key, :basic, :token_provider)'
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
freeze_scheme_values!
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Resolve the schemes against the current moment: callable values are
|
|
53
|
+
# invoked now, so tokens fetched per request are always fresh.
|
|
54
|
+
#
|
|
55
|
+
# @return [Hash] +{ headers: Hash, query: Hash }+ to merge into the
|
|
56
|
+
# outgoing request
|
|
57
|
+
def resolve
|
|
58
|
+
headers = {}
|
|
59
|
+
query = {}
|
|
60
|
+
@schemes.each do |scheme|
|
|
61
|
+
case scheme[:scheme]
|
|
62
|
+
when :token_provider
|
|
63
|
+
token = scheme[:value].token
|
|
64
|
+
validate_resolved_credential!(token, :token_provider)
|
|
65
|
+
headers['Authorization'] = "Bearer #{token}"
|
|
66
|
+
when :bearer
|
|
67
|
+
token = credential(scheme[:value])
|
|
68
|
+
validate_resolved_credential!(token, :bearer)
|
|
69
|
+
headers['Authorization'] = "Bearer #{token}"
|
|
70
|
+
when :basic
|
|
71
|
+
user, pass = credential(scheme[:value])
|
|
72
|
+
# basic auth allows a blank password (RFC 7617), but a nil
|
|
73
|
+
# user or nil pass means the callable did not return a
|
|
74
|
+
# usable pair — refuse to send the request rather than
|
|
75
|
+
# emit a header the server will reject.
|
|
76
|
+
validate_resolved_credential!(user, :basic_user)
|
|
77
|
+
pass = pass.to_s
|
|
78
|
+
token = ["#{user}:#{pass}"].pack('m0')
|
|
79
|
+
headers['Authorization'] = "Basic #{token}"
|
|
80
|
+
when :api_key
|
|
81
|
+
value = credential(scheme[:value])
|
|
82
|
+
validate_resolved_credential!(value, :api_key)
|
|
83
|
+
if scheme[:location] == :query
|
|
84
|
+
query[scheme[:name].to_s] = value
|
|
85
|
+
else
|
|
86
|
+
headers[scheme[:name].to_s] = value
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
{ headers: headers, query: query }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Reject nil resolved credentials before they hit the wire.
|
|
94
|
+
# Without this guard, an unset ENV['API_TOKEN'] would let the
|
|
95
|
+
# request go out as `Authorization: Bearer ` (empty token) and a
|
|
96
|
+
# misconfigured basic callable returning `[nil, nil]` would emit
|
|
97
|
+
# `Basic Og==` (the base64 of `:`). Both look like valid auth to
|
|
98
|
+
# the server, which then 401s in a confusing way that hides the
|
|
99
|
+
# real cause (a missing environment variable or a typo'd callable).
|
|
100
|
+
def validate_resolved_credential!(value, kind)
|
|
101
|
+
return unless value.nil?
|
|
102
|
+
return if kind == :basic_user # allow empty user/pass on basic
|
|
103
|
+
|
|
104
|
+
raise Contractful::Contract::InvalidDefinition,
|
|
105
|
+
"resolved #{kind} credential is nil — check the " \
|
|
106
|
+
'callable or ENV var referenced by the auth scheme'
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The token provider object when the contract authenticates with
|
|
110
|
+
# <tt>auth token_provider:</tt>, otherwise nil. The executor uses this
|
|
111
|
+
# to drive the refresh-on-401 flow.
|
|
112
|
+
def token_provider
|
|
113
|
+
scheme = @schemes.find { |s| s[:scheme] == :token_provider }
|
|
114
|
+
scheme && scheme[:value]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def to_s
|
|
118
|
+
@schemes.map { |s| s[:scheme] }.uniq.join('+')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def inspect
|
|
122
|
+
"#<Contractful::Contract::Auth #{self}>"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def add_bearer(value)
|
|
128
|
+
validate_credential!(value, :bearer)
|
|
129
|
+
@schemes << { scheme: :bearer, value: value }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A token provider is an object, not a callable: it must answer
|
|
133
|
+
# #token (the current credential) and #refresh (force a new token).
|
|
134
|
+
# The executor refreshes it in one flight when a 401 arrives.
|
|
135
|
+
def add_token_provider(value)
|
|
136
|
+
unless value.respond_to?(:token) && value.respond_to?(:refresh)
|
|
137
|
+
raise InvalidDefinition,
|
|
138
|
+
'auth token_provider: expects an object responding to ' \
|
|
139
|
+
'#token and #refresh, got ' + value.inspect
|
|
140
|
+
end
|
|
141
|
+
@schemes << { scheme: :token_provider, value: value }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def add_api_keys(value)
|
|
145
|
+
# Array() would explode a Hash into key/value pairs — normalize here.
|
|
146
|
+
entries = value.is_a?(Array) ? value : [value]
|
|
147
|
+
entries.each do |entry|
|
|
148
|
+
unless entry.is_a?(Hash)
|
|
149
|
+
raise InvalidDefinition,
|
|
150
|
+
'auth api_key: expects a Hash {name:, value:, in:} or an ' \
|
|
151
|
+
"Array of them, got #{entry.inspect}"
|
|
152
|
+
end
|
|
153
|
+
name = entry[:name] || entry['name']
|
|
154
|
+
unless name.is_a?(String) || name.is_a?(Symbol)
|
|
155
|
+
raise InvalidDefinition,
|
|
156
|
+
"auth api_key: requires a name, got #{name.inspect}"
|
|
157
|
+
end
|
|
158
|
+
credential_value = entry[:value] || entry['value']
|
|
159
|
+
validate_credential!(credential_value, :api_key)
|
|
160
|
+
location = (entry[:in] || entry['in'] || :header).to_sym
|
|
161
|
+
unless VALID_LOCATIONS.include?(location)
|
|
162
|
+
raise InvalidDefinition,
|
|
163
|
+
"auth api_key: in: must be :header or :query, got #{location.inspect}"
|
|
164
|
+
end
|
|
165
|
+
@schemes << { scheme: :api_key, name: name,
|
|
166
|
+
value: credential_value, location: location }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def add_basic(value)
|
|
171
|
+
validate_credential!(value, :basic)
|
|
172
|
+
@schemes << { scheme: :basic, value: value }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def validate_credential!(value, scheme_name)
|
|
176
|
+
ok = value.is_a?(String) || value.is_a?(Symbol) ||
|
|
177
|
+
(value.is_a?(Array) && scheme_name == :basic) ||
|
|
178
|
+
value.respond_to?(:call)
|
|
179
|
+
return if ok
|
|
180
|
+
|
|
181
|
+
raise InvalidDefinition,
|
|
182
|
+
"auth #{scheme_name}: value must be a literal or a callable, " \
|
|
183
|
+
"got #{value.inspect}"
|
|
184
|
+
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def credential(value)
|
|
188
|
+
value.respond_to?(:call) ? value.call : value
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Basic-auth pairs are resolved lazily; everything else is frozen so a
|
|
192
|
+
# misconfigured literal fails fast.
|
|
193
|
+
def freeze_scheme_values!
|
|
194
|
+
@schemes.each do |scheme|
|
|
195
|
+
scheme[:value].freeze if scheme[:scheme] != :basic && scheme[:value].is_a?(String)
|
|
196
|
+
end
|
|
197
|
+
@schemes.freeze
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# One-flight refresh coordination for token providers: concurrent 401s
|
|
202
|
+
# for the same provider never run #refresh concurrently — the first
|
|
203
|
+
# caller holds the per-provider mutex while refreshing, later callers
|
|
204
|
+
# block and then re-resolve the token (their #token call sees the
|
|
205
|
+
# new credential). Refreshes post a `token_refresh` notification.
|
|
206
|
+
#
|
|
207
|
+
# The previous implementation did NOT deduplicate: every caller that
|
|
208
|
+
# acquired the per-provider mutex ALSO called `provider.refresh`, so
|
|
209
|
+
# N concurrent 401s produced N sequential refresh calls (defeating
|
|
210
|
+
# the "one-flight" goal). Track a `@last_refreshed_at[provider]`
|
|
211
|
+
# timestamp inside the per-provider lock; later callers that arrive
|
|
212
|
+
# within the deduplication window skip the refresh call and just
|
|
213
|
+
# re-resolve via `provider.token`.
|
|
214
|
+
module TokenRefresh
|
|
215
|
+
@registry = {}
|
|
216
|
+
@last_refreshed_at = {}
|
|
217
|
+
@mutex = Mutex.new
|
|
218
|
+
DEDUP_WINDOW_SECONDS = 5
|
|
219
|
+
|
|
220
|
+
class << self
|
|
221
|
+
def refresh(provider)
|
|
222
|
+
lock = lock_for(provider)
|
|
223
|
+
lock.synchronize do
|
|
224
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
225
|
+
last = @last_refreshed_at[provider]
|
|
226
|
+
if last && (now - last) < DEDUP_WINDOW_SECONDS
|
|
227
|
+
# A refresh completed within the dedup window — skip the
|
|
228
|
+
# refresh call and re-resolve the token. The provider's
|
|
229
|
+
# implementation is responsible for returning a fresh
|
|
230
|
+
# token via #token.
|
|
231
|
+
token = provider.token
|
|
232
|
+
else
|
|
233
|
+
token = provider.refresh
|
|
234
|
+
@last_refreshed_at[provider] = now
|
|
235
|
+
Notifications.post('token_refresh',
|
|
236
|
+
provider: provider.class.name)
|
|
237
|
+
end
|
|
238
|
+
token
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def clear!
|
|
243
|
+
@mutex.synchronize do
|
|
244
|
+
@registry.clear
|
|
245
|
+
@last_refreshed_at.clear
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def lock_for(provider)
|
|
252
|
+
@mutex.synchronize { @registry[provider] ||= Mutex.new }
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Contractful
|
|
4
|
+
module Contract
|
|
5
|
+
# Class-level configuration for contract-first clients.
|
|
6
|
+
#
|
|
7
|
+
# An instance lives on each class that includes {Contractful::Contract}
|
|
8
|
+
# (as <tt>@__contract_config</tt>) and is read by walking the superclass
|
|
9
|
+
# chain: scalar settings use nearest-definition-wins, while callbacks and
|
|
10
|
+
# contracts accumulate down the chain (see {DSL::ClassMethods}).
|
|
11
|
+
class Config
|
|
12
|
+
# Contract name (Symbol) => Definition
|
|
13
|
+
attr_reader :contracts
|
|
14
|
+
|
|
15
|
+
# { before_request: [...], after_response: [...] }
|
|
16
|
+
attr_reader :callbacks
|
|
17
|
+
|
|
18
|
+
# Scalars are nil until explicitly set, so that "unset" can be
|
|
19
|
+
# distinguished from every meaningful value (including false/{}).
|
|
20
|
+
attr_accessor :base_url
|
|
21
|
+
attr_reader :default_headers, :strict_params, :payload_style, :auth
|
|
22
|
+
attr_reader :idempotency_header, :idempotency_generator
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@contracts = {}
|
|
26
|
+
@callbacks = { before_request: [], after_response: [] }
|
|
27
|
+
@base_url = nil
|
|
28
|
+
@default_headers = nil
|
|
29
|
+
@strict_params = nil
|
|
30
|
+
@payload_style = nil
|
|
31
|
+
@auth = nil
|
|
32
|
+
@idempotency_header = nil
|
|
33
|
+
@idempotency_generator = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def default_headers=(headers)
|
|
38
|
+
unless headers.is_a?(Hash)
|
|
39
|
+
raise InvalidDefinition,
|
|
40
|
+
"default_headers must be a Hash, got #{headers.inspect}"
|
|
41
|
+
end
|
|
42
|
+
@default_headers = headers
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def strict_params=(flag)
|
|
46
|
+
@strict_params = flag ? true : false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def payload_style=(style)
|
|
50
|
+
unless %i[json form query].include?(style.to_sym)
|
|
51
|
+
raise InvalidDefinition,
|
|
52
|
+
"invalid payload_style #{style.inspect} " \
|
|
53
|
+
'(expected :json, :form or :query)'
|
|
54
|
+
end
|
|
55
|
+
@payload_style = style.to_sym
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def auth=(value)
|
|
59
|
+
unless value.nil? || value.is_a?(Auth) || value.equal?(false)
|
|
60
|
+
raise InvalidDefinition,
|
|
61
|
+
'auth must be a Contractful::Contract::Auth or false, ' \
|
|
62
|
+
"got #{value.inspect}"
|
|
63
|
+
end
|
|
64
|
+
@auth = value
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def idempotency_header=(name)
|
|
68
|
+
unless name.is_a?(String) && !name.empty?
|
|
69
|
+
raise InvalidDefinition,
|
|
70
|
+
'idempotency_header must be a non-empty String, ' \
|
|
71
|
+
"got #{name.inspect}"
|
|
72
|
+
end
|
|
73
|
+
@idempotency_header = name
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def idempotency_generator=(generator)
|
|
77
|
+
unless generator.respond_to?(:call)
|
|
78
|
+
raise InvalidDefinition,
|
|
79
|
+
'idempotency_generator must respond to #call, ' \
|
|
80
|
+
"got #{generator.inspect}"
|
|
81
|
+
end
|
|
82
|
+
@idempotency_generator = generator
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def add_callback(kind, block)
|
|
86
|
+
raise InvalidDefinition, "unknown callback kind #{kind.inspect}" unless @callbacks.key?(kind)
|
|
87
|
+
raise InvalidDefinition, "callback #{kind} requires a block" unless block.is_a?(Proc)
|
|
88
|
+
|
|
89
|
+
@callbacks[kind] << block
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Contractful
|
|
4
|
+
module Contract
|
|
5
|
+
# The definition of a single API operation — one "contract".
|
|
6
|
+
#
|
|
7
|
+
# A Definition is built from a request line (<tt>'POST /repos/:owner/:repo/issues'</tt>)
|
|
8
|
+
# plus an optional DSL block that is +instance_eval+'d against the
|
|
9
|
+
# definition object itself, so every statement in the block is a method
|
|
10
|
+
# call on the definition:
|
|
11
|
+
#
|
|
12
|
+
# contract :create_issue, 'POST /repos/:owner/:repo/issues' do
|
|
13
|
+
# param :owner, String, required: true
|
|
14
|
+
# param :title, String, required: true
|
|
15
|
+
# param :labels, Array, default: []
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Path placeholders (+:owner+, +:repo+) are automatically synthesized as
|
|
19
|
+
# required params unless they were explicitly declared.
|
|
20
|
+
class Definition
|
|
21
|
+
# A placeholder is +:name+. A colon written directly after a placeholder
|
|
22
|
+
# starts a literal suffix instead (group 2), so custom verbs such as
|
|
23
|
+
# '/things/:id:archive' (the Google-style '{id}:archive') keep their colon.
|
|
24
|
+
PATH_PLACEHOLDER = /:([a-zA-Z_][a-zA-Z0-9_]*)((?::[a-zA-Z_][a-zA-Z0-9_]*)?)/
|
|
25
|
+
|
|
26
|
+
VALID_VERBS = %i[get head post put patch delete options].freeze
|
|
27
|
+
|
|
28
|
+
# Symbol aliases for response body types. +:json+ means "must parse as
|
|
29
|
+
# JSON" without constraining the shape; +Array+/+Hash+ also parse and
|
|
30
|
+
# additionally check the parsed shape.
|
|
31
|
+
RESPONSE_TYPES = {
|
|
32
|
+
array: Array,
|
|
33
|
+
hash: Hash,
|
|
34
|
+
string: String,
|
|
35
|
+
json: :json
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
attr_reader :name, :verb, :path_template, :params, :description, :response_statuses, :response_type, :response_verifier, :response_fields
|
|
39
|
+
# Per-contract strict-params override (nil = inherit the class setting).
|
|
40
|
+
attr_reader :strict
|
|
41
|
+
# Contract-level auth opt-out (nil = inherit, false = disabled).
|
|
42
|
+
attr_reader :auth
|
|
43
|
+
# Idempotent flag (nil = not flagged).
|
|
44
|
+
attr_reader :idempotent
|
|
45
|
+
# Pagination declaration (nil = not paginated), see {Pagination}.
|
|
46
|
+
attr_reader :pagination
|
|
47
|
+
|
|
48
|
+
def initialize(name, verb, path_template, description: nil, &block)
|
|
49
|
+
@name = name.to_sym
|
|
50
|
+
@verb = verb.to_s.downcase.to_sym
|
|
51
|
+
@path_template = path_template.dup
|
|
52
|
+
@description = description
|
|
53
|
+
@params = {}
|
|
54
|
+
@payload_style = nil
|
|
55
|
+
@strict = nil
|
|
56
|
+
@response_fields = {}
|
|
57
|
+
@auth = nil
|
|
58
|
+
@idempotent = nil
|
|
59
|
+
@pagination = nil
|
|
60
|
+
|
|
61
|
+
validate_verb!
|
|
62
|
+
validate_path_template!
|
|
63
|
+
synthesize_path_params!
|
|
64
|
+
|
|
65
|
+
# The DSL block is instance_eval'd against the definition, so every
|
|
66
|
+
# statement inside it is a method call on this object. It runs after
|
|
67
|
+
# path-param synthesis so explicit declarations win.
|
|
68
|
+
instance_eval(&block) if block
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# --- DSL surface (evaluated inside the contract block) -----------------
|
|
72
|
+
|
|
73
|
+
# Declare a parameter. See {Param} for the supported options.
|
|
74
|
+
def param(name, type = nil, **options)
|
|
75
|
+
key = name.to_sym
|
|
76
|
+
param = Param.new(key, type, **options)
|
|
77
|
+
if param.location && path_param_names.include?(key)
|
|
78
|
+
raise InvalidDefinition,
|
|
79
|
+
"path parameter :#{key} cannot also be a #{param.location} param for :#{@name}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
@params[key] = param
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Declare the payload style for non-query params:
|
|
86
|
+
# +:json+ (default for POST/PUT/PATCH), +:form+ or +:query+.
|
|
87
|
+
def payload_style(style = nil)
|
|
88
|
+
return @payload_style if style.nil?
|
|
89
|
+
|
|
90
|
+
unless %i[json form query].include?(style.to_sym)
|
|
91
|
+
raise InvalidDefinition,
|
|
92
|
+
"invalid payload_style #{style.inspect} for :#{@name} " \
|
|
93
|
+
'(expected :json, :form or :query)'
|
|
94
|
+
end
|
|
95
|
+
@payload_style = style.to_sym
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Per-contract strict-params override. Calling this inside a contract
|
|
99
|
+
# block wins over the class-level `strict_params` macro; generated
|
|
100
|
+
# (resource) contracts use it to stay open for free-form attributes.
|
|
101
|
+
def strict_params(flag = true)
|
|
102
|
+
@strict = flag ? true : false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Declare the expected responses: one or more status codes (Integers or
|
|
106
|
+
# Ranges) and an optional body +type+ (Array, Hash, String, any class or
|
|
107
|
+
# the symbols :array/:hash/:string/:json).
|
|
108
|
+
#
|
|
109
|
+
# When the declared statuses include error codes (4xx/5xx), the default
|
|
110
|
+
# contractful exception behavior is suppressed for this contract and
|
|
111
|
+
# every response is judged by the contract instead; a status outside
|
|
112
|
+
# the declared set raises {UnexpectedResponse}.
|
|
113
|
+
def responds(*statuses, type: nil)
|
|
114
|
+
if statuses.empty?
|
|
115
|
+
raise InvalidDefinition,
|
|
116
|
+
"responds requires at least one status for :#{@name}"
|
|
117
|
+
end
|
|
118
|
+
statuses.each do |status|
|
|
119
|
+
next if valid_status?(status)
|
|
120
|
+
|
|
121
|
+
raise InvalidDefinition,
|
|
122
|
+
"invalid status #{status.inspect} for :#{@name} " \
|
|
123
|
+
'(expected an Integer or an Integer Range)'
|
|
124
|
+
end
|
|
125
|
+
@response_statuses = statuses.freeze
|
|
126
|
+
@response_type = normalize_response_type(type) if type
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Declare pagination for this collection contract. The pagination
|
|
130
|
+
# params (cursor/offset/limit) must also be declared as regular
|
|
131
|
+
# contract params. See {Pagination} for the styles and options.
|
|
132
|
+
def paginated(**options)
|
|
133
|
+
@pagination = Pagination.normalize!(options)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Flag this contract as idempotent: the client will inject an
|
|
137
|
+
# idempotency key header on every call (default 'Idempotency-Key',
|
|
138
|
+
# see the class-level +idempotency_header+ / +idempotency_generator+),
|
|
139
|
+
# making automatic retries safe for non-GET operations.
|
|
140
|
+
def idempotent(flag = NO_DEFAULT)
|
|
141
|
+
return @idempotent if flag.equal?(NO_DEFAULT)
|
|
142
|
+
|
|
143
|
+
@idempotent = flag ? true : false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Opt this contract out of the class-level auth: `auth false`. Called
|
|
147
|
+
# with no arguments it returns the current setting (nil = inherit,
|
|
148
|
+
# false = disabled).
|
|
149
|
+
def auth(flag = NO_DEFAULT)
|
|
150
|
+
return @auth if flag.equal?(NO_DEFAULT)
|
|
151
|
+
|
|
152
|
+
unless flag.equal?(false)
|
|
153
|
+
raise InvalidDefinition,
|
|
154
|
+
'contract-level auth only accepts false to opt out of the ' \
|
|
155
|
+
"class auth scheme; got #{flag.inspect}"
|
|
156
|
+
end
|
|
157
|
+
@auth = false
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Declare a field of the response payload. See {ResponseField} for the
|
|
161
|
+
# open-schema semantics: +required+ and +type+ bind, everything else is
|
|
162
|
+
# additive data. Response fields require the body to be a JSON object.
|
|
163
|
+
def response_field(name, type = nil, **options)
|
|
164
|
+
if @response_type.equal?(String)
|
|
165
|
+
raise InvalidDefinition,
|
|
166
|
+
'response_field cannot be used with a String response type ' \
|
|
167
|
+
"for :#{@name}"
|
|
168
|
+
end
|
|
169
|
+
key = name.to_sym
|
|
170
|
+
@response_fields[key] = ResponseField.new(key, type, **options)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Register a custom assertion block. It receives the response (and the
|
|
174
|
+
# JSON-parsed body when declared with two parameters); a falsey return
|
|
175
|
+
# value raises {UnexpectedResponse}. Raising inside the block
|
|
176
|
+
# propagates untouched.
|
|
177
|
+
def verify_response(&block)
|
|
178
|
+
unless block
|
|
179
|
+
raise InvalidDefinition,
|
|
180
|
+
"verify_response requires a block for :#{@name}"
|
|
181
|
+
end
|
|
182
|
+
@response_verifier = block
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# --- path handling -----------------------------------------------------
|
|
186
|
+
|
|
187
|
+
# Symbols referenced as +:placeholders+ in the path template.
|
|
188
|
+
def path_param_names
|
|
189
|
+
@path_param_names ||=
|
|
190
|
+
@path_template.scan(PATH_PLACEHOLDER).map { |m| m[0].to_sym }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Expand the path template with (already coerced) +params+.
|
|
194
|
+
#
|
|
195
|
+
# @return [Array(String, Hash)] the expanded path and the remaining
|
|
196
|
+
# params that were not consumed by placeholders
|
|
197
|
+
def expand_path(params)
|
|
198
|
+
remaining = params.dup
|
|
199
|
+
expanded = @path_template.gsub(PATH_PLACEHOLDER) do
|
|
200
|
+
key = Regexp.last_match(1).to_sym
|
|
201
|
+
suffix = Regexp.last_match(2)
|
|
202
|
+
unless remaining.key?(key)
|
|
203
|
+
raise MissingParam,
|
|
204
|
+
"path param :#{key} is required by #{@path_template.inspect} " \
|
|
205
|
+
'but was not provided'
|
|
206
|
+
end
|
|
207
|
+
escape_path_segment(remaining.delete(key)) + suffix
|
|
208
|
+
end
|
|
209
|
+
[expanded, remaining]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def escape_path_segment(value)
|
|
213
|
+
URI.encode_www_form_component(value.to_s).gsub('+', '%20')
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# --- introspection -----------------------------------------------------
|
|
217
|
+
|
|
218
|
+
# One-line description, e.g. <tt>"POST /repos/:owner/:repo/issues (params: :owner, :repo, :title) responds 200, 404 => Hash"</tt>.
|
|
219
|
+
def describe
|
|
220
|
+
parts = ["#{verb.to_s.upcase} #{path_template}"]
|
|
221
|
+
parts << "(params: #{@params.keys.map { |k| ":#{k}" }.join(', ')})" if @params.any?
|
|
222
|
+
parts << "(fields: #{@response_fields.keys.map { |k| ":#{k}" }.join(', ')})" if @response_fields.any?
|
|
223
|
+
parts << 'idempotent' if @idempotent
|
|
224
|
+
parts << 'paginated' if @pagination
|
|
225
|
+
if @response_statuses
|
|
226
|
+
rendered = @response_statuses.map { |s| s.is_a?(Range) ? s.inspect : s.to_s }
|
|
227
|
+
response_part = "responds #{rendered.join(', ')}"
|
|
228
|
+
response_part << " => #{response_type_name}" if @response_type
|
|
229
|
+
parts << response_part
|
|
230
|
+
end
|
|
231
|
+
parts.join(' ')
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def inspect
|
|
235
|
+
"#<Contractful::Contract::Definition #{describe}>"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Human-readable name of the declared response body type (also used by
|
|
239
|
+
# the Executor when building violation messages).
|
|
240
|
+
def response_type_name
|
|
241
|
+
case @response_type
|
|
242
|
+
when :json then 'JSON'
|
|
243
|
+
when String then 'String'
|
|
244
|
+
else @response_type.name || 'anonymous class'
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
def validate_verb!
|
|
251
|
+
return if VALID_VERBS.include?(@verb)
|
|
252
|
+
|
|
253
|
+
raise InvalidDefinition,
|
|
254
|
+
"unsupported HTTP verb #{verb.to_s.inspect} for :#{@name} " \
|
|
255
|
+
"(supported: #{VALID_VERBS.join(', ')})"
|
|
256
|
+
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def valid_status?(status)
|
|
260
|
+
status.is_a?(Integer) ||
|
|
261
|
+
(status.is_a?(Range) && status.min.is_a?(Integer) && status.max.is_a?(Integer))
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def normalize_response_type(type)
|
|
265
|
+
return RESPONSE_TYPES[type] if type.is_a?(Symbol) && RESPONSE_TYPES.key?(type)
|
|
266
|
+
|
|
267
|
+
return type if type.is_a?(Class)
|
|
268
|
+
|
|
269
|
+
raise InvalidDefinition,
|
|
270
|
+
"unsupported response type #{type.inspect} for :#{@name} " \
|
|
271
|
+
'(expected a Class or :array/:hash/:string/:json)'
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def validate_path_template!
|
|
275
|
+
return if @path_template.start_with?('/')
|
|
276
|
+
|
|
277
|
+
raise InvalidDefinition,
|
|
278
|
+
"path template #{@path_template.inspect} for :#{@name} must " \
|
|
279
|
+
'start with "/" (it is resolved against the base URL)'
|
|
280
|
+
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Every +:placeholder+ in the path template is implicitly a required
|
|
284
|
+
# String param unless the user declared it explicitly.
|
|
285
|
+
def synthesize_path_params!
|
|
286
|
+
path_param_names.each do |key|
|
|
287
|
+
next if @params.key?(key)
|
|
288
|
+
|
|
289
|
+
@params[key] = Param.new(key, nil, required: true,
|
|
290
|
+
description: 'path parameter (auto-synthesized)')
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|