committee 1.15.0 → 2.0.0.pre

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/bin/committee-stub +10 -36
  3. data/lib/committee/bin/committee_stub.rb +61 -0
  4. data/lib/committee/drivers/hyper_schema.rb +151 -0
  5. data/lib/committee/drivers/open_api_2.rb +297 -0
  6. data/lib/committee/drivers.rb +57 -0
  7. data/lib/committee/middleware/base.rb +52 -13
  8. data/lib/committee/middleware/request_validation.rb +33 -10
  9. data/lib/committee/middleware/response_validation.rb +2 -1
  10. data/lib/committee/middleware/stub.rb +24 -8
  11. data/lib/committee/request_validator.rb +1 -1
  12. data/lib/committee/response_generator.rb +58 -13
  13. data/lib/committee/response_validator.rb +32 -8
  14. data/lib/committee/router.rb +5 -33
  15. data/lib/committee/{query_params_coercer.rb → string_params_coercer.rb} +11 -6
  16. data/lib/committee/test/methods.rb +49 -12
  17. data/lib/committee.rb +15 -1
  18. data/test/bin/committee_stub_test.rb +45 -0
  19. data/test/bin_test.rb +20 -0
  20. data/test/committee_test.rb +49 -0
  21. data/test/drivers/hyper_schema_test.rb +95 -0
  22. data/test/drivers/open_api_2_test.rb +255 -0
  23. data/test/drivers_test.rb +60 -0
  24. data/test/middleware/base_test.rb +49 -5
  25. data/test/middleware/request_validation_test.rb +39 -25
  26. data/test/middleware/response_validation_test.rb +32 -20
  27. data/test/middleware/stub_test.rb +50 -19
  28. data/test/request_unpacker_test.rb +10 -0
  29. data/test/request_validator_test.rb +4 -3
  30. data/test/response_generator_test.rb +50 -6
  31. data/test/response_validator_test.rb +29 -4
  32. data/test/router_test.rb +40 -13
  33. data/test/{query_params_coercer_test.rb → string_params_coercer_test.rb} +3 -4
  34. data/test/test/methods_test.rb +44 -5
  35. data/test/test_helper.rb +59 -1
  36. metadata +62 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: d5fcb82a192f894e1baec1dcab89f081782d222f
4
- data.tar.gz: 54d056b8be5b297b6a524166363f955db7a1bd15
3
+ metadata.gz: 5911eb64b8a07bb45eaf264399bebeb95ea2395b
4
+ data.tar.gz: 35d800d2dd5cd0c3d402b26813d7b156f0809006
5
5
  SHA512:
6
- metadata.gz: f22924efd2696d884eb499978cdb2a9f6e81ead9d25dacbe3ea331b9fb4d771886a4ccea1926fe2d09256886433a451fdbe25ef47cbe8d4c80a07a4157e0b9bc
7
- data.tar.gz: db8d1aa50e4f4fa854bda1d5484df09e7262801bd845195c1340a718fc6d0792666360698ea2fe8722f1c57a287fcfb4b1f0258706032f47a2ef7cceaffd7df9
6
+ metadata.gz: 86c361cc0bd1f4d0e340335402ab452b99a0c0d0eae814aa3296794ed0a076177f9dc3ebc772ae95a66afbe45efcbeccc77e2851ef3a0e385171fc549e31092c
7
+ data.tar.gz: 306a67c72463d3879dd7fb4e9aff88a17d34e54d83c209a439cabc47b912027178c5d23fc9b5270cc6b0f9d65d4957f152ec630c7e3484931bba1bda46ed4bd0
data/bin/committee-stub CHANGED
@@ -6,45 +6,19 @@ require 'yaml'
6
6
  require_relative "../lib/committee"
7
7
 
8
8
  args = ARGV.dup
9
- options = { port: 9292, tolerant: false }
10
- opt_parser = OptionParser.new do |opts|
11
- opts.banner = "Usage: rackup [options] [JSON Schema file]"
9
+ bin = Committee::Bin::CommitteeStub.new
10
+ options, parser = bin.get_options_parser
12
11
 
13
- opts.separator ""
14
- opts.separator "Options:"
12
+ if $0 == __FILE__
13
+ parser.parse!(args)
15
14
 
16
- opts.on_tail("-h", "-?", "--help", "Show this message") {
17
- puts opts
15
+ unless args.count == 1 || options[:help]
16
+ puts parser.to_s
18
17
  exit
19
- }
20
-
21
- opts.on("-t", "--tolerant", "don't perform request/response validations") {
22
- options[:tolerant] = true
23
- }
24
-
25
- opts.on("-p", "--port PORT", "use PORT (default: 9292)") { |port|
26
- options[:port] = port
27
- }
28
- end
29
- opt_parser.parse!(args)
30
-
31
- unless args.count == 1
32
- puts opt_parser.to_s
33
- exit
34
- end
35
-
36
- schema = ::YAML.load(File.read(args[0]))
37
-
38
- app = Rack::Builder.new {
39
- unless options[:tolerant]
40
- use Committee::Middleware::RequestValidation, schema: schema
41
- use Committee::Middleware::ResponseValidation, schema: schema
42
18
  end
43
19
 
44
- use Committee::Middleware::Stub, schema: schema
45
- run lambda { |_|
46
- [404, {}, ["Not found"]]
47
- }
48
- }
20
+ driver = Committee::Drivers.driver_from_name(options[:driver])
21
+ schema = driver.parse(::YAML.load(File.read(args[0])))
49
22
 
50
- Rack::Server.start(app: app, Port: options[:port])
23
+ Rack::Server.start(app: bin.get_app(schema, options), Port: options[:port])
24
+ end
@@ -0,0 +1,61 @@
1
+ module Committee
2
+ module Bin
3
+ # CommitteeStub internalizes the functionality of bin/committee-stub so
4
+ # that we can test code that would otherwise be difficult to get at in an
5
+ # executable.
6
+ class CommitteeStub
7
+ # Gets a Rack app suitable for use as a stub.
8
+ def get_app(schema, options)
9
+ Rack::Builder.new {
10
+ unless options[:tolerant]
11
+ use Committee::Middleware::RequestValidation, schema: schema
12
+ use Committee::Middleware::ResponseValidation, schema: schema
13
+ end
14
+
15
+ use Committee::Middleware::Stub, schema: schema
16
+
17
+ run lambda { |_|
18
+ [404, {}, ["Not found"]]
19
+ }
20
+ }
21
+ end
22
+
23
+ # Gets an option parser for command line arguments.
24
+ def get_options_parser
25
+ options = {
26
+ :driver => :hyper_schema,
27
+ :help => false,
28
+ :port => 9292,
29
+ :tolerant => false,
30
+ }
31
+
32
+ parser = OptionParser.new do |opts|
33
+ opts.banner = "Usage: rackup [options] [JSON Schema file]"
34
+
35
+ opts.separator ""
36
+ opts.separator "Options:"
37
+
38
+ opts.on_tail("-h", "-?", "--help", "Show this message") {
39
+ options[:help] = true
40
+ }
41
+
42
+ opts.on("-d", "--driver NAME", "name of driver [open_api_2|hyper_schema]") { |name|
43
+ options[:driver] = name.to_sym
44
+ }
45
+
46
+ opts.on("-t", "--tolerant", "don't perform request/response validations") {
47
+ options[:tolerant] = true
48
+ }
49
+
50
+ opts.on("-p", "--port PORT", "use PORT (default: 9292)") { |port|
51
+ options[:port] = port
52
+ }
53
+ end
54
+ [options, parser]
55
+ end
56
+
57
+ def get_schema(driver_name, data)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,151 @@
1
+ module Committee::Drivers
2
+ class HyperSchema < Committee::Drivers::Driver
3
+ # Whether parameters in a request's path will be considered and coerced by
4
+ # default.
5
+ def default_path_params
6
+ false
7
+ end
8
+
9
+ # Whether parameters in a request's query string will be considered and
10
+ # coerced by default.
11
+ def default_query_params
12
+ false
13
+ end
14
+
15
+ def name
16
+ :hyper_schema
17
+ end
18
+
19
+ # Parses an API schema and builds a set of route definitions for use with
20
+ # Committee.
21
+ #
22
+ # The expected input format is a data hash with keys as strings (as opposed
23
+ # to symbols) like the kind produced by JSON.parse or YAML.load.
24
+ def parse(schema)
25
+ # Really we'd like to only have data hashes passed into drivers these
26
+ # days, but here we handle a JsonSchema::Schema for now to maintain
27
+ # backward compatibility (this library used to be hyper-schema only).
28
+ if schema.is_a?(JsonSchema::Schema)
29
+ hyper_schema = schema
30
+ else
31
+ hyper_schema = JsonSchema.parse!(schema)
32
+ hyper_schema.expand_references!
33
+ end
34
+
35
+ schema = Schema.new
36
+ schema.driver = self
37
+ schema.routes = build_routes(hyper_schema)
38
+ schema
39
+ end
40
+
41
+ def schema_class
42
+ Committee::Drivers::HyperSchema::Schema
43
+ end
44
+
45
+ # Link abstracts an API link specifically for JSON hyper-schema.
46
+ #
47
+ # For most operations, it's a simple pass through to a
48
+ # JsonSchema::Schema::Link, but implements some exotic behavior in a few
49
+ # places.
50
+ class Link
51
+ def initialize(hyper_schema_link)
52
+ self.hyper_schema_link = hyper_schema_link
53
+ end
54
+
55
+ # The link's input media type. i.e. How requests should be encoded.
56
+ def enc_type
57
+ hyper_schema_link.enc_type
58
+ end
59
+
60
+ def href
61
+ hyper_schema_link.href
62
+ end
63
+
64
+ # The link's output media type. i.e. How responses should be encoded.
65
+ def media_type
66
+ hyper_schema_link.media_type
67
+ end
68
+
69
+ def method
70
+ hyper_schema_link.method
71
+ end
72
+
73
+ # Passes through a link's parent resource. Note that this is *not* part
74
+ # of the Link interface and is here to support a legacy Heroku-ism
75
+ # behavior that allowed a link tagged with rel=instances to imply that a
76
+ # list will be returned.
77
+ def parent
78
+ hyper_schema_link.parent
79
+ end
80
+
81
+ def rel
82
+ hyper_schema_link.rel
83
+ end
84
+
85
+ # The link's input schema. i.e. How we validate an endpoint's incoming
86
+ # parameters.
87
+ def schema
88
+ hyper_schema_link.schema
89
+ end
90
+
91
+ def status_success
92
+ hyper_schema_link.rel == "create" ? 201 : 200
93
+ end
94
+
95
+ # The link's output schema. i.e. How we validate an endpoint's response
96
+ # data.
97
+ def target_schema
98
+ hyper_schema_link.target_schema
99
+ end
100
+
101
+ private
102
+
103
+ attr_accessor :hyper_schema_link
104
+ end
105
+
106
+ class Schema < Committee::Drivers::Schema
107
+ # A link back to the derivative instace of Committee::Drivers::Driver
108
+ # that create this schema.
109
+ attr_accessor :driver
110
+
111
+ attr_accessor :routes
112
+ end
113
+
114
+ private
115
+
116
+ def build_routes(hyper_schema)
117
+ routes = {}
118
+
119
+ hyper_schema.links.each do |link|
120
+ method, href = parse_link(link)
121
+ next unless method
122
+
123
+ rx = %r{^#{href}$}
124
+ Committee.log_debug "Created route: #{method} #{href} (regex #{rx})"
125
+
126
+ routes[method] ||= []
127
+ routes[method] << [rx, Link.new(link)]
128
+ end
129
+
130
+ # recursively iterate through all `properties` subschemas to build a
131
+ # complete routing table
132
+ hyper_schema.properties.each do |_, subschema|
133
+ routes.merge!(build_routes(subschema)) { |_, r1, r2| r1 + r2 }
134
+ end
135
+
136
+ routes
137
+ end
138
+
139
+ def href_to_regex(href)
140
+ href.gsub(/\{(.*?)\}/, "[^/]+")
141
+ end
142
+
143
+ def parse_link(link)
144
+ return nil, nil if !link.method || !link.href
145
+ method = link.method.to_s.upcase
146
+ # /apps/{id} --> /apps/([^/]+)
147
+ href = href_to_regex(link.href)
148
+ [method, href]
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,297 @@
1
+ module Committee::Drivers
2
+ class OpenAPI2 < Committee::Drivers::Driver
3
+ # Whether parameters in a request's path will be considered and coerced by
4
+ # default.
5
+ def default_path_params
6
+ true
7
+ end
8
+
9
+ # Whether parameters in a request's query string will be considered and
10
+ # coerced by default.
11
+ def default_query_params
12
+ true
13
+ end
14
+
15
+ def name
16
+ :open_api_2
17
+ end
18
+
19
+ # Parses an API schema and builds a set of route definitions for use with
20
+ # Committee.
21
+ #
22
+ # The expected input format is a data hash with keys as strings (as opposed
23
+ # to symbols) like the kind produced by JSON.parse or YAML.load.
24
+ def parse(data)
25
+ REQUIRED_FIELDS.each do |field|
26
+ if !data[field]
27
+ raise ArgumentError, "Committee: no #{field} section in spec data."
28
+ end
29
+ end
30
+
31
+ if data['swagger'] != '2.0'
32
+ raise ArgumentError, "Committee: driver requires OpenAPI 2.0."
33
+ end
34
+
35
+ schema = Schema.new
36
+ schema.driver = self
37
+
38
+ schema.base_path = data['basePath'] || ''
39
+
40
+ # Arbitrarily choose the first media type found in these arrays. This
41
+ # appraoch could probably stand to be improved, but at least users will
42
+ # for now have the option of turning media type validation off if they so
43
+ # choose.
44
+ schema.consumes = data['consumes'].first
45
+ schema.produces = data['produces'].first
46
+
47
+ schema.definitions, store = parse_definitions!(data)
48
+ schema.routes = parse_routes!(data, schema, store)
49
+
50
+ schema
51
+ end
52
+
53
+ def schema_class
54
+ Committee::Drivers::OpenAPI2::Schema
55
+ end
56
+
57
+ # Link abstracts an API link specifically for OpenAPI 2.
58
+ class Link
59
+ # The link's input media type. i.e. How requests should be encoded.
60
+ attr_accessor :enc_type
61
+
62
+ attr_accessor :href
63
+
64
+ # The link's output media type. i.e. How responses should be encoded.
65
+ attr_accessor :media_type
66
+
67
+ attr_accessor :method
68
+
69
+ # The link's input schema. i.e. How we validate an endpoint's incoming
70
+ # parameters.
71
+ attr_accessor :schema
72
+
73
+ attr_accessor :status_success
74
+
75
+ # The link's output schema. i.e. How we validate an endpoint's response
76
+ # data.
77
+ attr_accessor :target_schema
78
+
79
+ def rel
80
+ raise "Committee: rel not implemented for OpenAPI"
81
+ end
82
+ end
83
+
84
+ # ParameterSchemaBuilder converts OpenAPI 2 link parameters, which are not
85
+ # quite JSON schemas (but will be in OpenAPI 3) into synthetic schemas that
86
+ # we can use to do some basic request validation.
87
+ class ParameterSchemaBuilder
88
+ def initialize(link_data)
89
+ self.link_data = link_data
90
+ end
91
+
92
+ def call
93
+ link_schema = JsonSchema::Schema.new
94
+ link_schema.properties = {}
95
+ link_schema.required = []
96
+
97
+ if link_data["parameters"]
98
+ link_data["parameters"].each do |param_data|
99
+ LINK_REQUIRED_FIELDS.each do |field|
100
+ if !param_data[field]
101
+ raise ArgumentError,
102
+ "Committee: no #{field} section in link data."
103
+ end
104
+ end
105
+
106
+ param_schema = JsonSchema::Schema.new
107
+
108
+ # We could probably use more validation here, but the formats of
109
+ # OpenAPI 2 are based off of what's available in JSON schema, and
110
+ # therefore this should map over quite well.
111
+ param_schema.type = [param_data["type"]]
112
+
113
+ # And same idea: despite parameters not being schemas, the items
114
+ # key (if preset) is actually a schema that defines each item of an
115
+ # array type, so we can just reflect that directly onto our
116
+ # artifical schema.
117
+ if param_data["type"] == "array" && param_data["items"]
118
+ param_schema.items = param_data["items"]
119
+ end
120
+
121
+ link_schema.properties[param_data["name"]] = param_schema
122
+ if param_data["required"] == true
123
+ link_schema.required << param_data["name"]
124
+ end
125
+ end
126
+ end
127
+
128
+ link_schema
129
+ end
130
+
131
+ private
132
+
133
+ LINK_REQUIRED_FIELDS = [
134
+ :name
135
+ ].map(&:to_s).freeze
136
+
137
+ attr_accessor :link_data
138
+ end
139
+
140
+ class Schema < Committee::Drivers::Schema
141
+ attr_accessor :base_path
142
+ attr_accessor :consumes
143
+
144
+ # A link back to the derivative instace of Committee::Drivers::Driver
145
+ # that create this schema.
146
+ attr_accessor :driver
147
+
148
+ attr_accessor :definitions
149
+ attr_accessor :produces
150
+ attr_accessor :routes
151
+ end
152
+
153
+ private
154
+
155
+ DEFINITIONS_PSEUDO_URI = "http://json-schema.org/committee-definitions"
156
+
157
+ # These are fields that the OpenAPI 2 spec considers mandatory to be
158
+ # included in the document's top level.
159
+ REQUIRED_FIELDS = [
160
+ :consumes,
161
+ :definitions,
162
+ :paths,
163
+ :produces,
164
+ :swagger,
165
+ ].map(&:to_s).freeze
166
+
167
+ def find_best_fit_response(link_data)
168
+ if response_data = link_data["responses"]["200"]
169
+ [200, response_data]
170
+ elsif response_data = link_data["responses"]["201"]
171
+ [201, response_data]
172
+ else
173
+ # Sort responses so that we can try to prefer any 3-digit status code.
174
+ # If there are none, we'll just take anything from the list.
175
+ ordered_responses = link_data["responses"].
176
+ select { |k, v| k =~ /[0-9]{3}/ }
177
+ if first = ordered_responses.first
178
+ [first[0].to_i, first[1]]
179
+ else
180
+ [nil, nil]
181
+ end
182
+ end
183
+ end
184
+
185
+ def href_to_regex(href)
186
+ href.gsub(/\{(.*?)\}/, '(?<\1>[^/]+)')
187
+ end
188
+
189
+ def parse_definitions!(data)
190
+ # The "definitions" section of an OpenAPI 2 spec is a valid JSON schema.
191
+ # We extract it from the spec and parse it as a schema in isolation so
192
+ # that all references to it will still have correct paths (i.e. we can
193
+ # still find a resource at '#/definitions/resource' instead of
194
+ # '#/resource').
195
+ schema = JsonSchema.parse!({
196
+ "definitions" => data['definitions'],
197
+ })
198
+ schema.expand_references!
199
+ schema.uri = DEFINITIONS_PSEUDO_URI
200
+
201
+ # So this is a little weird: an OpenAPI specification is _not_ a valid
202
+ # JSON schema and yet it self-references like it is a valid JSON schema.
203
+ # To work around this what we do is parse its "definitions" section as a
204
+ # JSON schema and then build a document store here containing that. When
205
+ # trying to resolve a reference from elsewhere in the spec, we build a
206
+ # synthetic schema with a JSON reference to the document created from
207
+ # "definitions" and then expand references against this store.
208
+ store = JsonSchema::DocumentStore.new
209
+ store.add_schema(schema)
210
+
211
+ [schema, store]
212
+ end
213
+
214
+ def parse_routes!(data, schema, store)
215
+ routes = {}
216
+
217
+ # This is a performance optimization: instead of going through each link
218
+ # and parsing out its JSON schema separately, instead we just aggregate
219
+ # all schemas into one big hash and then parse it all at the end. After
220
+ # we parse it, go through each link and assign a proper schema object. In
221
+ # practice this comes out to somewhere on the order of 50x faster.
222
+ target_schemas_data = { "properties" => {} }
223
+
224
+ data['paths'].each do |path, methods|
225
+ href = schema.base_path + path
226
+ target_schemas_data["properties"][href] = { "properties" => {} }
227
+
228
+ methods.each do |method, link_data|
229
+ method = method.upcase
230
+
231
+ link = Link.new
232
+ link.enc_type = schema.consumes
233
+ link.href = href
234
+ link.media_type = schema.produces
235
+ link.method = method
236
+
237
+ # Convert the spec's parameter pseudo-schemas into JSON schemas that
238
+ # we can use for some basic request validation.
239
+ link.schema = ParameterSchemaBuilder.new(link_data).call
240
+
241
+ # Arbitrarily pick one response for the time being. Prefers in order:
242
+ # a 200, 201, any 3-digit numerical response, then anything at all.
243
+ status, response_data = find_best_fit_response(link_data)
244
+ if status
245
+ link.status_success = status
246
+
247
+ # A link need not necessarily specify a target schema.
248
+ if response_data["schema"]
249
+ target_schemas_data["properties"][href]["properties"][method] =
250
+ response_data["schema"]
251
+ end
252
+ end
253
+
254
+ rx = %r{^#{href_to_regex(link.href)}$}
255
+ Committee.log_debug "Created route: #{link.method} #{link.href} (regex #{rx})"
256
+
257
+ routes[method] ||= []
258
+ routes[method] << [rx, link]
259
+ end
260
+ end
261
+
262
+ # See the note on our DocumentStore's initialization in
263
+ # #parse_definitions!, but what we're doing here is prefixing references
264
+ # with a specialized internal URI so that they can reference definitions
265
+ # from another document in the store.
266
+ target_schemas = rewrite_references(target_schemas_data)
267
+
268
+ target_schemas = JsonSchema.parse!(target_schemas)
269
+ target_schemas.expand_references!(store: store)
270
+
271
+ # As noted above, now that we've parsed our aggregate response schema, go
272
+ # back through each link and them their response schema.
273
+ routes.each do |method, method_routes|
274
+ method_routes.each do |(_, link)|
275
+ link.target_schema =
276
+ target_schemas.properties[link.href].properties[method]
277
+ end
278
+ end
279
+
280
+ routes
281
+ end
282
+
283
+ def rewrite_references(schema)
284
+ if schema.is_a?(Hash)
285
+ ref = schema["$ref"]
286
+ if ref && ref.is_a?(String) && ref[0] == "#"
287
+ schema["$ref"] = DEFINITIONS_PSEUDO_URI + ref
288
+ else
289
+ schema.each do |_, v|
290
+ rewrite_references(v)
291
+ end
292
+ end
293
+ end
294
+ schema
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,57 @@
1
+ module Committee
2
+ module Drivers
3
+ # Gets a driver instance from the specified name. Raises ArgumentError for
4
+ # an unknown driver name.
5
+ def self.driver_from_name(name)
6
+ case name
7
+ when :hyper_schema
8
+ Committee::Drivers::HyperSchema.new
9
+ when :open_api_2
10
+ Committee::Drivers::OpenAPI2.new
11
+ else
12
+ raise ArgumentError, %{Committee: unknown driver "#{name}".}
13
+ end
14
+ end
15
+
16
+ # Driver is a base class for driver implementations.
17
+ class Driver
18
+ # Whether parameters in a request's path will be considered and coerced
19
+ # by default.
20
+ def default_path_params
21
+ raise "needs implementation"
22
+ end
23
+
24
+ # Whether parameters in a request's query string will be considered and
25
+ # coerced by default.
26
+ def default_query_params
27
+ raise "needs implementation"
28
+ end
29
+
30
+ def name
31
+ raise "needs implementation"
32
+ end
33
+
34
+ # Parses an API schema and builds a set of route definitions for use with
35
+ # Committee.
36
+ #
37
+ # The expected input format is a data hash with keys as strings (as
38
+ # opposed to symbols) like the kind produced by JSON.parse or YAML.load.
39
+ def parse(data)
40
+ raise "needs implementation"
41
+ end
42
+
43
+ def schema_class
44
+ raise "needs implementation"
45
+ end
46
+ end
47
+
48
+ # Schema is a base class for driver schema implementations.
49
+ class Schema
50
+ # A link back to the derivative instace of Committee::Drivers::Driver
51
+ # that create this schema.
52
+ def driver
53
+ raise "needs implementation"
54
+ end
55
+ end
56
+ end
57
+ end