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