committee 1.15.0 → 2.0.0.pre

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