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,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Committee
4
+ module Drivers
5
+ module HyperSchema
6
+ # Link abstracts an API link specifically for JSON hyper-schema.
7
+ #
8
+ # For most operations, it's a simple pass through to a
9
+ # JsonSchema::Schema::Link, but implements some exotic behavior in a few
10
+ # places.
11
+ class Link
12
+ def initialize(hyper_schema_link)
13
+ @hyper_schema_link = hyper_schema_link
14
+ end
15
+
16
+ # The link's input media type. i.e. How requests should be encoded.
17
+ def enc_type
18
+ hyper_schema_link.enc_type
19
+ end
20
+
21
+ def href
22
+ hyper_schema_link.href
23
+ end
24
+
25
+ # The link's output media type. i.e. How responses should be encoded.
26
+ def media_type
27
+ hyper_schema_link.media_type
28
+ end
29
+
30
+ def method
31
+ hyper_schema_link.method
32
+ end
33
+
34
+ # Passes through a link's parent resource. Note that this is *not* part
35
+ # of the Link interface and is here to support a legacy Heroku-ism
36
+ # behavior that allowed a link tagged with rel=instances to imply that a
37
+ # list will be returned.
38
+ def parent
39
+ hyper_schema_link.parent
40
+ end
41
+
42
+ def rel
43
+ hyper_schema_link.rel
44
+ end
45
+
46
+ # The link's input schema. i.e. How we validate an endpoint's incoming
47
+ # parameters.
48
+ def schema
49
+ hyper_schema_link.schema
50
+ end
51
+
52
+ def status_success
53
+ hyper_schema_link.rel == "create" ? 201 : 200
54
+ end
55
+
56
+ # The link's output schema. i.e. How we validate an endpoint's response
57
+ # data.
58
+ def target_schema
59
+ hyper_schema_link.target_schema
60
+ end
61
+
62
+ private
63
+
64
+ attr_accessor :hyper_schema_link
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Committee
4
+ module Drivers
5
+ module HyperSchema
6
+ class Schema < ::Committee::Drivers::Schema
7
+ # A link back to the derivative instance of Committee::Drivers::Driver
8
+ # that create this schema.
9
+ attr_accessor :driver
10
+
11
+ attr_accessor :routes
12
+
13
+ attr_reader :validator_option
14
+
15
+ def build_router(options)
16
+ @validator_option = Committee::SchemaValidator::Option.new(options, self, :hyper_schema)
17
+ Committee::SchemaValidator::HyperSchema::Router.new(self, @validator_option)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -1,420 +1,13 @@
1
- module Committee::Drivers
2
- class OpenAPI2 < Committee::Drivers::Driver
3
- def default_coerce_date_times
4
- false
5
- end
6
-
7
- # Whether parameters that were form-encoded will be coerced by default.
8
- def default_coerce_form_params
9
- true
10
- end
11
-
12
- def default_allow_get_body
13
- true
14
- end
15
-
16
- # Whether parameters in a request's path will be considered and coerced by
17
- # default.
18
- def default_path_params
19
- true
20
- end
21
-
22
- # Whether parameters in a request's query string will be considered and
23
- # coerced by default.
24
- def default_query_params
25
- true
26
- end
27
-
28
- def default_validate_success_only
29
- true
30
- end
31
-
32
- def name
33
- :open_api_2
34
- end
35
-
36
- # Parses an API schema and builds a set of route definitions for use with
37
- # Committee.
38
- #
39
- # The expected input format is a data hash with keys as strings (as opposed
40
- # to symbols) like the kind produced by JSON.parse or YAML.load.
41
- def parse(data)
42
- REQUIRED_FIELDS.each do |field|
43
- if !data[field]
44
- raise ArgumentError, "Committee: no #{field} section in spec data."
45
- end
46
- end
47
-
48
- if data['swagger'] != '2.0'
49
- raise ArgumentError, "Committee: driver requires OpenAPI 2.0."
50
- end
51
-
52
- schema = Schema.new
53
- schema.driver = self
54
-
55
- schema.base_path = data['basePath'] || ''
56
-
57
- # Arbitrarily choose the first media type found in these arrays. This
58
- # appraoch could probably stand to be improved, but at least users will
59
- # for now have the option of turning media type validation off if they so
60
- # choose.
61
- schema.consumes = data['consumes'].first
62
- schema.produces = data['produces'].first
63
-
64
- schema.definitions, store = parse_definitions!(data)
65
- schema.routes = parse_routes!(data, schema, store)
66
-
67
- schema
68
- end
69
-
70
- def schema_class
71
- Committee::Drivers::OpenAPI2::Schema
72
- end
73
-
74
- # Link abstracts an API link specifically for OpenAPI 2.
75
- class Link
76
- # The link's input media type. i.e. How requests should be encoded.
77
- attr_accessor :enc_type
78
-
79
- attr_accessor :href
80
-
81
- # The link's output media type. i.e. How responses should be encoded.
82
- attr_accessor :media_type
83
-
84
- attr_accessor :method
85
-
86
- # The link's input schema. i.e. How we validate an endpoint's incoming
87
- # parameters.
88
- attr_accessor :schema
89
-
90
- attr_accessor :status_success
91
-
92
- # The link's output schema. i.e. How we validate an endpoint's response
93
- # data.
94
- attr_accessor :target_schema
95
-
96
- attr_accessor :header_schema
97
-
98
- def rel
99
- raise "Committee: rel not implemented for OpenAPI"
100
- end
101
- end
102
-
103
- class SchemaBuilder
104
- def initialize(link_data)
105
- self.link_data = link_data
106
- end
107
-
108
- private
109
-
110
- LINK_REQUIRED_FIELDS = [
111
- :name
112
- ].map(&:to_s).freeze
113
-
114
- attr_accessor :link_data
115
-
116
- def check_required_fields!(param_data)
117
- LINK_REQUIRED_FIELDS.each do |field|
118
- if !param_data[field]
119
- raise ArgumentError,
120
- "Committee: no #{field} section in link data."
121
- end
122
- end
123
- end
124
- end
125
-
126
- class HeaderSchemaBuilder < SchemaBuilder
127
- def call
128
- if link_data["parameters"]
129
- link_schema = JsonSchema::Schema.new
130
- link_schema.properties = {}
131
- link_schema.required = []
132
-
133
- header_parameters = link_data["parameters"].select { |param_data| param_data["in"] == "header" }
134
- header_parameters.each do |param_data|
135
- check_required_fields!(param_data)
136
-
137
- param_schema = JsonSchema::Schema.new
138
-
139
- param_schema.type = [param_data["type"]]
140
-
141
- link_schema.properties[param_data["name"]] = param_schema
142
- if param_data["required"] == true
143
- link_schema.required << param_data["name"]
144
- end
145
- end
146
-
147
- link_schema
148
- end
149
- end
150
- end
151
-
152
- # ParameterSchemaBuilder converts OpenAPI 2 link parameters, which are not
153
- # quite JSON schemas (but will be in OpenAPI 3) into synthetic schemas that
154
- # we can use to do some basic request validation.
155
- class ParameterSchemaBuilder < SchemaBuilder
156
- # Returns a tuple of (schema, schema_data) where only one of the two
157
- # values is present. This is either a full schema that's ready to go _or_
158
- # a hash of unparsed schema data.
159
- def call
160
- if link_data["parameters"]
161
- body_param = link_data["parameters"].detect { |p| p["in"] == "body" }
162
- if body_param
163
- check_required_fields!(body_param)
164
-
165
- if link_data["parameters"].detect { |p| p["in"] == "form" } != nil
166
- raise ArgumentError, "Committee: can't mix body parameter " \
167
- "with form parameters."
168
- end
1
+ # frozen_string_literal: true
169
2
 
170
- schema_data = body_param["schema"]
171
- [nil, schema_data]
172
- else
173
- link_schema = JsonSchema::Schema.new
174
- link_schema.properties = {}
175
- link_schema.required = []
176
-
177
- parameters = link_data["parameters"].reject { |param_data| param_data["in"] == "header" }
178
- parameters.each do |param_data|
179
- check_required_fields!(param_data)
180
-
181
- param_schema = JsonSchema::Schema.new
182
-
183
- # We could probably use more validation here, but the formats of
184
- # OpenAPI 2 are based off of what's available in JSON schema, and
185
- # therefore this should map over quite well.
186
- param_schema.type = [param_data["type"]]
187
-
188
- param_schema.enum = param_data["enum"] unless param_data["enum"].nil?
189
-
190
- # validation: string
191
- param_schema.format = param_data["format"] unless param_data["format"].nil?
192
- param_schema.pattern = Regexp.new(param_data["pattern"]) unless param_data["pattern"].nil?
193
- param_schema.min_length = param_data["minLength"] unless param_data["minLength"].nil?
194
- param_schema.max_length = param_data["maxLength"] unless param_data["maxLength"].nil?
195
-
196
- # validation: array
197
- param_schema.min_items = param_data["minItems"] unless param_data["minItems"].nil?
198
- param_schema.max_items = param_data["maxItems"] unless param_data["maxItems"].nil?
199
- param_schema.unique_items = param_data["uniqueItems"] unless param_data["uniqueItems"].nil?
200
-
201
- # validation: number/integer
202
- param_schema.min = param_data["minimum"] unless param_data["minimum"].nil?
203
- param_schema.min_exclusive = param_data["exclusiveMinimum"] unless param_data["exclusiveMinimum"].nil?
204
- param_schema.max = param_data["maximum"] unless param_data["maximum"].nil?
205
- param_schema.max_exclusive = param_data["exclusiveMaximum"] unless param_data["exclusiveMaximum"].nil?
206
- param_schema.multiple_of = param_data["multipleOf"] unless param_data["multipleOf"].nil?
207
-
208
- # And same idea: despite parameters not being schemas, the items
209
- # key (if preset) is actually a schema that defines each item of an
210
- # array type, so we can just reflect that directly onto our
211
- # artifical schema.
212
- if param_data["type"] == "array" && param_data["items"]
213
- param_schema.items = param_data["items"]
214
- end
215
-
216
- link_schema.properties[param_data["name"]] = param_schema
217
- if param_data["required"] == true
218
- link_schema.required << param_data["name"]
219
- end
220
- end
221
-
222
- [link_schema, nil]
223
- end
224
- end
225
- end
226
- end
227
-
228
- class Schema < Committee::Drivers::Schema
229
- attr_accessor :base_path
230
- attr_accessor :consumes
231
-
232
- # A link back to the derivative instace of Committee::Drivers::Driver
233
- # that create this schema.
234
- attr_accessor :driver
235
-
236
- attr_accessor :definitions
237
- attr_accessor :produces
238
- attr_accessor :routes
239
- attr_reader :validator_option
240
-
241
- def build_router(options)
242
- @validator_option = Committee::SchemaValidator::Option.new(options, self, :hyper_schema)
243
- Committee::SchemaValidator::HyperSchema::Router.new(self, @validator_option)
244
- end
245
- end
246
-
247
- private
248
-
249
- DEFINITIONS_PSEUDO_URI = "http://json-schema.org/committee-definitions"
250
-
251
- # These are fields that the OpenAPI 2 spec considers mandatory to be
252
- # included in the document's top level.
253
- REQUIRED_FIELDS = [
254
- :consumes,
255
- :definitions,
256
- :paths,
257
- :produces,
258
- :swagger,
259
- ].map(&:to_s).freeze
260
-
261
- def find_best_fit_response(link_data)
262
- if response_data = link_data["responses"]["200"] || response_data = link_data["responses"][200]
263
- [200, response_data]
264
- elsif response_data = link_data["responses"]["201"] || response_data = link_data["responses"][201]
265
- [201, response_data]
266
- else
267
- # Sort responses so that we can try to prefer any 3-digit status code.
268
- # If there are none, we'll just take anything from the list.
269
- ordered_responses = link_data["responses"].
270
- select { |k, v| k.to_s =~ /[0-9]{3}/ }
271
- if first = ordered_responses.first
272
- [first[0].to_i, first[1]]
273
- else
274
- [nil, nil]
275
- end
276
- end
277
- end
278
-
279
- def href_to_regex(href)
280
- href.gsub(/\{(.*?)\}/, '(?<\1>[^/]+)')
281
- end
282
-
283
- def parse_definitions!(data)
284
- # The "definitions" section of an OpenAPI 2 spec is a valid JSON schema.
285
- # We extract it from the spec and parse it as a schema in isolation so
286
- # that all references to it will still have correct paths (i.e. we can
287
- # still find a resource at '#/definitions/resource' instead of
288
- # '#/resource').
289
- schema = JsonSchema.parse!({
290
- "definitions" => data['definitions'],
291
- })
292
- schema.expand_references!
293
- schema.uri = DEFINITIONS_PSEUDO_URI
294
-
295
- # So this is a little weird: an OpenAPI specification is _not_ a valid
296
- # JSON schema and yet it self-references like it is a valid JSON schema.
297
- # To work around this what we do is parse its "definitions" section as a
298
- # JSON schema and then build a document store here containing that. When
299
- # trying to resolve a reference from elsewhere in the spec, we build a
300
- # synthetic schema with a JSON reference to the document created from
301
- # "definitions" and then expand references against this store.
302
- store = JsonSchema::DocumentStore.new
303
- store.add_schema(schema)
304
-
305
- [schema, store]
306
- end
307
-
308
- def parse_routes!(data, schema, store)
309
- routes = {}
310
-
311
- # This is a performance optimization: instead of going through each link
312
- # and parsing out its JSON schema separately, instead we just aggregate
313
- # all schemas into one big hash and then parse it all at the end. After
314
- # we parse it, go through each link and assign a proper schema object. In
315
- # practice this comes out to somewhere on the order of 50x faster.
316
- schemas_data = { "properties" => {} }
317
-
318
- # Exactly the same idea, but for response schemas.
319
- target_schemas_data = { "properties" => {} }
320
-
321
- data['paths'].each do |path, methods|
322
- href = schema.base_path + path
323
- schemas_data["properties"][href] = { "properties" => {} }
324
- target_schemas_data["properties"][href] = { "properties" => {} }
325
-
326
- methods.each do |method, link_data|
327
- method = method.upcase
328
-
329
- link = Link.new
330
- link.enc_type = schema.consumes
331
- link.href = href
332
- link.media_type = schema.produces
333
- link.method = method
334
-
335
- # Convert the spec's parameter pseudo-schemas into JSON schemas that
336
- # we can use for some basic request validation.
337
- link.schema, schema_data = ParameterSchemaBuilder.new(link_data).call
338
- link.header_schema = HeaderSchemaBuilder.new(link_data).call
339
-
340
- # If data came back instead of a schema (this occurs when a route has
341
- # a single `body` parameter instead of a collection of URL/query/form
342
- # parameters), store it for later parsing.
343
- if schema_data
344
- schemas_data["properties"][href]["properties"][method] = schema_data
345
- end
346
-
347
- # Arbitrarily pick one response for the time being. Prefers in order:
348
- # a 200, 201, any 3-digit numerical response, then anything at all.
349
- status, response_data = find_best_fit_response(link_data)
350
- if status
351
- link.status_success = status
352
-
353
- # A link need not necessarily specify a target schema.
354
- if response_data["schema"]
355
- target_schemas_data["properties"][href]["properties"][method] =
356
- response_data["schema"]
357
- end
358
- end
359
-
360
- rx = %r{^#{href_to_regex(link.href)}$}
361
- Committee.log_debug "Created route: #{link.method} #{link.href} (regex #{rx})"
362
-
363
- routes[method] ||= []
364
- routes[method] << [rx, link]
365
- end
366
- end
367
-
368
- # See the note on our DocumentStore's initialization in
369
- # #parse_definitions!, but what we're doing here is prefixing references
370
- # with a specialized internal URI so that they can reference definitions
371
- # from another document in the store.
372
- schemas =
373
- rewrite_references_and_parse(schemas_data, store)
374
- target_schemas =
375
- rewrite_references_and_parse(target_schemas_data, store)
376
-
377
- # As noted above, now that we've parsed our aggregate response schema, go
378
- # back through each link and them their response schema.
379
- routes.each do |method, method_routes|
380
- method_routes.each do |(_, link)|
381
- # request
382
- #
383
- # Differs slightly from responses in that the schema may already have
384
- # been set for endpoints with non-body parameters, so check for nil
385
- # before we set it.
386
- if schema = schemas.properties[link.href].properties[method]
387
- link.schema = schema
388
- end
389
-
390
- # response
391
- link.target_schema =
392
- target_schemas.properties[link.href].properties[method]
393
- end
394
- end
395
-
396
- routes
397
- end
398
-
399
- def rewrite_references_and_parse(schemas_data, store)
400
- schemas = rewrite_references(schemas_data)
401
- schemas = JsonSchema.parse!(schemas_data)
402
- schemas.expand_references!(:store => store)
403
- schemas
404
- end
405
-
406
- def rewrite_references(schema)
407
- if schema.is_a?(Hash)
408
- ref = schema["$ref"]
409
- if ref && ref.is_a?(String) && ref[0] == "#"
410
- schema["$ref"] = DEFINITIONS_PSEUDO_URI + ref
411
- else
412
- schema.each do |_, v|
413
- rewrite_references(v)
414
- end
415
- end
416
- end
417
- schema
3
+ module Committee
4
+ module Drivers
5
+ module OpenAPI2
418
6
  end
419
7
  end
420
8
  end
9
+
10
+ require_relative 'open_api_2/driver'
11
+ require_relative 'open_api_2/link'
12
+ require_relative 'open_api_2/schema'
13
+ require_relative 'open_api_2/schema_builder'