committee 0.4.14 → 1.0.0

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.
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