committee 0.1

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.
@@ -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: []