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,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Contractful
|
|
4
|
+
module Contract
|
|
5
|
+
module PostmanImport
|
|
6
|
+
# One Postman request as a contract {Definition}, or a note saying why it
|
|
7
|
+
# cannot become one.
|
|
8
|
+
module Operation
|
|
9
|
+
VERBS = %w[get put post patch delete head options].freeze
|
|
10
|
+
# A built operation: its definition, the origin it calls and its auth.
|
|
11
|
+
Result = Struct.new(:definition, :origin, :auth, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def build(item, scope)
|
|
16
|
+
request = item['request']
|
|
17
|
+
verb = request['method'].to_s.downcase
|
|
18
|
+
return unsupported(item, verb, scope) unless VERBS.include?(verb)
|
|
19
|
+
|
|
20
|
+
build_request(item, request, verb, scope)
|
|
21
|
+
rescue Url::Unresolved, Contract::InvalidDefinition => e
|
|
22
|
+
scope.note("#{item['name'] || 'request'}: #{e.message} — skipped")
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def build_request(item, request, verb, scope)
|
|
27
|
+
parsed = Url.parse(request['url'], scope.variables)
|
|
28
|
+
definition = new_definition(item, verb, parsed, request, scope)
|
|
29
|
+
add_parts(definition, request, parsed, "#{verb.upcase} #{parsed.path}", scope)
|
|
30
|
+
Result.new(definition: definition, origin: parsed.origin, auth: request['auth'])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def unsupported(item, verb, scope)
|
|
34
|
+
scope.note("#{item['name'] || 'request'}: #{verb.upcase} is not a supported method — skipped")
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def new_definition(item, verb, parsed, request, scope)
|
|
39
|
+
Definition.new(scope.name_for(item, verb, parsed.path), verb.to_sym, parsed.path,
|
|
40
|
+
description: description(request))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def add_parts(definition, request, parsed, label, scope)
|
|
44
|
+
add_query(definition, parsed.query, scope, label)
|
|
45
|
+
add_headers(definition, request['header'], scope, label)
|
|
46
|
+
Body.apply(definition, request['body'], scope, label)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def description(request)
|
|
50
|
+
text = request['description']
|
|
51
|
+
text = text['content'] if text.is_a?(Hash)
|
|
52
|
+
text.is_a?(String) && !text.empty? ? text : nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Query keys become untyped, optional params; the example values are not kept.
|
|
56
|
+
def add_query(definition, pairs, scope, label)
|
|
57
|
+
pairs.map(&:first).each { |name| add_query_key(definition, name, scope, label) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def add_query_key(definition, name, scope, label)
|
|
61
|
+
unless name.match?(PostmanImport::IDENTIFIER)
|
|
62
|
+
return scope.note("#{label}: query parameter #{name.inspect} is not a valid identifier — skipped")
|
|
63
|
+
end
|
|
64
|
+
if definition.params.key?(name.to_sym)
|
|
65
|
+
return scope.note("#{label}: query parameter #{name.inspect} collides with another param — skipped")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
definition.param(name.to_sym)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def add_headers(definition, headers, scope, label)
|
|
72
|
+
PostmanImport.enabled(headers).each do |header|
|
|
73
|
+
name = header['key'].to_s
|
|
74
|
+
next if not_mapped_header?(name, scope, label)
|
|
75
|
+
|
|
76
|
+
add_header(definition, name, header['value'].to_s, scope, label)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def not_mapped_header?(name, scope, label)
|
|
81
|
+
case name.downcase
|
|
82
|
+
when 'content-type' then true # the body decides the content type
|
|
83
|
+
when 'authorization'
|
|
84
|
+
scope.note("#{label}: Authorization is not mapped; declare auth (see the auth hints)")
|
|
85
|
+
true
|
|
86
|
+
else false
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A static value becomes the default, so the header is sent unless the caller
|
|
91
|
+
# overrides it; a value with a variable is left for the caller to supply.
|
|
92
|
+
def add_header(definition, name, value, scope, label)
|
|
93
|
+
key = OpenAPIImport.header_param_key(name)
|
|
94
|
+
unless name.match?(Param::HTTP_TOKEN) && key && !definition.params.key?(key)
|
|
95
|
+
return scope.note("#{label}: header #{name.inspect} cannot be declared (name or duplicate) — skipped")
|
|
96
|
+
end
|
|
97
|
+
return definition.param(key, String, header: name) if value.empty? || value.include?('{{')
|
|
98
|
+
unless Param::HEADER_VALUE.match?(value)
|
|
99
|
+
return scope.note("#{label}: header #{name.inspect} has control characters in its value — skipped")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
definition.param(key, String, header: name, default: value)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Contractful
|
|
4
|
+
module Contract
|
|
5
|
+
module PostmanImport
|
|
6
|
+
# The notes and contract names of one import.
|
|
7
|
+
class Scope
|
|
8
|
+
attr_reader :variables, :notes
|
|
9
|
+
attr_writer :inferred_body
|
|
10
|
+
|
|
11
|
+
def initialize(variables)
|
|
12
|
+
@variables = variables
|
|
13
|
+
@notes = []
|
|
14
|
+
@names = OpenAPIImport::Context.new({})
|
|
15
|
+
@inferred_body = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def note(message)
|
|
19
|
+
@notes << message unless @notes.include?(message)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def inferred_body?
|
|
23
|
+
@inferred_body
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A contract name from the request name, or from the method and path when
|
|
27
|
+
# the request has no usable name.
|
|
28
|
+
def name_for(item, verb, path)
|
|
29
|
+
base = OpenAPIImport.snake_case(item['name'].to_s).gsub(/[^a-zA-Z0-9_]+/, '_').gsub(/\A_+|_+\z/, '')
|
|
30
|
+
base = "#{verb} #{path}".gsub(/[^a-zA-Z0-9]+/, '_').gsub(/\A_+|_+\z/, '') if base.empty?
|
|
31
|
+
base = "_#{base}" if base.match?(/\A\d/)
|
|
32
|
+
@names.unique_name(base)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Contractful
|
|
6
|
+
module Contract
|
|
7
|
+
module PostmanImport
|
|
8
|
+
# Turns a Postman request URL into an origin, a path template and query
|
|
9
|
+
# pairs. {{variables}} resolve from the collection and the caller. The
|
|
10
|
+
# origin and the path must resolve, since a contract needs both.
|
|
11
|
+
module Url
|
|
12
|
+
VARIABLE = /\{\{\s*([^{}\s]+)\s*\}\}/
|
|
13
|
+
ABSOLUTE = %r{\A([a-zA-Z][a-zA-Z0-9+.-]*://[^/]+)(/.*)?\z}
|
|
14
|
+
|
|
15
|
+
# The origin and path of a request, and its enabled query pairs.
|
|
16
|
+
Parsed = Struct.new(:origin, :path, :query, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
# Raised when the origin or the path cannot be resolved.
|
|
19
|
+
class Unresolved < StandardError; end
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def parse(request_url, variables)
|
|
24
|
+
text = substitute(raw(request_url), variables)
|
|
25
|
+
main, query_text = text.split('?', 2)
|
|
26
|
+
main ||= ''
|
|
27
|
+
variable = main.scan(VARIABLE).first
|
|
28
|
+
raise Unresolved, "unresolved variable {{#{variable.first}}}" if variable
|
|
29
|
+
|
|
30
|
+
match = ABSOLUTE.match(main)
|
|
31
|
+
raise Unresolved, "no absolute URL for #{main.inspect}" unless match
|
|
32
|
+
|
|
33
|
+
Parsed.new(origin: match[1], path: match[2] || '/', query: query(request_url, query_text))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The URL as one string: Postman stores it as a string or as an object.
|
|
37
|
+
def raw(request_url)
|
|
38
|
+
return request_url if request_url.is_a?(String)
|
|
39
|
+
return '' unless request_url.is_a?(Hash)
|
|
40
|
+
return request_url['raw'].to_s if request_url['raw']
|
|
41
|
+
|
|
42
|
+
"#{scheme_of(request_url)}#{authority(request_url)}#{path_of(request_url)}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def scheme_of(request_url)
|
|
46
|
+
request_url['protocol'] ? "#{request_url['protocol']}://" : ''
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def authority(request_url)
|
|
50
|
+
host = Array(request_url['host']).join('.')
|
|
51
|
+
request_url['port'] ? "#{host}:#{request_url['port']}" : host
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def path_of(request_url)
|
|
55
|
+
path = Array(request_url['path']).join('/')
|
|
56
|
+
path.empty? ? '' : "/#{path}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def substitute(text, variables)
|
|
60
|
+
text.gsub(VARIABLE) do
|
|
61
|
+
name = Regexp.last_match(1)
|
|
62
|
+
variables.fetch(name) { "{{#{name}}}" }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The object form's query list wins over the query text of the raw URL.
|
|
67
|
+
def query(request_url, query_text)
|
|
68
|
+
if request_url.is_a?(Hash) && request_url['query']
|
|
69
|
+
return PostmanImport.enabled(request_url['query']).map { |entry| [entry['key'].to_s, entry['value'].to_s] }
|
|
70
|
+
end
|
|
71
|
+
return [] unless query_text
|
|
72
|
+
|
|
73
|
+
URI.decode_www_form(query_text)
|
|
74
|
+
rescue ArgumentError
|
|
75
|
+
[]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'openapi_import'
|
|
5
|
+
require_relative 'postman_import/scope'
|
|
6
|
+
require_relative 'postman_import/url'
|
|
7
|
+
require_relative 'postman_import/body'
|
|
8
|
+
require_relative 'postman_import/auth'
|
|
9
|
+
require_relative 'postman_import/operation'
|
|
10
|
+
|
|
11
|
+
module Contractful
|
|
12
|
+
module Contract
|
|
13
|
+
# Imports a Postman Collection (a v2.0 or v2.1 JSON export) as contract
|
|
14
|
+
# declarations, the way {OpenAPIImport} imports OpenAPI documents. It is
|
|
15
|
+
# opt-in and development-time:
|
|
16
|
+
#
|
|
17
|
+
# require 'contractful/contract/postman_import'
|
|
18
|
+
#
|
|
19
|
+
# A collection carries example values rather than schemas, so its params are
|
|
20
|
+
# untyped and none is required, except a GraphQL query. Everything that is not
|
|
21
|
+
# mapped is reported on {Import#notes}.
|
|
22
|
+
module PostmanImport
|
|
23
|
+
FORMAT = 'Postman Collection v2.1'
|
|
24
|
+
IDENTIFIER = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
|
|
25
|
+
|
|
26
|
+
# The import result, shared with {OpenAPIImport}.
|
|
27
|
+
Import = OpenAPIImport::Import
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# @param source a collection Hash, a path, or JSON text
|
|
32
|
+
# @param variables [Hash] values for {{variables}} the collection does not
|
|
33
|
+
# define, such as an environment's baseUrl; they override the collection's
|
|
34
|
+
# @param only [Array<Symbol>] keep only these operation names
|
|
35
|
+
# @param except [Array<Symbol>] drop these operation names
|
|
36
|
+
# @return [OpenAPIImport::Import]
|
|
37
|
+
def import(source, variables: {}, only: nil, except: nil)
|
|
38
|
+
collection = load(source)
|
|
39
|
+
scope = Scope.new(collection_variables(collection).merge(string_hash(variables)))
|
|
40
|
+
results = requests(collection['item']).filter_map { |item| Operation.build(item, scope) }
|
|
41
|
+
base = base_origin(results, scope)
|
|
42
|
+
definitions = definitions_for(results, base, only, except)
|
|
43
|
+
summarize(scope, definitions)
|
|
44
|
+
assemble(collection, results, base, definitions, scope)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def assemble(collection, results, base, definitions, scope)
|
|
48
|
+
Import.new(document: document(collection), base_url: base, auth_hints: auth_hints(collection, results),
|
|
49
|
+
definitions: definitions, notes: scope.notes)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def definitions(source, variables: {}, only: nil, except: nil)
|
|
53
|
+
import(source, variables: variables, only: only, except: except).definitions
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_ruby(source, class_name: 'GeneratedClient', variables: {}, only: nil, except: nil)
|
|
57
|
+
import(source, variables: variables, only: only, except: except).to_ruby(class_name: class_name)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def load(source)
|
|
61
|
+
collection = source.is_a?(Hash) ? source : parse(source)
|
|
62
|
+
unless collection.is_a?(Hash) && collection['item'].is_a?(Array)
|
|
63
|
+
raise OpenAPIImport::Error, 'not a Postman collection: expected an "item" array'
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
collection
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse(source)
|
|
70
|
+
JSON.parse(text_of(source))
|
|
71
|
+
rescue JSON::ParserError => e
|
|
72
|
+
raise OpenAPIImport::Error, "invalid JSON for a Postman collection: #{e.message}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def text_of(source)
|
|
76
|
+
return source if source.is_a?(String) && source.lstrip.start_with?('{', '[')
|
|
77
|
+
raise OpenAPIImport::Error, "no such file: #{source}" unless File.file?(source.to_s)
|
|
78
|
+
|
|
79
|
+
File.read(source.to_s)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Request items in document order, with folders flattened away.
|
|
83
|
+
def requests(items)
|
|
84
|
+
list(items).flat_map do |item|
|
|
85
|
+
next [] unless item.is_a?(Hash)
|
|
86
|
+
next [item] if item['request'].is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
requests(item['item'])
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The origin most requests call. Requests on another origin cannot share
|
|
93
|
+
# one client's base URL, so they are skipped and noted.
|
|
94
|
+
def base_origin(results, scope)
|
|
95
|
+
base = results.map(&:origin).tally.max_by(&:last)&.first
|
|
96
|
+
results.each do |result|
|
|
97
|
+
next if result.origin == base
|
|
98
|
+
|
|
99
|
+
scope.note("#{result.definition.name}: calls #{result.origin}, not #{base} — one client has " \
|
|
100
|
+
'one base URL, so it is skipped')
|
|
101
|
+
end
|
|
102
|
+
base
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def definitions_for(results, base, only, except)
|
|
106
|
+
results.each_with_object({}) do |result, out|
|
|
107
|
+
name = result.definition.name
|
|
108
|
+
out[name] = result.definition if result.origin == base && !OpenAPIImport.skip_name?(name, only, except)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def summarize(scope, definitions)
|
|
113
|
+
return if definitions.empty?
|
|
114
|
+
|
|
115
|
+
scope.note('a Postman collection carries example values, not schemas: params are untyped and ' \
|
|
116
|
+
'none is required, except a GraphQL query')
|
|
117
|
+
return unless scope.inferred_body?
|
|
118
|
+
|
|
119
|
+
scope.note('body fields come from example bodies: their types are unknown, and strict_params is ' \
|
|
120
|
+
'off on those operations, because an example may omit fields')
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def document(collection)
|
|
124
|
+
{ 'info' => { 'title' => info_name(collection) }, 'x-contractful-source' => FORMAT }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def auth_hints(collection, results)
|
|
128
|
+
prefix = env_prefix(collection)
|
|
129
|
+
auths = ([collection['auth']] + results.map(&:auth)).compact.uniq
|
|
130
|
+
auths.flat_map { |auth| Auth.hints(auth, prefix) }.uniq
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def info_name(collection)
|
|
134
|
+
info = collection['info']
|
|
135
|
+
info.is_a?(Hash) ? info['name'].to_s : ''
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def env_prefix(collection)
|
|
139
|
+
name = info_name(collection).upcase.gsub(/[^A-Z0-9]+/, '_').gsub(/\A_+|_+\z/, '')
|
|
140
|
+
name.empty? ? 'POSTMAN' : name
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def collection_variables(collection)
|
|
144
|
+
list(collection['variable']).each_with_object({}) do |entry, out|
|
|
145
|
+
out[entry['key'].to_s] = entry['value'].to_s if entry.is_a?(Hash) && entry['key']
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def string_hash(hash)
|
|
150
|
+
hash.to_h { |key, value| [key.to_s, value.to_s] }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The items of an array field, or none when the field is missing or malformed.
|
|
154
|
+
def list(value)
|
|
155
|
+
value.is_a?(Array) ? value : []
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The entries of a key/value list that are not disabled.
|
|
159
|
+
def enabled(entries)
|
|
160
|
+
list(entries).select { |entry| entry.is_a?(Hash) && !entry['disabled'] }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Contractful
|
|
4
|
+
module Contract
|
|
5
|
+
# A single declared field of a response payload — the "open" side of a
|
|
6
|
+
# contract.
|
|
7
|
+
#
|
|
8
|
+
# Response schemas are deliberately OPEN, unlike request params:
|
|
9
|
+
#
|
|
10
|
+
# * +required+ and +type+ BIND: a declared required field that is
|
|
11
|
+
# missing (or null), or a field whose value does not match the
|
|
12
|
+
# declared type, raises {UnexpectedResponse} when the response is
|
|
13
|
+
# received.
|
|
14
|
+
# * Everything else is ADDITIVE DATA: fields that were never declared
|
|
15
|
+
# are passed through untouched, and declared +inclusion+ sets are
|
|
16
|
+
# NOT enforced unless <tt>strict: true</tt> is given — a new enum
|
|
17
|
+
# member the client does not know about yet is data, not an error.
|
|
18
|
+
# * Values are never coerced: what the server sent is what you get.
|
|
19
|
+
#
|
|
20
|
+
# Example:
|
|
21
|
+
#
|
|
22
|
+
# contract :create_order, 'POST /orders' do
|
|
23
|
+
# response_field :order_id, :string, required: true
|
|
24
|
+
# response_field :status, :string # open: new states are additive
|
|
25
|
+
# end
|
|
26
|
+
class ResponseField
|
|
27
|
+
# Symbol aliases accepted for +type+. +:boolean+ matches true/false,
|
|
28
|
+
# +:number+ matches any Numeric (Integer or Float).
|
|
29
|
+
SYMBOL_TYPES = {
|
|
30
|
+
string: String,
|
|
31
|
+
integer: Integer,
|
|
32
|
+
float: Float,
|
|
33
|
+
array: Array,
|
|
34
|
+
hash: Hash,
|
|
35
|
+
number: :number,
|
|
36
|
+
boolean: :boolean
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
attr_reader :name, :type, :inclusion, :description
|
|
40
|
+
|
|
41
|
+
def initialize(name, type = nil, required: false, inclusion: nil,
|
|
42
|
+
strict: false, description: nil)
|
|
43
|
+
@name = name.to_sym
|
|
44
|
+
@type = normalize_type(type)
|
|
45
|
+
@required = required ? true : false
|
|
46
|
+
@inclusion = inclusion
|
|
47
|
+
@strict = strict ? true : false
|
|
48
|
+
@description = description
|
|
49
|
+
validate_definition!
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def required?
|
|
53
|
+
@required
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def strict?
|
|
57
|
+
@strict
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Check a parsed JSON object against this field.
|
|
61
|
+
#
|
|
62
|
+
# JSON.parse yields String-keyed hashes, so both Symbol and String key
|
|
63
|
+
# spellings are looked up (Symbol first — internal hashes tend to be
|
|
64
|
+
# symbol-keyed).
|
|
65
|
+
#
|
|
66
|
+
# @return [Array<String>] every violation message (empty when the
|
|
67
|
+
# payload conforms)
|
|
68
|
+
def violations_for(payload)
|
|
69
|
+
if payload.key?(@name)
|
|
70
|
+
value = payload[@name]
|
|
71
|
+
elsif payload.key?(string_name = @name.to_s)
|
|
72
|
+
value = payload[string_name]
|
|
73
|
+
else
|
|
74
|
+
return @required ? ["field :#{@name} is required but missing"] : []
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
msgs = []
|
|
78
|
+
if value.nil?
|
|
79
|
+
msgs << "field :#{@name} is required but was null" if @required
|
|
80
|
+
else
|
|
81
|
+
msgs.concat(type_violations(value))
|
|
82
|
+
msgs.concat(inclusion_violations(value))
|
|
83
|
+
end
|
|
84
|
+
msgs
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Human-readable summary used in error messages and introspection.
|
|
88
|
+
def to_s
|
|
89
|
+
parts = [type_name]
|
|
90
|
+
parts << 'required' if @required
|
|
91
|
+
parts << "in #{inclusion.inspect}" if @inclusion
|
|
92
|
+
parts << 'strict' if @strict
|
|
93
|
+
parts.join(', ')
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def normalize_type(type)
|
|
99
|
+
return nil if type.nil?
|
|
100
|
+
|
|
101
|
+
if type.is_a?(Symbol)
|
|
102
|
+
unless SYMBOL_TYPES.key?(type)
|
|
103
|
+
raise InvalidDefinition,
|
|
104
|
+
"unsupported response_field type #{type.inspect} for :#{@name} " \
|
|
105
|
+
"(supported symbols: #{SYMBOL_TYPES.keys.join(', ')})"
|
|
106
|
+
end
|
|
107
|
+
return SYMBOL_TYPES[type]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
unless type.is_a?(Class)
|
|
111
|
+
raise InvalidDefinition,
|
|
112
|
+
"unsupported response_field type #{type.inspect} for :#{@name} " \
|
|
113
|
+
'(expected a Class or a supported symbol)'
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
type
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validate_definition!
|
|
120
|
+
return unless @inclusion && !@inclusion.respond_to?(:include?)
|
|
121
|
+
|
|
122
|
+
raise InvalidDefinition,
|
|
123
|
+
"response_field :#{@name}: inclusion must respond to #include? " \
|
|
124
|
+
"(got #{@inclusion.inspect})"
|
|
125
|
+
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def type_violations(value)
|
|
129
|
+
return [] unless @type
|
|
130
|
+
|
|
131
|
+
case @type
|
|
132
|
+
when :boolean
|
|
133
|
+
unless value.equal?(true) || value.equal?(false)
|
|
134
|
+
return ["field :#{@name} expected boolean, got #{value.class}"]
|
|
135
|
+
end
|
|
136
|
+
when :number
|
|
137
|
+
return ["field :#{@name} expected number, got #{value.class}"] unless value.is_a?(Numeric)
|
|
138
|
+
else
|
|
139
|
+
return ["field :#{@name} expected #{type_name}, got #{value.class}"] unless value.is_a?(@type)
|
|
140
|
+
end
|
|
141
|
+
[]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def inclusion_violations(value)
|
|
145
|
+
# Open-schema default: new enum members are additive data. Only an
|
|
146
|
+
# explicitly strict field treats an unknown value as a violation.
|
|
147
|
+
return [] unless @inclusion && @strict
|
|
148
|
+
|
|
149
|
+
unless @inclusion.include?(value)
|
|
150
|
+
return ["field :#{@name} value #{value.inspect} " \
|
|
151
|
+
"is not in #{@inclusion.inspect}"]
|
|
152
|
+
end
|
|
153
|
+
[]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def type_name
|
|
157
|
+
case @type
|
|
158
|
+
when nil then 'untyped'
|
|
159
|
+
when :boolean then 'boolean'
|
|
160
|
+
when :number then 'number'
|
|
161
|
+
when Class then @type.name || 'anonymous class'
|
|
162
|
+
else @type.to_s
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|