committee 3.1.0 → 3.1.1

Sign up to get free protection for your applications and to get access to all the features.
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'