rack-json_schema 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/CHANGELOG.md +52 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +200 -0
- data/Rakefile +1 -0
- data/bin/specup +30 -0
- data/lib/rack-json_schema.rb +1 -0
- data/lib/rack/json_schema.rb +15 -0
- data/lib/rack/json_schema/base_request_handler.rb +64 -0
- data/lib/rack/json_schema/docs.rb +38 -0
- data/lib/rack/json_schema/error.rb +30 -0
- data/lib/rack/json_schema/error_handler.rb +19 -0
- data/lib/rack/json_schema/mock.rb +93 -0
- data/lib/rack/json_schema/request_validation.rb +184 -0
- data/lib/rack/json_schema/response_validation.rb +120 -0
- data/lib/rack/json_schema/schema.rb +55 -0
- data/lib/rack/json_schema/version.rb +5 -0
- data/rack-json_schema.gemspec +31 -0
- data/spec/fixtures/schema.json +152 -0
- data/spec/rack/spec/docs_spec.rb +93 -0
- data/spec/rack/spec/mock_spec.rb +126 -0
- data/spec/rack/spec_spec.rb +221 -0
- data/spec/spec_helper.rb +11 -0
- metadata +229 -0
@@ -0,0 +1,30 @@
|
|
1
|
+
module Rack
|
2
|
+
module JsonSchema
|
3
|
+
class Error < StandardError
|
4
|
+
# @return [Array] Rack response
|
5
|
+
def to_rack_response
|
6
|
+
[status, headers, [body]]
|
7
|
+
end
|
8
|
+
|
9
|
+
private
|
10
|
+
|
11
|
+
# @note Override this
|
12
|
+
def status
|
13
|
+
500
|
14
|
+
end
|
15
|
+
|
16
|
+
# @note Override this
|
17
|
+
def id
|
18
|
+
"internal_server_error"
|
19
|
+
end
|
20
|
+
|
21
|
+
def headers
|
22
|
+
{ "Content-Type" => "application/json" }
|
23
|
+
end
|
24
|
+
|
25
|
+
def body
|
26
|
+
MultiJson.encode({ id: id, message: message }, pretty: true) + "\n"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Rack
|
2
|
+
module JsonSchema
|
3
|
+
class ErrorHandler
|
4
|
+
# Behaves as a rack middleware
|
5
|
+
# @param app [Object] Rack application
|
6
|
+
def initialize(app)
|
7
|
+
@app = app
|
8
|
+
end
|
9
|
+
|
10
|
+
# Behaves as a rack middleware
|
11
|
+
# @param env [Hash] Rack env
|
12
|
+
def call(env)
|
13
|
+
@app.call(env)
|
14
|
+
rescue Rack::JsonSchema::Error => exception
|
15
|
+
exception.to_rack_response
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
module Rack
|
2
|
+
module JsonSchema
|
3
|
+
class Mock
|
4
|
+
# Behaves as a rack-middleware
|
5
|
+
# @param app [Object] Rack application
|
6
|
+
# @param schema [Hash] Schema object written in JSON schema format
|
7
|
+
# @raise [JsonSchema::SchemaError]
|
8
|
+
def initialize(app, schema: nil)
|
9
|
+
@app = app
|
10
|
+
@schema = Schema.new(schema)
|
11
|
+
end
|
12
|
+
|
13
|
+
# @param env [Hash] Rack env
|
14
|
+
def call(env)
|
15
|
+
RequestHandler.call(app: @app, env: env, schema: @schema)
|
16
|
+
end
|
17
|
+
|
18
|
+
class RequestHandler < BaseRequestHandler
|
19
|
+
# @param app [Object] Rack application
|
20
|
+
def initialize(app: nil, **args)
|
21
|
+
@app = app
|
22
|
+
super(**args)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Returns dummy response if JSON schema is defined for the current link
|
26
|
+
# @return [Array] Rack response
|
27
|
+
def call
|
28
|
+
if has_link_for_current_action?
|
29
|
+
dummy_response
|
30
|
+
else
|
31
|
+
@app.call(@env)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def dummy_response
|
38
|
+
[dummy_status, dummy_headers, [dummy_body]]
|
39
|
+
end
|
40
|
+
|
41
|
+
def dummy_status
|
42
|
+
method == "POST" ? 201 : 200
|
43
|
+
end
|
44
|
+
|
45
|
+
def dummy_headers
|
46
|
+
{ "Content-Type" => "application/json" }
|
47
|
+
end
|
48
|
+
|
49
|
+
def dummy_body
|
50
|
+
document = ResponseGenerator.call(schema_for_current_link)
|
51
|
+
document = [document] if has_list_data?
|
52
|
+
MultiJson.encode(document, pretty: true) + "\n"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
class ResponseGenerator
|
57
|
+
# Generates example response Hash from given schema
|
58
|
+
# @return [Hash]
|
59
|
+
# @example
|
60
|
+
# Rack::JsonSchema::Mock::ResponseGenerator(schema) #=> { "id" => 1, "name" => "example" }
|
61
|
+
def self.call(schema)
|
62
|
+
schema.properties.inject({}) do |result, (key, value)|
|
63
|
+
result.merge(
|
64
|
+
key => case
|
65
|
+
when !value.properties.empty?
|
66
|
+
call(value)
|
67
|
+
when !value.data["example"].nil?
|
68
|
+
value.data["example"]
|
69
|
+
when value.type.include?("null")
|
70
|
+
nil
|
71
|
+
else
|
72
|
+
raise ExampleNotFound, "No example found for #{schema.pointer}/#{key}"
|
73
|
+
end
|
74
|
+
)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
class Error < Error
|
80
|
+
end
|
81
|
+
|
82
|
+
class ExampleNotFound < Error
|
83
|
+
def id
|
84
|
+
"example_not_found"
|
85
|
+
end
|
86
|
+
|
87
|
+
def status
|
88
|
+
500
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,184 @@
|
|
1
|
+
module Rack
|
2
|
+
module JsonSchema
|
3
|
+
class RequestValidation
|
4
|
+
# Behaves as a rack-middleware
|
5
|
+
# @param app [Object] Rack application
|
6
|
+
# @param schema [Hash] Schema object written in JSON schema format
|
7
|
+
# @raise [JsonSchema::SchemaError]
|
8
|
+
def initialize(app, schema: nil)
|
9
|
+
@app = app
|
10
|
+
@schema = Schema.new(schema)
|
11
|
+
end
|
12
|
+
|
13
|
+
# @raise [Rack::JsonSchema::RequestValidation::Error] Raises if given request is invalid to JSON Schema
|
14
|
+
# @param env [Hash] Rack env
|
15
|
+
def call(env)
|
16
|
+
Validator.call(env: env, schema: @schema)
|
17
|
+
@app.call(env)
|
18
|
+
end
|
19
|
+
|
20
|
+
class Validator < BaseRequestHandler
|
21
|
+
# Utility wrapper method
|
22
|
+
def self.call(**args)
|
23
|
+
new(**args).call
|
24
|
+
end
|
25
|
+
|
26
|
+
# @param env [Hash] Rack env
|
27
|
+
# @param schema [JsonSchema::Schema] Schema object
|
28
|
+
def initialize(env: nil, schema: nil)
|
29
|
+
@env = env
|
30
|
+
@schema = schema
|
31
|
+
end
|
32
|
+
|
33
|
+
# Raises an error if any error detected
|
34
|
+
# @raise [Rack::JsonSchema::RequestValidation::Error]
|
35
|
+
def call
|
36
|
+
case
|
37
|
+
when !has_link_for_current_action?
|
38
|
+
raise LinkNotFound
|
39
|
+
when has_body? && !has_valid_content_type?
|
40
|
+
raise InvalidContentType
|
41
|
+
when has_body? && !has_valid_json?
|
42
|
+
raise InvalidJson
|
43
|
+
when has_body? && has_schema? && !has_valid_parameter?
|
44
|
+
raise InvalidParameter, "Invalid request.\n#{schema_validation_error_message}"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def has_valid_json?
|
51
|
+
parameters
|
52
|
+
true
|
53
|
+
rescue MultiJson::ParseError
|
54
|
+
false
|
55
|
+
end
|
56
|
+
|
57
|
+
# @return [true, false] True if request parameters are all valid
|
58
|
+
def has_valid_parameter?
|
59
|
+
schema_validation_result[0]
|
60
|
+
end
|
61
|
+
|
62
|
+
# @return [true, false] True if any schema is defined for the current action
|
63
|
+
def has_schema?
|
64
|
+
!!link.schema
|
65
|
+
end
|
66
|
+
|
67
|
+
# @return [true, false] True if request body is not empty
|
68
|
+
def has_body?
|
69
|
+
!body.empty?
|
70
|
+
end
|
71
|
+
|
72
|
+
# @return [true, false] True if no or matched content type given
|
73
|
+
def has_valid_content_type?
|
74
|
+
mime_type.nil? || Rack::Mime.match?(link.enc_type, mime_type)
|
75
|
+
end
|
76
|
+
|
77
|
+
# @return [Array] A result of schema validation for the current action
|
78
|
+
def schema_validation_result
|
79
|
+
@schema_validation_result ||= link.schema.validate(parameters)
|
80
|
+
end
|
81
|
+
|
82
|
+
# @return [Array] Errors of schema validation
|
83
|
+
def schema_validation_errors
|
84
|
+
schema_validation_result[1]
|
85
|
+
end
|
86
|
+
|
87
|
+
# @return [String] Joined error message to the result of schema validation
|
88
|
+
def schema_validation_error_message
|
89
|
+
::JsonSchema::SchemaError.aggregate(schema_validation_errors).join("\n")
|
90
|
+
end
|
91
|
+
|
92
|
+
# @return [String, nil] Request MIME Type specified in Content-Type header field
|
93
|
+
# @example
|
94
|
+
# mime_type #=> "application/json"
|
95
|
+
def mime_type
|
96
|
+
request.content_type.split(";").first if request.content_type
|
97
|
+
end
|
98
|
+
|
99
|
+
# @return [String] request body
|
100
|
+
def body
|
101
|
+
if instance_variable_defined?(:@body)
|
102
|
+
@body
|
103
|
+
else
|
104
|
+
@body = request.body.read
|
105
|
+
request.body.rewind
|
106
|
+
@body
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
# @return [Hash] Request parameters decoded from JSON
|
111
|
+
# @raise [MultiJson::ParseError]
|
112
|
+
def parameters
|
113
|
+
@parameters ||= begin
|
114
|
+
if has_body?
|
115
|
+
MultiJson.decode(body)
|
116
|
+
else
|
117
|
+
{}
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
# Base error class for Rack::JsonSchema::RequestValidation
|
124
|
+
class Error < Error
|
125
|
+
end
|
126
|
+
|
127
|
+
# Error class for case when no link defined for given request
|
128
|
+
class LinkNotFound < Error
|
129
|
+
def initialize
|
130
|
+
super("Not found")
|
131
|
+
end
|
132
|
+
|
133
|
+
def status
|
134
|
+
404
|
135
|
+
end
|
136
|
+
|
137
|
+
def id
|
138
|
+
"link_not_found"
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
# Error class for invalid request content type
|
143
|
+
class InvalidContentType < Error
|
144
|
+
def initialize
|
145
|
+
super("Invalid content type")
|
146
|
+
end
|
147
|
+
|
148
|
+
def status
|
149
|
+
400
|
150
|
+
end
|
151
|
+
|
152
|
+
def id
|
153
|
+
"invalid_content_type"
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# Error class for invalid JSON
|
158
|
+
class InvalidJson < Error
|
159
|
+
def initialize
|
160
|
+
super("Request body wasn't valid JSON")
|
161
|
+
end
|
162
|
+
|
163
|
+
def status
|
164
|
+
400
|
165
|
+
end
|
166
|
+
|
167
|
+
def id
|
168
|
+
"invalid_json"
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
# Error class for invalid request parameter
|
173
|
+
class InvalidParameter < Error
|
174
|
+
def status
|
175
|
+
400
|
176
|
+
end
|
177
|
+
|
178
|
+
def id
|
179
|
+
"invalid_parameter"
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module Rack
|
2
|
+
module JsonSchema
|
3
|
+
class ResponseValidation
|
4
|
+
# Behaves as a rack-middleware
|
5
|
+
# @param app [Object] Rack application
|
6
|
+
# @param schema [Hash] Schema object written in JSON schema format
|
7
|
+
# @raise [JsonSchema::SchemaError]
|
8
|
+
def initialize(app, schema: nil)
|
9
|
+
@app = app
|
10
|
+
@schema = Schema.new(schema)
|
11
|
+
end
|
12
|
+
|
13
|
+
# @raise [Rack::JsonSchema::ResponseValidation::Error]
|
14
|
+
# @param env [Hash] Rack env
|
15
|
+
def call(env)
|
16
|
+
@app.call(env).tap do |response|
|
17
|
+
Validator.call(env: env, response: response, schema: @schema)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
class Validator < BaseRequestHandler
|
22
|
+
# @param env [Hash] Rack env
|
23
|
+
# @param response [Array] Rack response
|
24
|
+
# @param schema [JsonSchema::Schema] Schema object
|
25
|
+
def initialize(env: nil, response: nil, schema: nil)
|
26
|
+
@env = env
|
27
|
+
@response = response
|
28
|
+
@schema = schema
|
29
|
+
end
|
30
|
+
|
31
|
+
# Raises an error if any error detected, skipping validation for non-defined link
|
32
|
+
# @raise [Rack::JsonSchema::ResponseValidation::InvalidResponse]
|
33
|
+
def call
|
34
|
+
if has_link_for_current_action?
|
35
|
+
case
|
36
|
+
when !has_json_content_type?
|
37
|
+
raise InvalidResponseContentType
|
38
|
+
when !valid?
|
39
|
+
raise InvalidResponseType, validator.errors
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# @return [true, false] True if response Content-Type is for JSON
|
45
|
+
def has_json_content_type?
|
46
|
+
%r<\Aapplication/.*json> === headers["Content-Type"]
|
47
|
+
end
|
48
|
+
|
49
|
+
# @return [true, false] True if given data is valid to the JSON schema
|
50
|
+
def valid?
|
51
|
+
validator.validate(example_item)
|
52
|
+
end
|
53
|
+
|
54
|
+
# @return [Hash] Choose an item from response data, to be validated
|
55
|
+
def example_item
|
56
|
+
if has_list_data?
|
57
|
+
data.first
|
58
|
+
else
|
59
|
+
data
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# @return [Array, Hash] Response body data, decoded from JSON
|
64
|
+
def data
|
65
|
+
MultiJson.decode(body)
|
66
|
+
end
|
67
|
+
|
68
|
+
# @return [JsonSchema::Validator]
|
69
|
+
# @note The result is memoized for returning errors in invalid case
|
70
|
+
def validator
|
71
|
+
@validator ||= ::JsonSchema::Validator.new(schema_for_current_link)
|
72
|
+
end
|
73
|
+
|
74
|
+
# @return [Hash] Response headers
|
75
|
+
def headers
|
76
|
+
@response[1]
|
77
|
+
end
|
78
|
+
|
79
|
+
# @return [String] Response body
|
80
|
+
def body
|
81
|
+
result = ""
|
82
|
+
@response[2].each {|str| result << str }
|
83
|
+
result
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Base error class for Rack::JsonSchema::ResponseValidation
|
88
|
+
class Error < Error
|
89
|
+
end
|
90
|
+
|
91
|
+
class InvalidResponseType < Error
|
92
|
+
def initialize(errors)
|
93
|
+
super ::JsonSchema::SchemaError.aggregate(errors).join(" ")
|
94
|
+
end
|
95
|
+
|
96
|
+
def id
|
97
|
+
"invalid_response_type"
|
98
|
+
end
|
99
|
+
|
100
|
+
def status
|
101
|
+
500
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
class InvalidResponseContentType < Error
|
106
|
+
def initialize
|
107
|
+
super("Response Content-Type wasn't for JSON")
|
108
|
+
end
|
109
|
+
|
110
|
+
def id
|
111
|
+
"invalid_response_content_type"
|
112
|
+
end
|
113
|
+
|
114
|
+
def status
|
115
|
+
500
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|