committee 0.4.14 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/bin/committee-stub CHANGED
@@ -32,8 +32,8 @@ schema = File.read(args[0])
32
32
 
33
33
  app = Rack::Builder.new {
34
34
  use Committee::Middleware::RequestValidation, schema: schema
35
- use Committee::Middleware::Stub, schema: schema
36
35
  use Committee::Middleware::ResponseValidation, schema: schema
36
+ use Committee::Middleware::Stub, schema: schema
37
37
  run lambda { |_|
38
38
  [404, {}, ["Not found"]]
39
39
  }
@@ -5,16 +5,7 @@ module Committee
5
5
  class BadRequest < Error
6
6
  end
7
7
 
8
- class InvalidPattern < Error
9
- end
10
-
11
- class InvalidFormat < Error
12
- end
13
-
14
- class InvalidType < Error
15
- end
16
-
17
- class InvalidParams < Error
8
+ class InvalidRequest < Error
18
9
  end
19
10
 
20
11
  class InvalidResponse < Error
@@ -5,7 +5,11 @@ module Committee::Middleware
5
5
 
6
6
  @params_key = options[:params_key] || "committee.params"
7
7
  data = options[:schema] || raise("need option `schema`")
8
- @schema = Committee::Schema.new(data)
8
+ if data.is_a?(String)
9
+ warn_string_deprecated
10
+ data = MultiJson.decode(data)
11
+ end
12
+ @schema = JsonSchema.parse!(data)
9
13
  @router = Committee::Router.new(@schema)
10
14
  end
11
15
 
@@ -15,5 +19,9 @@ module Committee::Middleware
15
19
  [status, { "Content-Type" => "application/json" },
16
20
  [MultiJson.encode({ id: id, error: message }, pretty: true)]]
17
21
  end
22
+
23
+ def warn_string_deprecated
24
+ Committee.warn_deprecated("Committee: passing a string to `schema` option is deprecated; please send a deserialized hash instead.")
25
+ end
18
26
  end
19
27
  end
@@ -2,21 +2,17 @@ module Committee::Middleware
2
2
  class RequestValidation < Base
3
3
  def initialize(app, options={})
4
4
  super
5
- @allow_extra = options[:allow_extra]
6
5
  @prefix = options[:prefix]
6
+
7
+ # deprecated
8
+ @allow_extra = options[:allow_extra]
7
9
  end
8
10
 
9
11
  def call(env)
10
12
  request = Rack::Request.new(env)
11
13
  env[@params_key] = Committee::RequestUnpacker.new(request).call
12
- link, _ = @router.routes_request?(request, prefix: @prefix)
13
- if link
14
- Committee::ParamValidator.new(
15
- env[@params_key],
16
- @schema,
17
- link,
18
- allow_extra: @allow_extra
19
- ).call
14
+ if link = @router.routes_request?(request, prefix: @prefix)
15
+ Committee::RequestValidator.new.call(link, env[@params_key])
20
16
  end
21
17
  @app.call(env)
22
18
  rescue Committee::BadRequest
@@ -8,17 +8,10 @@ module Committee::Middleware
8
8
  def call(env)
9
9
  status, headers, response = @app.call(env)
10
10
  request = Rack::Request.new(env)
11
- link_schema, type_schema =
12
- @router.routes_request?(request, prefix: @prefix)
13
- if type_schema
14
- check_content_type!(headers)
11
+ if link = @router.routes_request?(request, prefix: @prefix)
15
12
  str = response.reduce("") { |str, s| str << s }
16
- Committee::ResponseValidator.new(
17
- MultiJson.decode(str),
18
- @schema,
19
- link_schema,
20
- type_schema
21
- ).call
13
+ data = MultiJson.decode(str)
14
+ Committee::ResponseValidator.new(link).call(headers, data)
22
15
  end
23
16
  [status, headers, response]
24
17
  rescue Committee::InvalidResponse
@@ -26,14 +19,5 @@ module Committee::Middleware
26
19
  rescue MultiJson::LoadError
27
20
  render_error(500, :invalid_response, "Response wasn't valid JSON.")
28
21
  end
29
-
30
- private
31
-
32
- def check_content_type!(headers)
33
- unless headers["Content-Type"] =~ %r{application/json}
34
- raise Committee::InvalidResponse,
35
- %{"Content-Type" response header must be set to "application/json".}
36
- end
37
- end
38
22
  end
39
23
  end
@@ -9,11 +9,10 @@ module Committee::Middleware
9
9
 
10
10
  def call(env)
11
11
  request = Rack::Request.new(env)
12
- link_schema, type_schema = @router.routes_request?(request, prefix: @prefix)
13
- if type_schema
12
+ if link = @router.routes_request?(request, prefix: @prefix)
14
13
  headers = { "Content-Type" => "application/json" }
15
- data = cache(link_schema["method"], link_schema["href"]) do
16
- Committee::ResponseGenerator.new(@schema, type_schema, link_schema).call
14
+ data = cache(link.method, link.href) do
15
+ Committee::ResponseGenerator.new.call(link)
17
16
  end
18
17
  if @call
19
18
  env["committee.response"] = data
@@ -28,7 +27,7 @@ module Committee::Middleware
28
27
  # made, and stub normally
29
28
  headers.merge!(call_headers)
30
29
  end
31
- status = link_schema["rel"] == "create" ? 201 : 200
30
+ status = link.rel == "create" ? 201 : 200
32
31
  [status, headers, [MultiJson.encode(data, pretty: true)]]
33
32
  else
34
33
  @app.call(env)
@@ -0,0 +1,28 @@
1
+ module Committee
2
+ class RequestValidator
3
+ def initialize(options = {})
4
+ end
5
+
6
+ def call(link, params)
7
+ if link.schema
8
+ valid, errors = link.schema.validate(params)
9
+ if !valid
10
+ errors = error_messages(errors).join("\n")
11
+ raise InvalidRequest, "Invalid request.\n\n#{errors}"
12
+ end
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def error_messages(errors)
19
+ errors.map do |error|
20
+ if error.schema
21
+ %{At "#{error.schema.uri}": #{error.message}}
22
+ else
23
+ error.message
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -1,29 +1,35 @@
1
1
  module Committee
2
2
  class ResponseGenerator
3
- def initialize(schema, type_schema, link_schema)
4
- @schema = schema
5
- @type_schema = type_schema
6
- @link_schema = link_schema
7
- end
3
+ def call(link)
4
+ data = generate_properties(link.parent)
5
+
6
+ # list is a special case; wrap data in an array
7
+ data = [data] if link.rel == "instances"
8
8
 
9
- def call
10
- generate_properties(@type_schema)
9
+ data
11
10
  end
12
11
 
13
12
  private
14
13
 
15
14
  def generate_properties(schema)
16
15
  data = {}
17
- schema["properties"].each do |name, value|
18
- data[name] = if value["properties"]
16
+ schema.properties.each do |key, value|
17
+ data[key] = if !value.properties.empty?
19
18
  generate_properties(value)
20
19
  else
21
- definition = @schema.find(value["$ref"])
22
- definition["example"]
20
+ # special example attribute was included; use its value
21
+ if !value.data["example"].nil?
22
+ value.data["example"]
23
+ # null is allowed; use that
24
+ elsif value.type.include?("null")
25
+ nil
26
+ # otherwise we don't know what to do (we could eventually generate
27
+ # random data based on type/format
28
+ else
29
+ raise(%{At "#{schema.id}"/"#{key}": no "example" attribute and "null" is not allowed; don't know how to generate property.})
30
+ end
23
31
  end
24
32
  end
25
- # list is a special case; wrap data in an array
26
- data = [data] if @link_schema["title"] == "List"
27
33
  data
28
34
  end
29
35
  end
@@ -1,111 +1,44 @@
1
1
  module Committee
2
2
  class ResponseValidator
3
- include Validation
4
-
5
- def initialize(data, schema, link_schema, type_schema)
6
-
7
- @data = data
8
- @schema = schema
9
- @link_schema = link_schema
10
- @type_schema = type_schema
3
+ def initialize(link)
4
+ @link = link
5
+ @validator = JsonSchema::Validator.new(link.parent)
11
6
  end
12
7
 
13
- def call
14
- data = if @link_schema["rel"] == "instances"
15
- if !@data.is_a?(Array)
8
+ def call(headers, data)
9
+ check_content_type!(headers)
10
+
11
+ if @link.rel == "instances"
12
+ if !data.is_a?(Array)
16
13
  raise InvalidResponse, "List endpoints must return an array of objects."
17
14
  end
18
15
  # only consider the first object during the validation from here on
19
- @data[0]
20
- else
21
- @data
22
- end
23
-
24
- data_keys = build_data_keys(data)
25
- schema_keys = build_schema_keys
26
-
27
- extra = data_keys - schema_keys
28
- missing = schema_keys - data_keys
29
-
30
- errors = []
31
-
32
- if extra.count > 0
33
- errors << "Extra keys in response: #{extra.join(', ')}."
34
- end
35
-
36
- if missing.count > 0
37
- errors << "Missing keys in response: #{missing.join(', ')}."
16
+ data = data[0]
38
17
  end
39
18
 
40
- unless errors.empty?
41
- raise InvalidResponse, ["`#{@link_schema['method']} #{@link_schema['href']}` deviates from schema.", *errors].join(' ')
42
- end
43
-
44
- check_data!(@type_schema, data, [])
45
- end
46
-
47
- def check_data!(schema, data, path)
48
- schema["properties"].each do |key, value|
49
- if value["properties"]
50
- check_data!(value, data[key], path + [key])
51
- elsif value["type"] == ["array"]
52
- definition = @schema.find(value["items"]["$ref"])
53
- data[key].each do |datum|
54
- check_type!(definition["type"], datum, path + [key])
55
- check_data!(definition, datum, path + [key]) if definition["type"] == ["object"]
56
- unless definition["type"].include?("null") && datum.nil?
57
- check_format!(definition["format"], datum, path + [key])
58
- check_pattern!(definition["pattern"], datum, path + [key])
59
- end
60
- end
61
-
62
- else
63
- definition = @schema.find(value["$ref"])
64
- check_type!(definition["type"], data[key], path + [key])
65
- unless definition["type"].include?("null") && data[key].nil?
66
- check_format!(definition["format"], data[key], path + [key])
67
- check_pattern!(definition["pattern"], data[key], path + [key])
68
- end
69
- end
19
+ if !@validator.validate(data)
20
+ errors = error_messages(@validator.errors).join("\n")
21
+ raise InvalidResponse, "Invalid response.\n\n#{errors}"
70
22
  end
71
23
  end
72
24
 
73
25
  private
74
26
 
75
- def build_data_keys(data)
76
- keys = []
77
- data.each do |key, value|
78
- if value.is_a?(Hash)
79
- keys += value.keys.map { |k| "#{key}:#{k}" }
80
- else
81
- keys << key
82
- end
27
+ def check_content_type!(headers)
28
+ unless headers["Content-Type"] =~ %r{application/json}
29
+ raise Committee::InvalidResponse,
30
+ %{"Content-Type" response header must be set to "application/json".}
83
31
  end
84
- keys
85
32
  end
86
33
 
87
- def build_schema_keys
88
- keys = []
89
- @type_schema["properties"].each do |key, info|
90
- data = if info["properties"]
91
- info
92
- elsif info["type"] == ["array"]
93
- array_schema = @schema.find(info["items"]["$ref"])
94
- unless array_schema["type"] == ["object"]
95
- array_schema
96
- else
97
- {} # satisfy data['properties'] check below
98
- end
99
- elsif info["$ref"]
100
- @schema.find(info["$ref"])
101
- end
102
- if data["properties"]
103
- keys += data["properties"].keys.map { |k| "#{key}:#{k}" }
34
+ def error_messages(errors)
35
+ errors.map do |error|
36
+ if error.schema
37
+ %{At "#{error.schema.uri}": #{error.message}}
104
38
  else
105
- keys << key
39
+ error.message
106
40
  end
107
41
  end
108
- keys
109
42
  end
110
43
  end
111
44
  end
@@ -7,13 +7,13 @@ module Committee
7
7
  def routes?(method, path, options = {})
8
8
  path = path.gsub(/^#{options[:prefix]}/, "") if options[:prefix]
9
9
  if method_routes = @routes[method]
10
- method_routes.each do |pattern, link, schema|
10
+ method_routes.each do |pattern, link|
11
11
  if path =~ pattern
12
- return link, schema
12
+ return link
13
13
  end
14
14
  end
15
15
  end
16
- [nil, nil]
16
+ nil
17
17
  end
18
18
 
19
19
  def routes_request?(request, options = {})
@@ -24,13 +24,14 @@ module Committee
24
24
 
25
25
  def build_routes(schema)
26
26
  routes = {}
27
- schema.each do |_, type_schema|
28
- type_schema["links"].each do |link|
29
- routes[link["method"]] ||= []
27
+ # realistically, we should be examining links recursively at all levels
28
+ schema.properties.each do |_, type_schema|
29
+ type_schema.links.each do |link|
30
+ method = link.method.to_s.upcase
31
+ routes[method] ||= []
30
32
  # /apps/{id} --> /apps/([^/]+)
31
- href = link["href"].gsub(/\{(.*?)\}/, "[^/]+")
32
- routes[link["method"]] <<
33
- [%r{^#{href}$}, link, type_schema]
33
+ href = link.href.gsub(/\{(.*?)\}/, "[^/]+")
34
+ routes[method] << [%r{^#{href}$}, link]
34
35
  end
35
36
  end
36
37
  routes
@@ -1,48 +1,46 @@
1
1
  module Committee::Test
2
2
  module Methods
3
3
  def assert_schema_conform
4
- assert_schema_content_type
4
+ if (data = schema_contents).is_a?(String)
5
+ warn_string_deprecated
6
+ data = MultiJson.decode(data)
7
+ end
5
8
 
6
- @schema ||= Committee::Schema.new(schema_contents)
9
+ @schema ||= JsonSchema.parse!(data)
7
10
  @router ||= Committee::Router.new(@schema)
8
11
 
9
- link_schema, type_schema =
12
+ link =
10
13
  @router.routes_request?(last_request, prefix: schema_url_prefix)
11
-
12
- unless link_schema
14
+ unless link
13
15
  response = "`#{last_request.request_method} #{last_request.path_info}` undefined in schema."
14
16
  raise Committee::InvalidResponse.new(response)
15
17
  end
16
18
 
17
19
  data = MultiJson.decode(last_response.body)
18
- Committee::ResponseValidator.new(
19
- data,
20
- @schema,
21
- link_schema,
22
- type_schema
23
- ).call
20
+ Committee::ResponseValidator.new(link).call(last_response.headers, data)
24
21
  end
25
22
 
26
23
  def assert_schema_content_type
27
- unless last_response.headers["Content-Type"] =~ %r{application/json}
28
- raise Committee::InvalidResponse,
29
- %{"Content-Type" response header must be set to "application/json".}
30
- end
24
+ Committee.warn_deprecated("Use of #assert_schema_content_type is deprecated; use #assert_schema_conform instead.")
31
25
  end
32
26
 
33
27
  # can be overridden alternatively to #schema_path in case the schema is
34
28
  # easier to access as a string
35
29
  # blob
36
30
  def schema_contents
37
- File.read(schema_path)
31
+ MultiJson.decode(File.read(schema_path))
38
32
  end
39
33
 
40
34
  def schema_path
41
- raise "Please override #schema_path."
35
+ raise "Please override #schema_contents or #schema_path."
42
36
  end
43
37
 
44
38
  def schema_url_prefix
45
39
  nil
46
40
  end
41
+
42
+ def warn_string_deprecated
43
+ Committee.warn_deprecated("Committee: returning a string from `#schema_contents` is deprecated; please return a deserialized hash instead.")
44
+ end
47
45
  end
48
46
  end
data/lib/committee.rb CHANGED
@@ -1,14 +1,13 @@
1
+ require "json_schema"
1
2
  require "multi_json"
2
3
  require "rack"
3
4
 
4
5
  require_relative "committee/errors"
5
- require_relative "committee/validation"
6
- require_relative "committee/param_validator"
7
6
  require_relative "committee/request_unpacker"
7
+ require_relative "committee/request_validator"
8
8
  require_relative "committee/response_generator"
9
9
  require_relative "committee/response_validator"
10
10
  require_relative "committee/router"
11
- require_relative "committee/schema"
12
11
 
13
12
  require_relative "committee/middleware/base"
14
13
  require_relative "committee/middleware/request_validation"
@@ -16,3 +15,11 @@ require_relative "committee/middleware/response_validation"
16
15
  require_relative "committee/middleware/stub"
17
16
 
18
17
  require_relative "committee/test/methods"
18
+
19
+ module Committee
20
+ def self.warn_deprecated(message)
21
+ if !$VERBOSE.nil?
22
+ $stderr.puts(message)
23
+ end
24
+ end
25
+ end
@@ -7,61 +7,31 @@ describe Committee::Middleware::RequestValidation do
7
7
  @app
8
8
  end
9
9
 
10
- it "detects an invalid Content-Type" do
11
- @app = new_rack_app
12
- header "Content-Type", "application/whats-this"
13
- post "/account/app-transfers", "{}"
14
- assert_equal 400, last_response.status
15
- end
16
-
17
10
  it "passes through a valid request" do
18
11
  @app = new_rack_app
19
12
  params = {
20
- "app" => "heroku-api",
21
- "recipient" => "owner@heroku.com",
13
+ "name" => "cloudnasium"
22
14
  }
23
15
  header "Content-Type", "application/json"
24
- post "/account/app-transfers", MultiJson.encode(params)
16
+ post "/apps", MultiJson.encode(params)
25
17
  assert_equal 200, last_response.status
26
18
  end
27
19
 
28
- it "detects a missing parameter" do
29
- @app = new_rack_app
30
- header "Content-Type", "application/json"
31
- post "/account/app-transfers", "{}"
32
- assert_equal 422, last_response.status
33
- assert_match /require params/i, last_response.body
34
- end
35
-
36
- it "detects an extra parameter" do
20
+ it "detects an invalid Content-Type" do
37
21
  @app = new_rack_app
22
+ header "Content-Type", "application/whats-this"
38
23
  params = {
39
- "app" => "heroku-api",
40
- "cloud" => "production",
41
- "recipient" => "owner@heroku.com",
42
- }
43
- header "Content-Type", "application/json"
44
- post "/account/app-transfers", MultiJson.encode(params)
45
- assert_equal 422, last_response.status
46
- assert_match /unknown params/i, last_response.body
47
- end
48
-
49
- it "doesn't error on an extra parameter with allow_extra" do
50
- @app = new_rack_app(allow_extra: true)
51
- params = {
52
- "app" => "heroku-api",
53
- "cloud" => "production",
54
- "recipient" => "owner@heroku.com",
24
+ "name" => "cloudnasium"
55
25
  }
56
- header "Content-Type", "application/json"
57
- post "/account/app-transfers", MultiJson.encode(params)
58
- assert_equal 200, last_response.status
26
+ post "/apps", MultiJson.encode(params)
27
+ assert_equal 400, last_response.status
28
+ assert_match /unsupported content-type/i, last_response.body
59
29
  end
60
30
 
61
31
  it "rescues JSON errors" do
62
32
  @app = new_rack_app
63
33
  header "Content-Type", "application/json"
64
- post "/account/app-transfers", "{x:y}"
34
+ post "/apps", "{x:y}"
65
35
  assert_equal 400, last_response.status
66
36
  assert_match /valid json/i, last_response.body
67
37
  end
@@ -69,11 +39,21 @@ describe Committee::Middleware::RequestValidation do
69
39
  it "takes a prefix" do
70
40
  @app = new_rack_app(prefix: "/v1")
71
41
  params = {
72
- "app" => "heroku-api",
73
- "recipient" => "owner@heroku.com",
42
+ "name" => "cloudnasium"
43
+ }
44
+ header "Content-Type", "application/json"
45
+ post "/v1/apps", MultiJson.encode(params)
46
+ assert_equal 200, last_response.status
47
+ end
48
+
49
+ it "warns when sending a deprecated string" do
50
+ mock(Committee).warn_deprecated.with_any_args
51
+ @app = new_rack_app(schema: File.read("./test/data/schema.json"))
52
+ params = {
53
+ "name" => "cloudnasium"
74
54
  }
75
55
  header "Content-Type", "application/json"
76
- post "/v1/account/app-transfers", MultiJson.encode(params)
56
+ post "/apps", MultiJson.encode(params)
77
57
  assert_equal 200, last_response.status
78
58
  end
79
59
 
@@ -81,7 +61,7 @@ describe Committee::Middleware::RequestValidation do
81
61
 
82
62
  def new_rack_app(options = {})
83
63
  options = {
84
- schema: File.read("./test/data/schema.json")
64
+ schema: MultiJson.decode(File.read("./test/data/schema.json"))
85
65
  }.merge(options)
86
66
  Rack::Builder.new {
87
67
  use Committee::Middleware::RequestValidation, options
@@ -13,14 +13,6 @@ describe Committee::Middleware::ResponseValidation do
13
13
  assert_equal 200, last_response.status
14
14
  end
15
15
 
16
- it "detects an invalid response Content-Type" do
17
- @app = new_rack_app(MultiJson.encode([ValidApp]),
18
- { "Content-Type" => "application/xml" })
19
- get "/apps"
20
- assert_equal 500, last_response.status
21
- assert_match /response header must be set to/i, last_response.body
22
- end
23
-
24
16
  it "detects an invalid response" do
25
17
  @app = new_rack_app("")
26
18
  get "/apps"
@@ -28,24 +20,6 @@ describe Committee::Middleware::ResponseValidation do
28
20
  assert_match /valid JSON/i, last_response.body
29
21
  end
30
22
 
31
- it "detects missing keys in response" do
32
- data = ValidApp.dup
33
- data.delete("name")
34
- @app = new_rack_app(MultiJson.encode([data]))
35
- get "/apps"
36
- assert_equal 500, last_response.status
37
- assert_match /missing keys/i, last_response.body
38
- end
39
-
40
- it "detects extra keys in response" do
41
- data = ValidApp.dup
42
- data.merge!("tier" => "important")
43
- @app = new_rack_app(MultiJson.encode([data]))
44
- get "/apps"
45
- assert_equal 500, last_response.status
46
- assert_match /extra keys/i, last_response.body
47
- end
48
-
49
23
  it "rescues JSON errors" do
50
24
  @app = new_rack_app("[{x:y}]")
51
25
  get "/apps"
@@ -59,6 +33,14 @@ describe Committee::Middleware::ResponseValidation do
59
33
  assert_equal 200, last_response.status
60
34
  end
61
35
 
36
+ it "warns when sending a deprecated string" do
37
+ mock(Committee).warn_deprecated.with_any_args
38
+ @app = new_rack_app(MultiJson.encode([ValidApp]), {},
39
+ schema: File.read("./test/data/schema.json"))
40
+ get "/apps"
41
+ assert_equal 200, last_response.status
42
+ end
43
+
62
44
  private
63
45
 
64
46
  def new_rack_app(response, headers = {}, options = {})
@@ -66,7 +48,7 @@ describe Committee::Middleware::ResponseValidation do
66
48
  "Content-Type" => "application/json"
67
49
  }.merge(headers)
68
50
  options = {
69
- schema: File.read("./test/data/schema.json")
51
+ schema: MultiJson.decode(File.read("./test/data/schema.json"))
70
52
  }.merge(options)
71
53
  Rack::Builder.new {
72
54
  use Committee::Middleware::ResponseValidation, options
@@ -46,12 +46,21 @@ describe Committee::Middleware::Stub do
46
46
  assert_equal ValidApp.keys.sort, data.keys.sort
47
47
  end
48
48
 
49
+ it "warns when sending a deprecated string" do
50
+ mock(Committee).warn_deprecated.with_any_args
51
+ @app = new_rack_app(schema: File.read("./test/data/schema.json"))
52
+ get "/apps/heroku-api"
53
+ assert_equal 200, last_response.status
54
+ data = MultiJson.decode(last_response.body)
55
+ assert_equal ValidApp.keys.sort, data.keys.sort
56
+ end
57
+
49
58
  private
50
59
 
51
60
  def new_rack_app(options = {})
52
61
  suppress = options.delete(:suppress)
53
62
  options = {
54
- schema: File.read("./test/data/schema.json")
63
+ schema: MultiJson.decode(File.read("./test/data/schema.json"))
55
64
  }.merge(options)
56
65
  Rack::Builder.new {
57
66
  use Committee::Middleware::Stub, options