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