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