committee 0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'optparse'
4
+
5
+ require_relative "../lib/committee"
6
+
7
+ args = ARGV.dup
8
+ options = { Port: 9292 }
9
+ opt_parser = OptionParser.new do |opts|
10
+ opts.banner = "Usage: rackup [options] [JSON Schema file]"
11
+
12
+ opts.separator ""
13
+ opts.separator "Options:"
14
+
15
+ opts.on_tail("-h", "-?", "--help", "Show this message") {
16
+ puts opts
17
+ exit
18
+ }
19
+
20
+ opts.on("-p", "--port PORT", "use PORT (default: 9292)") { |port|
21
+ options[:Port] = port
22
+ }
23
+ end
24
+ opt_parser.parse!(args)
25
+
26
+ unless args.count == 1
27
+ puts opt_parser.to_s
28
+ exit
29
+ end
30
+
31
+ schema = File.read(args[0])
32
+
33
+ app = Rack::Builder.new {
34
+ use Committee::Middleware::RequestValidation, schema: schema
35
+ use Committee::Middleware::Stub, schema: schema
36
+ use Committee::Middleware::ResponseValidation, schema: schema
37
+ run lambda { |_|
38
+ [404, {}, ["Not found"]]
39
+ }
40
+ }
41
+
42
+ options[:app] = app
43
+ Rack::Server.start(options)
@@ -0,0 +1,13 @@
1
+ module Committee
2
+ class BadRequest < StandardError
3
+ end
4
+
5
+ class InvalidParams < StandardError
6
+ end
7
+
8
+ class InvalidResponse < StandardError
9
+ end
10
+
11
+ class ReferenceNotFound < StandardError
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ module Committee::Middleware
2
+ class Base
3
+ def initialize(app, options={})
4
+ @app = app
5
+
6
+ @params_key = options[:params_key] || "committee.params"
7
+ data = options[:schema] || raise("need option `schema`")
8
+ @schema = Committee::Schema.new(data)
9
+ @router = Committee::Router.new(@schema)
10
+ end
11
+
12
+ private
13
+
14
+ def render_error(status, id, message)
15
+ [status, { "Content-Type" => "application/json" },
16
+ [MultiJson.encode(id: id, error: message)]]
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ module Committee::Middleware
2
+ class RequestValidation < Base
3
+ def call(env)
4
+ request = Rack::Request.new(env)
5
+ env[@params_key] = Committee::RequestUnpacker.new(request).call
6
+ link, _ = @router.routes_request?(request)
7
+ if link
8
+ Committee::ParamValidator.new(env[@params_key], @schema, link).call
9
+ end
10
+ @app.call(env)
11
+ rescue Committee::BadRequest
12
+ render_error(400, :bad_request, $!.message)
13
+ rescue Committee::InvalidParams
14
+ render_error(422, :invalid_params, $!.message)
15
+ rescue MultiJson::LoadError
16
+ render_error(400, :invalid_params, "Request body wasn't valid JSON.")
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,33 @@
1
+ module Committee::Middleware
2
+ class ResponseValidation < Base
3
+ def call(env)
4
+ status, headers, response = @app.call(env)
5
+ request = Rack::Request.new(env)
6
+ link_schema, type_schema = @router.routes_request?(request)
7
+ if type_schema
8
+ check_content_type!(headers)
9
+ str = response.reduce("") { |str, s| str << s }
10
+ Committee::ResponseValidator.new(
11
+ MultiJson.decode(str),
12
+ @schema,
13
+ link_schema,
14
+ type_schema
15
+ ).call
16
+ end
17
+ [status, headers, response]
18
+ rescue Committee::InvalidResponse
19
+ render_error(500, :invalid_response, $!.message)
20
+ rescue MultiJson::LoadError
21
+ render_error(500, :invalid_response, "Response wasn't valid JSON.")
22
+ end
23
+
24
+ private
25
+
26
+ def check_content_type!(headers)
27
+ unless headers["Content-Type"] =~ %r{application/json}
28
+ raise Committee::InvalidResponse,
29
+ %{"Content-Type" response header must be set to "application/json".}
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,33 @@
1
+ module Committee::Middleware
2
+ class Stub < Base
3
+ def initialize(app, options={})
4
+ super
5
+ @cache = {}
6
+ end
7
+
8
+ def call(env)
9
+ request = Rack::Request.new(env)
10
+ link_schema, type_schema = @router.routes_request?(request)
11
+ if type_schema
12
+ str = cache(link_schema["method"], link_schema["href"]) do
13
+ data = Committee::ResponseGenerator.new(@schema, type_schema, link_schema).call
14
+ MultiJson.encode(data, pretty: true)
15
+ end
16
+ [200, { "Content-Type" => "application/json" }, [str]]
17
+ else
18
+ @app.call(env)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def cache(method, href)
25
+ key = "#{method}+#{href}"
26
+ if @cache[key]
27
+ @cache[key]
28
+ else
29
+ @cache[key] = yield
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,132 @@
1
+ module Committee
2
+ class ParamValidator
3
+ def initialize(params, schema, link_schema)
4
+ @params = params
5
+ @schema = schema
6
+ @link_schema = link_schema
7
+ end
8
+
9
+ def call
10
+ detect_missing!
11
+ detect_extra!
12
+ check_data!
13
+ end
14
+
15
+ private
16
+
17
+ def all_keys
18
+ properties = @link_schema["schema"] && @link_schema["schema"]["properties"]
19
+ properties && properties.keys || []
20
+ end
21
+
22
+ def check_data!
23
+ return if !@link_schema["schema"] || !@link_schema["schema"]["properties"]
24
+
25
+ @link_schema["schema"]["properties"].each do |key, value|
26
+ # don't try to check this unless it was actually specificed
27
+ next unless @params.key?(key)
28
+
29
+ match = false
30
+ definitions = find_definitions(value["$ref"])
31
+
32
+ # try to match data against any possible definition
33
+ definitions.each do |definition|
34
+ if check_type(definition["type"], @params[key], key) &&
35
+ check_format(definition["format"], @params[key], key) &&
36
+ check_pattern(definition["pattern"], @params[key], key)
37
+ match = true
38
+ next
39
+ end
40
+ end
41
+
42
+ # if nothing was matched, throw error according to first definition
43
+ if !match && definition = definitions.first
44
+ check_type!(definition["type"], @params[key], key)
45
+ check_format!(definition["format"], @params[key], key)
46
+ check_pattern!(definition["pattern"], @params[key], key)
47
+ end
48
+ end
49
+ end
50
+
51
+ def check_format(format, value, key)
52
+ case format
53
+ when "date-time"
54
+ value =~ /^(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})(Z|[+-](\d{2})\:(\d{2}))$/
55
+ when "email"
56
+ value =~ /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/i
57
+ when "uuid"
58
+ value =~ /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
59
+ else
60
+ true
61
+ end
62
+ end
63
+
64
+ def check_format!(format, value, key)
65
+ unless check_format(format, value, key)
66
+ raise InvalidParams,
67
+ %{Invalid format for key "#{key}": expected "#{value}" to be "#{format}".}
68
+ end
69
+ end
70
+
71
+ def check_pattern(pattern, value, key)
72
+ !pattern || value =~ pattern
73
+ end
74
+
75
+ def check_pattern!(pattern, value, key)
76
+ unless check_pattern(pattern, value, key)
77
+ raise InvalidParams,
78
+ %{Invalid pattern for key "#{key}": expected #{value} to match "#{pattern}".}
79
+ end
80
+ end
81
+
82
+ def check_type(types, value, key)
83
+ type = case value
84
+ when NilClass
85
+ "null"
86
+ when TrueClass, FalseClass
87
+ "boolean"
88
+ when Fixnum
89
+ "integer"
90
+ when String
91
+ "string"
92
+ else
93
+ "unknown"
94
+ end
95
+ types.include?(type)
96
+ end
97
+
98
+ def check_type!(types, value, key)
99
+ unless check_type(types, value, key)
100
+ raise InvalidParams,
101
+ %{Invalid type for key "#{key}": expected #{value} to be #{types}.}
102
+ end
103
+ end
104
+
105
+ def detect_extra!
106
+ extra = @params.keys - all_keys
107
+ if extra.count > 0
108
+ raise InvalidParams.new("Unknown params: #{extra.join(', ')}.")
109
+ end
110
+ end
111
+
112
+ def detect_missing!
113
+ missing = required_keys - @params.keys
114
+ if missing.count > 0
115
+ raise InvalidParams.new("Require params: #{missing.join(', ')}.")
116
+ end
117
+ end
118
+
119
+ def find_definitions(ref)
120
+ definition = @schema.find(ref)
121
+ if definition["anyOf"]
122
+ definition["anyOf"].map { |r| @schema.find(r["$ref"]) }
123
+ else
124
+ [definition]
125
+ end
126
+ end
127
+
128
+ def required_keys
129
+ (@link_schema["schema"] && @link_schema["schema"]["required"]) || []
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,59 @@
1
+ module Committee
2
+ class RequestUnpacker
3
+ def initialize(request)
4
+ @request = request
5
+ end
6
+
7
+ def call
8
+ if !@request.content_type || @request.content_type =~ %r{application/json}
9
+ # if Content-Type is empty or JSON, and there was a request body, try
10
+ # to interpret it as JSON
11
+ if (body = @request.body.read).length != 0
12
+ @request.body.rewind
13
+ hash = MultiJson.decode(body)
14
+ # We want a hash specifically. '42', 42, and [42] will all be
15
+ # decoded properly, but we can't use them here.
16
+ if !hash.is_a?(Hash)
17
+ raise BadRequest,
18
+ "Invalid JSON input. Require object with parameters as keys."
19
+ end
20
+ indifferent_params(hash)
21
+ # if request body is empty, we just have empty params
22
+ else
23
+ {}
24
+ end
25
+ elsif @request.content_type == "application/x-www-form-urlencoded"
26
+ # Actually, POST means anything in the request body, could be from
27
+ # PUT or PATCH too. Silly Rack.
28
+ indifferent_params(@request.POST)
29
+ else
30
+ raise BadRequest, "Unsupported Content-Type: #{@request.content_type}."
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Creates a Hash with indifferent access.
37
+ #
38
+ # (Copied from Sinatra)
39
+ def indifferent_hash
40
+ Hash.new { |hash,key| hash[key.to_s] if Symbol === key }
41
+ end
42
+
43
+ # Enable string or symbol key access to the nested params hash.
44
+ #
45
+ # (Copied from Sinatra)
46
+ def indifferent_params(object)
47
+ case object
48
+ when Hash
49
+ new_hash = indifferent_hash
50
+ object.each { |key, value| new_hash[key] = indifferent_params(value) }
51
+ new_hash
52
+ when Array
53
+ object.map { |item| indifferent_params(item) }
54
+ else
55
+ object
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,30 @@
1
+ module Committee
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
8
+
9
+ def call
10
+ generate_properties(@type_schema)
11
+ end
12
+
13
+ private
14
+
15
+ def generate_properties(schema)
16
+ data = {}
17
+ schema["properties"].each do |name, value|
18
+ data[name] = if value["properties"]
19
+ generate_properties(value)
20
+ else
21
+ definition = @schema.find(value["$ref"])
22
+ definition["example"]
23
+ end
24
+ end
25
+ # list is a special case; wrap data in an array
26
+ data = [data] if @link_schema["title"] == "List"
27
+ data
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,127 @@
1
+ module Committee
2
+ class ResponseValidator
3
+ def initialize(data, schema, link_schema, type_schema)
4
+ @data = data
5
+ @schema = schema
6
+ @link_schema = link_schema
7
+ @type_schema = type_schema
8
+ end
9
+
10
+ def call
11
+ data = if @link_schema["title"] == "List"
12
+ if !@data.is_a?(Array)
13
+ raise InvalidResponse, "List endpoints must return an array of objects."
14
+ end
15
+ # only consider the first object during the validation from here on
16
+ @data[0]
17
+ else
18
+ @data
19
+ end
20
+
21
+ data_keys = build_data_keys(data)
22
+ schema_keys = build_schema_keys
23
+
24
+ extra = data_keys - schema_keys
25
+ missing = schema_keys - data_keys
26
+
27
+ errors = []
28
+
29
+ if extra.count > 0
30
+ errors << "Extra keys in response: #{extra.join(', ')}."
31
+ end
32
+
33
+ if missing.count > 0
34
+ errors << "Missing keys in response: #{missing.join(', ')}."
35
+ end
36
+
37
+ unless errors.empty?
38
+ raise InvalidResponse, ["`#{@link_schema['method']} #{@link_schema['href']}` deviates from schema.", *errors].join(' ')
39
+ end
40
+
41
+ check_data!(@type_schema, data, [])
42
+ end
43
+
44
+ def check_data!(schema, data, path)
45
+ schema["properties"].each do |key, value|
46
+ if value["properties"]
47
+ check_data!(value, data[key], path + [key])
48
+ else
49
+ definition = @schema.find(value["$ref"])
50
+ check_type!(definition["type"], data[key], path + [key])
51
+ check_format!(definition["format"], data[key], path + [key])
52
+ check_pattern!(definition["pattern"], data[key], path + [key])
53
+ end
54
+ end
55
+ end
56
+
57
+ def check_format!(format, value, path)
58
+ return if !format
59
+ valid = case format
60
+ when "date-time"
61
+ value =~ /^(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})(Z|[+-](\d{2})\:(\d{2}))$/
62
+ when "email"
63
+ value =~ /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/i
64
+ when "uuid"
65
+ value =~ /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
66
+ else
67
+ true
68
+ end
69
+ unless valid
70
+ raise InvalidResponse,
71
+ %{Invalid format at "#{path.join(":")}": expected "#{value}" to be "#{format}".}
72
+ end
73
+ end
74
+
75
+ def check_pattern!(pattern, value, path)
76
+ if pattern && !(value =~ pattern)
77
+ raise InvalidResponse,
78
+ %{Invalid pattern at "#{path.join(":")}": expected #{value} to match "#{pattern}".}
79
+ end
80
+ end
81
+
82
+ def check_type!(types, value, path)
83
+ type = case value
84
+ when NilClass
85
+ "null"
86
+ when TrueClass, FalseClass
87
+ "boolean"
88
+ when Fixnum
89
+ "integer"
90
+ when String
91
+ "string"
92
+ else
93
+ "unknown"
94
+ end
95
+ unless types.include?(type)
96
+ raise InvalidResponse,
97
+ %{Invalid type at "#{path.join(":")}": expected #{value} to be #{types} (was: #{type}).}
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def build_data_keys(data)
104
+ keys = []
105
+ data.each do |key, value|
106
+ if value.is_a?(Hash)
107
+ keys += value.keys.map { |k| "#{key}:#{k}" }
108
+ else
109
+ keys << key
110
+ end
111
+ end
112
+ keys
113
+ end
114
+
115
+ def build_schema_keys
116
+ keys = []
117
+ @type_schema["properties"].each do |key, info|
118
+ if info["properties"]
119
+ keys += info["properties"].keys.map { |k| "#{key}:#{k}" }
120
+ else
121
+ keys << key
122
+ end
123
+ end
124
+ keys
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,38 @@
1
+ module Committee
2
+ class Router
3
+ def initialize(schema)
4
+ @routes = build_routes(schema)
5
+ end
6
+
7
+ def routes?(method, path)
8
+ if method_routes = @routes[method]
9
+ method_routes.each do |pattern, link, schema|
10
+ if path =~ pattern
11
+ return link, schema
12
+ end
13
+ end
14
+ end
15
+ [nil, nil]
16
+ end
17
+
18
+ def routes_request?(request)
19
+ routes?(request.request_method, request.path_info)
20
+ end
21
+
22
+ private
23
+
24
+ def build_routes(schema)
25
+ routes = {}
26
+ schema.each do |_, type_schema|
27
+ type_schema["links"].each do |link|
28
+ routes[link["method"]] ||= []
29
+ # /apps/{id} --> /apps/([^/]+)
30
+ pattern = link["href"].gsub(/\{(.*?)\}/, "[^/]+")
31
+ routes[link["method"]] <<
32
+ [Regexp.new(pattern), link, type_schema]
33
+ end
34
+ end
35
+ routes
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,55 @@
1
+ module Committee
2
+ class Schema
3
+ include Enumerable
4
+
5
+ def initialize(data)
6
+ @cache = {}
7
+ @schema = MultiJson.decode(data)
8
+ manifest_regex
9
+ end
10
+
11
+ def [](type)
12
+ @schema["definitions"][type]
13
+ end
14
+
15
+ def each
16
+ @schema["definitions"].each do |type, type_schema|
17
+ yield(type, type_schema)
18
+ end
19
+ end
20
+
21
+ def find(ref)
22
+ cache(ref) do
23
+ parts = ref.split("/")
24
+ parts.shift if parts.first == "#"
25
+ pointer = @schema
26
+ parts.each do |p|
27
+ next unless pointer
28
+ pointer = pointer[p]
29
+ end
30
+ raise ReferenceNotFound, "Reference not found: #{ref}." if !pointer
31
+ pointer
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def cache(key)
38
+ if @cache[key]
39
+ @cache[key]
40
+ else
41
+ @cache[key] = yield
42
+ end
43
+ end
44
+
45
+ def manifest_regex
46
+ @schema["definitions"].each do |_, type_schema|
47
+ type_schema["definitions"].each do |_, property_schema|
48
+ if pattern = property_schema["pattern"]
49
+ property_schema["pattern"] = Regexp.new(pattern)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
data/lib/committee.rb ADDED
@@ -0,0 +1,15 @@
1
+ require "multi_json"
2
+ require "rack"
3
+
4
+ require_relative "committee/errors"
5
+ require_relative "committee/param_validator"
6
+ require_relative "committee/request_unpacker"
7
+ require_relative "committee/response_generator"
8
+ require_relative "committee/response_validator"
9
+ require_relative "committee/router"
10
+ require_relative "committee/schema"
11
+
12
+ require_relative "committee/middleware/base"
13
+ require_relative "committee/middleware/request_validation"
14
+ require_relative "committee/middleware/response_validation"
15
+ require_relative "committee/middleware/stub"
@@ -0,0 +1,59 @@
1
+ require_relative "../test_helper"
2
+
3
+ describe Committee::Middleware::RequestValidation do
4
+ include Rack::Test::Methods
5
+
6
+ StubApp = Rack::Builder.new {
7
+ use Committee::Middleware::RequestValidation,
8
+ schema: File.read("./test/data/schema.json")
9
+ run lambda { |_|
10
+ [200, {}, []]
11
+ }
12
+ }
13
+
14
+ def app
15
+ StubApp
16
+ end
17
+
18
+ before do
19
+ header "Content-Type", "application/json"
20
+ end
21
+
22
+ it "detects an invalid Content-Type" do
23
+ header "Content-Type", "application/whats-this"
24
+ post "/account/app-transfers", "{}"
25
+ assert_equal 400, last_response.status
26
+ end
27
+
28
+ it "passes through a valid request" do
29
+ params = {
30
+ "app" => "heroku-api",
31
+ "recipient" => "owner@heroku.com",
32
+ }
33
+ post "/account/app-transfers", MultiJson.encode(params)
34
+ assert_equal 200, last_response.status
35
+ end
36
+
37
+ it "detects a missing parameter" do
38
+ post "/account/app-transfers", "{}"
39
+ assert_equal 422, last_response.status
40
+ assert_match /require params/i, last_response.body
41
+ end
42
+
43
+ it "detects an extra parameter" do
44
+ params = {
45
+ "app" => "heroku-api",
46
+ "cloud" => "production",
47
+ "recipient" => "owner@heroku.com",
48
+ }
49
+ post "/account/app-transfers", MultiJson.encode(params)
50
+ assert_equal 422, last_response.status
51
+ assert_match /unknown params/i, last_response.body
52
+ end
53
+
54
+ it "rescues JSON errors" do
55
+ post "/account/app-transfers", "{x:y}"
56
+ assert_equal 400, last_response.status
57
+ assert_match /valid json/i, last_response.body
58
+ end
59
+ end
@@ -0,0 +1,64 @@
1
+ require_relative "../test_helper"
2
+
3
+ describe Committee::Middleware::ResponseValidation do
4
+ include Rack::Test::Methods
5
+
6
+ def app
7
+ @app
8
+ end
9
+
10
+ it "passes through a valid response" do
11
+ @app = new_rack_app(MultiJson.encode([ValidApp]))
12
+ get "/apps"
13
+ assert_equal 200, last_response.status
14
+ end
15
+
16
+ it "detects an invalid response Content-Type" do
17
+ @app = new_rack_app(MultiJson.encode([ValidApp]), {})
18
+ get "/apps"
19
+ assert_equal 500, last_response.status
20
+ assert_match /response header must be set to/i, last_response.body
21
+ end
22
+
23
+ it "detects an invalid response" do
24
+ @app = new_rack_app("")
25
+ get "/apps"
26
+ assert_equal 500, last_response.status
27
+ assert_match /valid JSON/i, last_response.body
28
+ end
29
+
30
+ it "detects missing keys in response" do
31
+ data = ValidApp.dup
32
+ data.delete("name")
33
+ @app = new_rack_app(MultiJson.encode([data]))
34
+ get "/apps"
35
+ assert_equal 500, last_response.status
36
+ assert_match /missing keys/i, last_response.body
37
+ end
38
+
39
+ it "detects extra keys in response" do
40
+ data = ValidApp.dup
41
+ data.merge!("tier" => "important")
42
+ @app = new_rack_app(MultiJson.encode([data]))
43
+ get "/apps"
44
+ assert_equal 500, last_response.status
45
+ assert_match /extra keys/i, last_response.body
46
+ end
47
+
48
+ it "rescues JSON errors" do
49
+ @app = new_rack_app("[{x:y}]")
50
+ get "/apps"
51
+ assert_equal 500, last_response.status
52
+ assert_match /valid json/i, last_response.body
53
+ end
54
+
55
+ def new_rack_app(response, headers={ "Content-Type" => "application/json" })
56
+ Rack::Builder.new {
57
+ use Committee::Middleware::ResponseValidation,
58
+ schema: File.read("./test/data/schema.json")
59
+ run lambda { |_|
60
+ [200, headers, [response]]
61
+ }
62
+ }
63
+ end
64
+ end
@@ -0,0 +1,24 @@
1
+ require_relative "../test_helper"
2
+
3
+ describe Committee::Middleware::Stub do
4
+ include Rack::Test::Methods
5
+
6
+ ParamValidationApp = Rack::Builder.new {
7
+ use Committee::Middleware::Stub,
8
+ schema: File.read("./test/data/schema.json")
9
+ run lambda { |_|
10
+ [200, {}, []]
11
+ }
12
+ }
13
+
14
+ def app
15
+ ParamValidationApp
16
+ end
17
+
18
+ it "responds with a stubbed response" do
19
+ get "/apps/heroku-api"
20
+ assert_equal 200, last_response.status
21
+ data = MultiJson.decode(last_response.body)
22
+ assert_equal ValidApp.keys.sort, data.keys.sort
23
+ end
24
+ end
@@ -0,0 +1,74 @@
1
+ require_relative "test_helper"
2
+
3
+ describe Committee::ParamValidator do
4
+ before do
5
+ @schema = Committee::Schema.new(File.read("./test/data/schema.json"))
6
+ # POST /account/app-transfers
7
+ @link_schema = @schema["app-transfer"]["links"][0]
8
+ end
9
+
10
+ it "passes through a valid request" do
11
+ params = {
12
+ "app" => "heroku-api",
13
+ "recipient" => "owner@heroku.com",
14
+ }
15
+ Committee::ParamValidator.new(params, @schema, @link_schema).call
16
+ end
17
+
18
+ it "detects a missing parameter" do
19
+ e = assert_raises(Committee::InvalidParams) do
20
+ Committee::ParamValidator.new({}, @schema, @link_schema).call
21
+ end
22
+ message = "Require params: app, recipient."
23
+ assert_equal message, e.message
24
+ end
25
+
26
+ it "detects an extraneous parameter" do
27
+ params = {
28
+ "app" => "heroku-api",
29
+ "cloud" => "production",
30
+ "recipient" => "owner@heroku.com",
31
+ }
32
+ e = assert_raises(Committee::InvalidParams) do
33
+ Committee::ParamValidator.new(params, @schema, @link_schema).call
34
+ end
35
+ message = "Unknown params: cloud."
36
+ assert_equal message, e.message
37
+ end
38
+
39
+ it "detects a parameter of the wrong type" do
40
+ params = {
41
+ "app" => "heroku-api",
42
+ "recipient" => 123,
43
+ }
44
+ e = assert_raises(Committee::InvalidParams) do
45
+ Committee::ParamValidator.new(params, @schema, @link_schema).call
46
+ end
47
+ message = %{Invalid type for key "recipient": expected 123 to be ["string"].}
48
+ assert_equal message, e.message
49
+ end
50
+
51
+ it "detects a parameter of the wrong format" do
52
+ params = {
53
+ "app" => "heroku-api",
54
+ "recipient" => "not-email",
55
+ }
56
+ e = assert_raises(Committee::InvalidParams) do
57
+ Committee::ParamValidator.new(params, @schema, @link_schema).call
58
+ end
59
+ message = %{Invalid format for key "recipient": expected "not-email" to be "email".}
60
+ assert_equal message, e.message
61
+ end
62
+
63
+ it "detects a parameter of the wrong pattern" do
64
+ params = {
65
+ "name" => "%@!"
66
+ }
67
+ link_schema = @schema["app"]["links"][0]
68
+ e = assert_raises(Committee::InvalidParams) do
69
+ Committee::ParamValidator.new(params, @schema, link_schema).call
70
+ end
71
+ message = %{Invalid pattern for key "name": expected %@! to match "(?-mix:^[a-z][a-z0-9-]{3,30}$)".}
72
+ assert_equal message, e.message
73
+ end
74
+ end
@@ -0,0 +1,66 @@
1
+ require_relative "test_helper"
2
+
3
+ require "stringio"
4
+
5
+ describe Committee::RequestUnpacker do
6
+ it "unpacks JSON on Content-Type: application/json" do
7
+ env = {
8
+ "CONTENT_TYPE" => "application/json",
9
+ "rack.input" => StringIO.new('{"x":"y"}'),
10
+ }
11
+ request = Rack::Request.new(env)
12
+ params = Committee::RequestUnpacker.new(request).call
13
+ assert_equal({ "x" => "y" }, params)
14
+ end
15
+
16
+ it "unpacks JSON on no Content-Type" do
17
+ env = {
18
+ "rack.input" => StringIO.new('{"x":"y"}'),
19
+ }
20
+ request = Rack::Request.new(env)
21
+ params = Committee::RequestUnpacker.new(request).call
22
+ assert_equal({ "x" => "y" }, params)
23
+ end
24
+
25
+ it "unpacks an empty hash on an empty request body" do
26
+ env = {
27
+ "CONTENT_TYPE" => "application/json",
28
+ "rack.input" => StringIO.new(""),
29
+ }
30
+ request = Rack::Request.new(env)
31
+ params = Committee::RequestUnpacker.new(request).call
32
+ assert_equal({}, params)
33
+ end
34
+
35
+ it "unpacks params on Content-Type: application/x-www-form-urlencoded" do
36
+ env = {
37
+ "CONTENT_TYPE" => "application/x-www-form-urlencoded",
38
+ "rack.input" => StringIO.new("x=y"),
39
+ }
40
+ request = Rack::Request.new(env)
41
+ params = Committee::RequestUnpacker.new(request).call
42
+ assert_equal({ "x" => "y" }, params)
43
+ end
44
+
45
+ it "errors if JSON is not an object" do
46
+ env = {
47
+ "CONTENT_TYPE" => "application/json",
48
+ "rack.input" => StringIO.new('[2]'),
49
+ }
50
+ request = Rack::Request.new(env)
51
+ assert_raises(Committee::BadRequest) do
52
+ Committee::RequestUnpacker.new(request).call
53
+ end
54
+ end
55
+
56
+ it "errors on an unknown Content-Type" do
57
+ env = {
58
+ "CONTENT_TYPE" => "application/whats-this",
59
+ "rack.input" => StringIO.new('{"x":"y"}'),
60
+ }
61
+ request = Rack::Request.new(env)
62
+ assert_raises(Committee::BadRequest) do
63
+ Committee::RequestUnpacker.new(request).call
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,32 @@
1
+ require_relative "test_helper"
2
+
3
+ describe Committee::ResponseGenerator do
4
+ before do
5
+ @schema = Committee::Schema.new(File.read("./test/data/schema.json"))
6
+ @generator = Committee::ResponseGenerator.new(
7
+ @schema,
8
+ @schema["app"],
9
+ @schema["app"]["links"][0]
10
+ )
11
+ end
12
+
13
+ it "generates string properties" do
14
+ data = @generator.call
15
+ assert data["name"].is_a?(String)
16
+ end
17
+
18
+ it "generates non-string properties" do
19
+ data = @generator.call
20
+ assert [FalseClass, TrueClass].include?(data["maintenance"].class)
21
+ end
22
+
23
+ it "wraps list data in an array" do
24
+ @generator = Committee::ResponseGenerator.new(
25
+ @schema,
26
+ @schema["app"],
27
+ @schema["app"]["links"][3]
28
+ )
29
+ data = @generator.call
30
+ assert data.is_a?(Array)
31
+ end
32
+ end
@@ -0,0 +1,75 @@
1
+ require_relative "test_helper"
2
+
3
+ describe Committee::ResponseValidator do
4
+ before do
5
+ @data = ValidApp.dup
6
+ @schema = Committee::Schema.new(File.read("./test/data/schema.json"))
7
+ # GET /apps/:id
8
+ @link_schema = @schema["app"]["links"][2]
9
+ end
10
+
11
+ it "passes through a valid response" do
12
+ call
13
+ end
14
+
15
+ it "passes through a valid list response" do
16
+ @data = [@data]
17
+ # GET /apps
18
+ @link_schema = @schema["app"]["links"][3]
19
+ call
20
+ end
21
+
22
+ it "detects an improperly formatted list response" do
23
+ # GET /apps
24
+ @link_schema = @schema["app"]["links"][3]
25
+ e = assert_raises(Committee::InvalidResponse) { call }
26
+ message = "List endpoints must return an array of objects."
27
+ assert_equal message, e.message
28
+ end
29
+
30
+ it "detects missing keys in response" do
31
+ @data.delete("name")
32
+ e = assert_raises(Committee::InvalidResponse) { call }
33
+ message = %r{Missing keys in response: name.}
34
+ assert_match message, e.message
35
+ end
36
+
37
+ it "detects extra keys in response" do
38
+ @data.merge!("tier" => "important")
39
+ e = assert_raises(Committee::InvalidResponse) { call }
40
+ message = %r{Extra keys in response: tier.}
41
+ assert_match message, e.message
42
+ end
43
+
44
+ it "detects mismatched types" do
45
+ @data.merge!("maintenance" => "not-bool")
46
+ e = assert_raises(Committee::InvalidResponse) { call }
47
+ message = %{Invalid type at "maintenance": expected not-bool to be ["boolean"] (was: string).}
48
+ assert_equal message, e.message
49
+ end
50
+
51
+ it "detects bad formats" do
52
+ @data.merge!("id" => "123")
53
+ e = assert_raises(Committee::InvalidResponse) { call }
54
+ message = %{Invalid format at "id": expected "123" to be "uuid".}
55
+ assert_equal message, e.message
56
+ end
57
+
58
+ it "detects bad patterns" do
59
+ @data.merge!("name" => "%@!")
60
+ e = assert_raises(Committee::InvalidResponse) { call }
61
+ message = %{Invalid pattern at "name": expected %@! to match "(?-mix:^[a-z][a-z0-9-]{3,30}$)".}
62
+ assert_equal message, e.message
63
+ end
64
+
65
+ private
66
+
67
+ def call
68
+ Committee::ResponseValidator.new(
69
+ @data,
70
+ @schema,
71
+ @link_schema,
72
+ @schema["app"]
73
+ ).call
74
+ end
75
+ end
@@ -0,0 +1,17 @@
1
+ require_relative "test_helper"
2
+
3
+ describe Committee::Router do
4
+ before do
5
+ data = File.read("./test/data/schema.json")
6
+ schema = Committee::Schema.new(data)
7
+ @router = Committee::Router.new(schema)
8
+ end
9
+
10
+ it "builds routes without parameters" do
11
+ assert @router.routes?("GET", "/apps")
12
+ end
13
+
14
+ it "builds routes with parameters" do
15
+ assert @router.routes?("GET", "/apps/123")
16
+ end
17
+ end
@@ -0,0 +1,36 @@
1
+ require_relative "test_helper"
2
+
3
+ describe Committee::Schema do
4
+ before do
5
+ data = File.read("./test/data/schema.json")
6
+ @schema = Committee::Schema.new(data)
7
+ end
8
+
9
+ it "is enumerable" do
10
+ arr = @schema.to_a
11
+ assert_equal "account", arr[0][0]
12
+ assert arr[0][1].is_a?(Hash)
13
+ end
14
+
15
+ it "can lookup definitions" do
16
+ expected = {
17
+ "default" => nil,
18
+ "description" => "slug size in bytes of app",
19
+ "example" => 0,
20
+ "readOnly" => true,
21
+ "type" => ["integer", "null"]
22
+ }
23
+ assert_equal expected, @schema.find("#/definitions/app/definitions/slug_size")
24
+ end
25
+
26
+ it "raises error on a non-existent definition" do
27
+ assert_raises(Committee::ReferenceNotFound) do
28
+ @schema.find("/schema/app#/definitions/bad_field")
29
+ end
30
+ end
31
+
32
+ it "materializes regexes" do
33
+ definition = @schema.find("#/definitions/app/definitions/git_url")
34
+ assert definition["pattern"].is_a?(Regexp)
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ require "minitest"
2
+ require "minitest/spec"
3
+ require 'minitest/pride'
4
+ require "minitest/autorun"
5
+
6
+ require "bundler/setup"
7
+ Bundler.require(:development)
8
+
9
+ require_relative "../lib/committee"
10
+
11
+ ValidApp = {
12
+ "archived_at" => "2012-01-01T12:00:00Z",
13
+ "buildpack_provided_description" => "Ruby/Rack",
14
+ "created_at" => "2012-01-01T12:00:00Z",
15
+ "git_url" => "git@heroku.com/example.git",
16
+ "id" => "01234567-89ab-cdef-0123-456789abcdef",
17
+ "maintenance" => false,
18
+ "name" => "example",
19
+ "owner" => {
20
+ "email" => "username@example.com",
21
+ "id" => "01234567-89ab-cdef-0123-456789abcdef"
22
+ },
23
+ "region" => {
24
+ "id" => "01234567-89ab-cdef-0123-456789abcdef",
25
+ "name" => "us"
26
+ },
27
+ "released_at" => "2012-01-01T12:00:00Z",
28
+ "repo_size" => 0,
29
+ "slug_size" => 0,
30
+ "stack" => {
31
+ "id" => "01234567-89ab-cdef-0123-456789abcdef",
32
+ "name" => "cedar"
33
+ },
34
+ "updated_at" => "2012-01-01T12:00:00Z",
35
+ "web_url" => "http://example.herokuapp.com"
36
+ }.freeze
metadata ADDED
@@ -0,0 +1,149 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: committee
3
+ version: !ruby/object:Gem::Version
4
+ version: '0.1'
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Brandur
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-12-18 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: multi_json
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>'
20
+ - !ruby/object:Gem::Version
21
+ version: '0.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>'
28
+ - !ruby/object:Gem::Version
29
+ version: '0.0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: rack
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>'
36
+ - !ruby/object:Gem::Version
37
+ version: '0.0'
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>'
44
+ - !ruby/object:Gem::Version
45
+ version: '0.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: minitest
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: rack-test
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ type: :development
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ - !ruby/object:Gem::Dependency
79
+ name: rake
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ! '>='
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ type: :development
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ description:
95
+ email: brandur@mutelight.org
96
+ executables:
97
+ - committee-stub
98
+ extensions: []
99
+ extra_rdoc_files: []
100
+ files:
101
+ - lib/committee/errors.rb
102
+ - lib/committee/middleware/base.rb
103
+ - lib/committee/middleware/request_validation.rb
104
+ - lib/committee/middleware/response_validation.rb
105
+ - lib/committee/middleware/stub.rb
106
+ - lib/committee/param_validator.rb
107
+ - lib/committee/request_unpacker.rb
108
+ - lib/committee/response_generator.rb
109
+ - lib/committee/response_validator.rb
110
+ - lib/committee/router.rb
111
+ - lib/committee/schema.rb
112
+ - lib/committee.rb
113
+ - test/middleware/request_validation_test.rb
114
+ - test/middleware/response_validation_test.rb
115
+ - test/middleware/stub_test.rb
116
+ - test/param_validator_test.rb
117
+ - test/request_unpacker_test.rb
118
+ - test/response_generator_test.rb
119
+ - test/response_validator_test.rb
120
+ - test/router_test.rb
121
+ - test/schema_test.rb
122
+ - test/test_helper.rb
123
+ - bin/committee-stub
124
+ homepage: https://github.com/brandur/rack-committee
125
+ licenses:
126
+ - MIT
127
+ post_install_message:
128
+ rdoc_options: []
129
+ require_paths:
130
+ - lib
131
+ required_ruby_version: !ruby/object:Gem::Requirement
132
+ none: false
133
+ requirements:
134
+ - - ! '>='
135
+ - !ruby/object:Gem::Version
136
+ version: '0'
137
+ required_rubygems_version: !ruby/object:Gem::Requirement
138
+ none: false
139
+ requirements:
140
+ - - ! '>='
141
+ - !ruby/object:Gem::Version
142
+ version: '0'
143
+ requirements: []
144
+ rubyforge_project:
145
+ rubygems_version: 1.8.24
146
+ signing_key:
147
+ specification_version: 3
148
+ summary: A collection of middleware to support JSON Schema.
149
+ test_files: []