committee 3.1.0 → 3.1.1

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/bin/committee-stub +1 -0
  3. data/lib/committee.rb +12 -34
  4. data/lib/committee/bin/committee_stub.rb +6 -4
  5. data/lib/committee/drivers.rb +15 -67
  6. data/lib/committee/drivers/driver.rb +47 -0
  7. data/lib/committee/drivers/hyper_schema.rb +8 -171
  8. data/lib/committee/drivers/hyper_schema/driver.rb +105 -0
  9. data/lib/committee/drivers/hyper_schema/link.rb +68 -0
  10. data/lib/committee/drivers/hyper_schema/schema.rb +22 -0
  11. data/lib/committee/drivers/open_api_2.rb +9 -416
  12. data/lib/committee/drivers/open_api_2/driver.rb +253 -0
  13. data/lib/committee/drivers/open_api_2/header_schema_builder.rb +33 -0
  14. data/lib/committee/drivers/open_api_2/link.rb +36 -0
  15. data/lib/committee/drivers/open_api_2/parameter_schema_builder.rb +83 -0
  16. data/lib/committee/drivers/open_api_2/schema.rb +26 -0
  17. data/lib/committee/drivers/open_api_2/schema_builder.rb +33 -0
  18. data/lib/committee/drivers/open_api_3.rb +7 -75
  19. data/lib/committee/drivers/open_api_3/driver.rb +51 -0
  20. data/lib/committee/drivers/open_api_3/schema.rb +41 -0
  21. data/lib/committee/drivers/schema.rb +23 -0
  22. data/lib/committee/errors.rb +2 -0
  23. data/lib/committee/middleware.rb +11 -0
  24. data/lib/committee/middleware/base.rb +38 -34
  25. data/lib/committee/middleware/request_validation.rb +51 -30
  26. data/lib/committee/middleware/response_validation.rb +49 -26
  27. data/lib/committee/middleware/stub.rb +55 -51
  28. data/lib/committee/request_unpacker.rb +3 -1
  29. data/lib/committee/schema_validator.rb +23 -0
  30. data/lib/committee/schema_validator/hyper_schema.rb +85 -74
  31. data/lib/committee/schema_validator/hyper_schema/parameter_coercer.rb +60 -54
  32. data/lib/committee/schema_validator/hyper_schema/request_validator.rb +43 -37
  33. data/lib/committee/schema_validator/hyper_schema/response_generator.rb +86 -80
  34. data/lib/committee/schema_validator/hyper_schema/response_validator.rb +65 -59
  35. data/lib/committee/schema_validator/hyper_schema/router.rb +35 -29
  36. data/lib/committee/schema_validator/hyper_schema/string_params_coercer.rb +87 -81
  37. data/lib/committee/schema_validator/open_api_3.rb +71 -61
  38. data/lib/committee/schema_validator/open_api_3/operation_wrapper.rb +121 -115
  39. data/lib/committee/schema_validator/open_api_3/request_validator.rb +24 -18
  40. data/lib/committee/schema_validator/open_api_3/response_validator.rb +22 -16
  41. data/lib/committee/schema_validator/open_api_3/router.rb +30 -24
  42. data/lib/committee/schema_validator/option.rb +42 -38
  43. data/lib/committee/test/methods.rb +55 -51
  44. data/lib/committee/validation_error.rb +2 -0
  45. data/test/bin/committee_stub_test.rb +3 -1
  46. data/test/bin_test.rb +3 -1
  47. data/test/committee_test.rb +3 -1
  48. data/test/drivers/hyper_schema/driver_test.rb +49 -0
  49. data/test/drivers/{hyper_schema_test.rb → hyper_schema/link_test.rb} +2 -45
  50. data/test/drivers/open_api_2/driver_test.rb +156 -0
  51. data/test/drivers/open_api_2/header_schema_builder_test.rb +26 -0
  52. data/test/drivers/open_api_2/link_test.rb +52 -0
  53. data/test/drivers/open_api_2/parameter_schema_builder_test.rb +195 -0
  54. data/test/drivers/{open_api_3_test.rb → open_api_3/driver_test.rb} +5 -3
  55. data/test/drivers_test.rb +12 -10
  56. data/test/middleware/base_test.rb +3 -1
  57. data/test/middleware/request_validation_open_api_3_test.rb +4 -2
  58. data/test/middleware/request_validation_test.rb +46 -5
  59. data/test/middleware/response_validation_open_api_3_test.rb +3 -1
  60. data/test/middleware/response_validation_test.rb +39 -4
  61. data/test/middleware/stub_test.rb +3 -1
  62. data/test/request_unpacker_test.rb +2 -2
  63. data/test/schema_validator/hyper_schema/parameter_coercer_test.rb +2 -2
  64. data/test/schema_validator/hyper_schema/request_validator_test.rb +3 -1
  65. data/test/schema_validator/hyper_schema/response_generator_test.rb +3 -1
  66. data/test/schema_validator/hyper_schema/response_validator_test.rb +3 -1
  67. data/test/schema_validator/hyper_schema/router_test.rb +5 -3
  68. data/test/schema_validator/hyper_schema/string_params_coercer_test.rb +3 -1
  69. data/test/schema_validator/open_api_3/operation_wrapper_test.rb +3 -1
  70. data/test/schema_validator/open_api_3/request_validator_test.rb +11 -1
  71. data/test/schema_validator/open_api_3/response_validator_test.rb +3 -1
  72. data/test/test/methods_new_version_test.rb +3 -1
  73. data/test/test/methods_test.rb +4 -2
  74. data/test/test_helper.rb +16 -16
  75. data/test/validation_error_test.rb +3 -1
  76. metadata +52 -6
  77. data/lib/committee/schema_validator/schema_validator.rb +0 -15
  78. data/test/drivers/open_api_2_test.rb +0 -416
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Committee
4
+ module Drivers
5
+ module OpenAPI2
6
+ class Driver < ::Committee::Drivers::Driver
7
+ def default_coerce_date_times
8
+ false
9
+ end
10
+
11
+ # Whether parameters that were form-encoded will be coerced by default.
12
+ def default_coerce_form_params
13
+ true
14
+ end
15
+
16
+ def default_allow_get_body
17
+ true
18
+ end
19
+
20
+ # Whether parameters in a request's path will be considered and coerced by
21
+ # default.
22
+ def default_path_params
23
+ true
24
+ end
25
+
26
+ # Whether parameters in a request's query string will be considered and
27
+ # coerced by default.
28
+ def default_query_params
29
+ true
30
+ end
31
+
32
+ def default_validate_success_only
33
+ true
34
+ end
35
+
36
+ def name
37
+ :open_api_2
38
+ end
39
+
40
+ # Parses an API schema and builds a set of route definitions for use with
41
+ # Committee.
42
+ #
43
+ # The expected input format is a data hash with keys as strings (as opposed
44
+ # to symbols) like the kind produced by JSON.parse or YAML.load.
45
+ def parse(data)
46
+ REQUIRED_FIELDS.each do |field|
47
+ if !data[field]
48
+ raise ArgumentError, "Committee: no #{field} section in spec data."
49
+ end
50
+ end
51
+
52
+ if data['swagger'] != '2.0'
53
+ raise ArgumentError, "Committee: driver requires OpenAPI 2.0."
54
+ end
55
+
56
+ schema = Schema.new
57
+ schema.driver = self
58
+
59
+ schema.base_path = data['basePath'] || ''
60
+
61
+ # Arbitrarily choose the first media type found in these arrays. This
62
+ # appraoch could probably stand to be improved, but at least users will
63
+ # for now have the option of turning media type validation off if they so
64
+ # choose.
65
+ schema.consumes = data['consumes'].first
66
+ schema.produces = data['produces'].first
67
+
68
+ schema.definitions, store = parse_definitions!(data)
69
+ schema.routes = parse_routes!(data, schema, store)
70
+
71
+ schema
72
+ end
73
+
74
+ def schema_class
75
+ Committee::Drivers::OpenAPI2::Schema
76
+ end
77
+
78
+ private
79
+
80
+ DEFINITIONS_PSEUDO_URI = "http://json-schema.org/committee-definitions"
81
+
82
+ # These are fields that the OpenAPI 2 spec considers mandatory to be
83
+ # included in the document's top level.
84
+ REQUIRED_FIELDS = [
85
+ :consumes,
86
+ :definitions,
87
+ :paths,
88
+ :produces,
89
+ :swagger,
90
+ ].map(&:to_s).freeze
91
+
92
+ def find_best_fit_response(link_data)
93
+ if response_data = link_data["responses"]["200"] || response_data = link_data["responses"][200]
94
+ [200, response_data]
95
+ elsif response_data = link_data["responses"]["201"] || response_data = link_data["responses"][201]
96
+ [201, response_data]
97
+ else
98
+ # Sort responses so that we can try to prefer any 3-digit status code.
99
+ # If there are none, we'll just take anything from the list.
100
+ ordered_responses = link_data["responses"].
101
+ select { |k, v| k.to_s =~ /[0-9]{3}/ }
102
+ if first = ordered_responses.first
103
+ [first[0].to_i, first[1]]
104
+ else
105
+ [nil, nil]
106
+ end
107
+ end
108
+ end
109
+
110
+ def href_to_regex(href)
111
+ href.gsub(/\{(.*?)\}/, '(?<\1>[^/]+)')
112
+ end
113
+
114
+ def parse_definitions!(data)
115
+ # The "definitions" section of an OpenAPI 2 spec is a valid JSON schema.
116
+ # We extract it from the spec and parse it as a schema in isolation so
117
+ # that all references to it will still have correct paths (i.e. we can
118
+ # still find a resource at '#/definitions/resource' instead of
119
+ # '#/resource').
120
+ schema = JsonSchema.parse!({
121
+ "definitions" => data['definitions'],
122
+ })
123
+ schema.expand_references!
124
+ schema.uri = DEFINITIONS_PSEUDO_URI
125
+
126
+ # So this is a little weird: an OpenAPI specification is _not_ a valid
127
+ # JSON schema and yet it self-references like it is a valid JSON schema.
128
+ # To work around this what we do is parse its "definitions" section as a
129
+ # JSON schema and then build a document store here containing that. When
130
+ # trying to resolve a reference from elsewhere in the spec, we build a
131
+ # synthetic schema with a JSON reference to the document created from
132
+ # "definitions" and then expand references against this store.
133
+ store = JsonSchema::DocumentStore.new
134
+ store.add_schema(schema)
135
+
136
+ [schema, store]
137
+ end
138
+
139
+ def parse_routes!(data, schema, store)
140
+ routes = {}
141
+
142
+ # This is a performance optimization: instead of going through each link
143
+ # and parsing out its JSON schema separately, instead we just aggregate
144
+ # all schemas into one big hash and then parse it all at the end. After
145
+ # we parse it, go through each link and assign a proper schema object. In
146
+ # practice this comes out to somewhere on the order of 50x faster.
147
+ schemas_data = { "properties" => {} }
148
+
149
+ # Exactly the same idea, but for response schemas.
150
+ target_schemas_data = { "properties" => {} }
151
+
152
+ data['paths'].each do |path, methods|
153
+ href = schema.base_path + path
154
+ schemas_data["properties"][href] = { "properties" => {} }
155
+ target_schemas_data["properties"][href] = { "properties" => {} }
156
+
157
+ methods.each do |method, link_data|
158
+ method = method.upcase
159
+
160
+ link = Link.new
161
+ link.enc_type = schema.consumes
162
+ link.href = href
163
+ link.media_type = schema.produces
164
+ link.method = method
165
+
166
+ # Convert the spec's parameter pseudo-schemas into JSON schemas that
167
+ # we can use for some basic request validation.
168
+ link.schema, schema_data = ParameterSchemaBuilder.new(link_data).call
169
+ link.header_schema = HeaderSchemaBuilder.new(link_data).call
170
+
171
+ # If data came back instead of a schema (this occurs when a route has
172
+ # a single `body` parameter instead of a collection of URL/query/form
173
+ # parameters), store it for later parsing.
174
+ if schema_data
175
+ schemas_data["properties"][href]["properties"][method] = schema_data
176
+ end
177
+
178
+ # Arbitrarily pick one response for the time being. Prefers in order:
179
+ # a 200, 201, any 3-digit numerical response, then anything at all.
180
+ status, response_data = find_best_fit_response(link_data)
181
+ if status
182
+ link.status_success = status
183
+
184
+ # A link need not necessarily specify a target schema.
185
+ if response_data["schema"]
186
+ target_schemas_data["properties"][href]["properties"][method] =
187
+ response_data["schema"]
188
+ end
189
+ end
190
+
191
+ rx = %r{^#{href_to_regex(link.href)}$}
192
+ Committee.log_debug "Created route: #{link.method} #{link.href} (regex #{rx})"
193
+
194
+ routes[method] ||= []
195
+ routes[method] << [rx, link]
196
+ end
197
+ end
198
+
199
+ # See the note on our DocumentStore's initialization in
200
+ # #parse_definitions!, but what we're doing here is prefixing references
201
+ # with a specialized internal URI so that they can reference definitions
202
+ # from another document in the store.
203
+ schemas =
204
+ rewrite_references_and_parse(schemas_data, store)
205
+ target_schemas =
206
+ rewrite_references_and_parse(target_schemas_data, store)
207
+
208
+ # As noted above, now that we've parsed our aggregate response schema, go
209
+ # back through each link and them their response schema.
210
+ routes.each do |method, method_routes|
211
+ method_routes.each do |(_, link)|
212
+ # request
213
+ #
214
+ # Differs slightly from responses in that the schema may already have
215
+ # been set for endpoints with non-body parameters, so check for nil
216
+ # before we set it.
217
+ if schema = schemas.properties[link.href].properties[method]
218
+ link.schema = schema
219
+ end
220
+
221
+ # response
222
+ link.target_schema =
223
+ target_schemas.properties[link.href].properties[method]
224
+ end
225
+ end
226
+
227
+ routes
228
+ end
229
+
230
+ def rewrite_references_and_parse(schemas_data, store)
231
+ schemas = rewrite_references(schemas_data)
232
+ schemas = JsonSchema.parse!(schemas_data)
233
+ schemas.expand_references!(store: store)
234
+ schemas
235
+ end
236
+
237
+ def rewrite_references(schema)
238
+ if schema.is_a?(Hash)
239
+ ref = schema["$ref"]
240
+ if ref && ref.is_a?(String) && ref[0] == "#"
241
+ schema["$ref"] = DEFINITIONS_PSEUDO_URI + ref
242
+ else
243
+ schema.each do |_, v|
244
+ rewrite_references(v)
245
+ end
246
+ end
247
+ end
248
+ schema
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Committee
4
+ module Drivers
5
+ module OpenAPI2
6
+ class HeaderSchemaBuilder < SchemaBuilder
7
+ def call
8
+ if link_data["parameters"]
9
+ link_schema = JsonSchema::Schema.new
10
+ link_schema.properties = {}
11
+ link_schema.required = []
12
+
13
+ header_parameters = link_data["parameters"].select { |param_data| param_data["in"] == "header" }
14
+ header_parameters.each do |param_data|
15
+ check_required_fields!(param_data)
16
+
17
+ param_schema = JsonSchema::Schema.new
18
+
19
+ param_schema.type = [param_data["type"]]
20
+
21
+ link_schema.properties[param_data["name"]] = param_schema
22
+ if param_data["required"] == true
23
+ link_schema.required << param_data["name"]
24
+ end
25
+ end
26
+
27
+ link_schema
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Committee
4
+ module Drivers
5
+ module OpenAPI2
6
+ # Link abstracts an API link specifically for OpenAPI 2.
7
+ class Link
8
+ # The link's input media type. i.e. How requests should be encoded.
9
+ attr_accessor :enc_type
10
+
11
+ attr_accessor :href
12
+
13
+ # The link's output media type. i.e. How responses should be encoded.
14
+ attr_accessor :media_type
15
+
16
+ attr_accessor :method
17
+
18
+ # The link's input schema. i.e. How we validate an endpoint's incoming
19
+ # parameters.
20
+ attr_accessor :schema
21
+
22
+ attr_accessor :status_success
23
+
24
+ # The link's output schema. i.e. How we validate an endpoint's response
25
+ # data.
26
+ attr_accessor :target_schema
27
+
28
+ attr_accessor :header_schema
29
+
30
+ def rel
31
+ raise "Committee: rel not implemented for OpenAPI"
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Committee
4
+ module Drivers
5
+ module OpenAPI2
6
+ # ParameterSchemaBuilder converts OpenAPI 2 link parameters, which are not
7
+ # quite JSON schemas (but will be in OpenAPI 3) into synthetic schemas that
8
+ # we can use to do some basic request validation.
9
+ class ParameterSchemaBuilder < SchemaBuilder
10
+ # Returns a tuple of (schema, schema_data) where only one of the two
11
+ # values is present. This is either a full schema that's ready to go _or_
12
+ # a hash of unparsed schema data.
13
+ def call
14
+ if link_data["parameters"]
15
+ body_param = link_data["parameters"].detect { |p| p["in"] == "body" }
16
+ if body_param
17
+ check_required_fields!(body_param)
18
+
19
+ if link_data["parameters"].detect { |p| p["in"] == "form" } != nil
20
+ raise ArgumentError, "Committee: can't mix body parameter " \
21
+ "with form parameters."
22
+ end
23
+
24
+ schema_data = body_param["schema"]
25
+ [nil, schema_data]
26
+ else
27
+ link_schema = JsonSchema::Schema.new
28
+ link_schema.properties = {}
29
+ link_schema.required = []
30
+
31
+ parameters = link_data["parameters"].reject { |param_data| param_data["in"] == "header" }
32
+ parameters.each do |param_data|
33
+ check_required_fields!(param_data)
34
+
35
+ param_schema = JsonSchema::Schema.new
36
+
37
+ # We could probably use more validation here, but the formats of
38
+ # OpenAPI 2 are based off of what's available in JSON schema, and
39
+ # therefore this should map over quite well.
40
+ param_schema.type = [param_data["type"]]
41
+
42
+ param_schema.enum = param_data["enum"] unless param_data["enum"].nil?
43
+
44
+ # validation: string
45
+ param_schema.format = param_data["format"] unless param_data["format"].nil?
46
+ param_schema.pattern = Regexp.new(param_data["pattern"]) unless param_data["pattern"].nil?
47
+ param_schema.min_length = param_data["minLength"] unless param_data["minLength"].nil?
48
+ param_schema.max_length = param_data["maxLength"] unless param_data["maxLength"].nil?
49
+
50
+ # validation: array
51
+ param_schema.min_items = param_data["minItems"] unless param_data["minItems"].nil?
52
+ param_schema.max_items = param_data["maxItems"] unless param_data["maxItems"].nil?
53
+ param_schema.unique_items = param_data["uniqueItems"] unless param_data["uniqueItems"].nil?
54
+
55
+ # validation: number/integer
56
+ param_schema.min = param_data["minimum"] unless param_data["minimum"].nil?
57
+ param_schema.min_exclusive = param_data["exclusiveMinimum"] unless param_data["exclusiveMinimum"].nil?
58
+ param_schema.max = param_data["maximum"] unless param_data["maximum"].nil?
59
+ param_schema.max_exclusive = param_data["exclusiveMaximum"] unless param_data["exclusiveMaximum"].nil?
60
+ param_schema.multiple_of = param_data["multipleOf"] unless param_data["multipleOf"].nil?
61
+
62
+ # And same idea: despite parameters not being schemas, the items
63
+ # key (if preset) is actually a schema that defines each item of an
64
+ # array type, so we can just reflect that directly onto our
65
+ # artifical schema.
66
+ if param_data["type"] == "array" && param_data["items"]
67
+ param_schema.items = param_data["items"]
68
+ end
69
+
70
+ link_schema.properties[param_data["name"]] = param_schema
71
+ if param_data["required"] == true
72
+ link_schema.required << param_data["name"]
73
+ end
74
+ end
75
+
76
+ [link_schema, nil]
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Committee
4
+ module Drivers
5
+ module OpenAPI2
6
+ class Schema < ::Committee::Drivers::Schema
7
+ attr_accessor :base_path
8
+ attr_accessor :consumes
9
+
10
+ # A link back to the derivative instance of Committee::Drivers::Driver
11
+ # that create this schema.
12
+ attr_accessor :driver
13
+
14
+ attr_accessor :definitions
15
+ attr_accessor :produces
16
+ attr_accessor :routes
17
+ attr_reader :validator_option
18
+
19
+ def build_router(options)
20
+ @validator_option = Committee::SchemaValidator::Option.new(options, self, :hyper_schema)
21
+ Committee::SchemaValidator::HyperSchema::Router.new(self, @validator_option)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Committee
4
+ module Drivers
5
+ module OpenAPI2
6
+ class SchemaBuilder
7
+ def initialize(link_data)
8
+ @link_data = link_data
9
+ end
10
+
11
+ private
12
+
13
+ LINK_REQUIRED_FIELDS = [
14
+ :name
15
+ ].map(&:to_s).freeze
16
+
17
+ attr_accessor :link_data
18
+
19
+ def check_required_fields!(param_data)
20
+ LINK_REQUIRED_FIELDS.each do |field|
21
+ if !param_data[field]
22
+ raise ArgumentError,
23
+ "Committee: no #{field} section in link data."
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ require_relative 'header_schema_builder'
33
+ require_relative 'parameter_schema_builder'