Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OpenAPI3 support response validations #159

Merged
merged 5 commits into from
Dec 10, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/committee.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
require_relative "committee/schema_validator/open_api_3/router"
require_relative "committee/schema_validator/open_api_3/operation_object"
require_relative "committee/schema_validator/open_api_3/request_validator"
require_relative "committee/schema_validator/open_api_3/response_validator"
require_relative "committee/schema_validator/open_api_3/string_params_coercer"
require_relative "committee/schema_validator/hyper_schema"
require_relative "committee/schema_validator/hyper_schema/request_validator"
Expand Down
3 changes: 2 additions & 1 deletion lib/committee/middleware/response_validation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ def initialize(app, options = {})
def handle(request)
status, headers, response = @app.call(request.env)

build_schema_validator(request).response_validate(status, headers, response) if validate?(status)
v = build_schema_validator(request)
v.response_validate(status, headers, response) if v.link_exist? && validate?(status)

[status, headers, response]
rescue Committee::InvalidResponse
Expand Down
9 changes: 9 additions & 0 deletions lib/committee/schema_validator/open_api_3.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ def request_validate(request)
# parameter_coerce!(request, link, "rack.request.query_hash") if link_exist? && !request.GET.nil? && !link.schema.nil?
end

def response_validate(status, headers, response)
full_body = ""
response.each do |chunk|
full_body << chunk
end
data = JSON.parse(full_body)
Committee::SchemaValidator::OpenAPI3::ResponseValidator.new(@operation_object, validator_option).call(status, headers, data)
end

def link_exist?
!@operation_object.nil?
end
Expand Down
89 changes: 84 additions & 5 deletions lib/committee/schema_validator/open_api_3/operation_object.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
module Committee
class SchemaValidator::OpenAPI3::OperationObject
# TODO: anyOf support

# @param oas_parser_endpoint [OasParser::Endpoint]
def initialize(oas_parser_endpoint)
@oas_parser_endpoint = oas_parser_endpoint
Expand All @@ -12,31 +14,100 @@ def coerce_query_parameter_object(name, value)
coerce_value(value, query_parameter_object)
end

def validate(params)
def validate_request_params(params)
case oas_parser_endpoint.method
when 'get'
# TODO: get validation support
when 'post'
validate_post_params(params)
validate_post_request_params(params)
else
raise "OpenAPI3 not support #{oas_parser_endpoint.method} method"
end
end

def validate_response_params(status, content_type, data)
ro = response_object(status)
media_type_object = ro.content[content_type] # TODO: support media type range like `text/*` because it's OpenAPI3 definition
return unless media_type_object # TODO: raise error option

# media_type_object like {schema: {type: 'object', properties: {....}}}
# OasParser::Parameter check root hash 'properties' so we should flatten.
# But, array object 'items' properties check ['schema']['items'] so we mustn't flatten :(
media_type_object = media_type_object['schema'] if media_type_object['schema'] && media_type_object['schema']['type'] == 'object'
parameter = OasParser::Parameter.new(ro, media_type_object)
check_parameter_type('response', data, parameter)
end

private

# @return [OasParser::Endpoint]
attr_reader :oas_parser_endpoint

def validate_post_params(params)
def validate_post_request_params(params)
params.each do |name, value|
parameter = request_body_properties[name]
check_parameter_type(name, value, parameter)
end
end

def check_parameter_type(name, value, parameter)
# TODO: check object parameter and nested object parameter
raise InvalidRequest, "invalid parameter type #{name} #{value} #{value.class} #{parameter.type}" if parameter.type == "string" && !value.is_a?(String)
return unless parameter # not definition in OpenAPI3

if value.nil?
return if parameter.raw["nullable"]

raise InvalidRequest, "invalid parameter type #{name} #{value} #{value.class} #{parameter.type}"
end

case parameter.type
when "string"
return if value.is_a?(String)
when "integer"
return if value.is_a?(Integer)
when "boolean"
return if value.is_a?(TrueClass)
return if value.is_a?(FalseClass)
when "number"
return if value.is_a?(Integer)
return if value.is_a?(Numeric)
when "object"
return if value.is_a?(Hash) && validate_object(name, value, parameter)
when "array"
return if value.is_a?(Array) && validate_array(name, value, parameter)
else
# TODO: unknown type support
end

raise InvalidRequest, "invalid parameter type #{name} #{value} #{value.class} #{parameter.type}"
end

# @param [OasParser::Parameter] parameter parameter.type = array
# @param [Array<Object>] values
def validate_array(object_name, values, parameter)
items = [OasParser::Parameter.new(parameter, parameter.items)] # TODO: multi pattern items support (anyOf, allOf)

values.each do |v|
item = items.first # TODO: multi pattern items support (anyOf, allOf)
check_parameter_type(object_name, v, item)
end
end

def validate_object(_object_name, values, parameter)
properties_hash = parameter.properties.map{ |po| [po.name, po]}.to_h
requireds_hash = properties_hash.select{ |_k,v| v.required}

values.each do |name, value|
parameter = properties_hash[name]
check_parameter_type(name, value, parameter)

requireds_hash.delete(name)
end

unless requireds_hash.empty?
raise InvalidRequest, "required parameters #{requireds_hash.keys.join(",")} not exist"
end

true
end

def request_body_properties
Expand Down Expand Up @@ -82,5 +153,13 @@ def coerce_value(value, query_parameter_object)

[nil, false]
end

# check responses object
def response_object(code)
# TODO: support error field
# TODO: support ignore response because oas_parser ignore default and raise error
# unless oas_parser_endpoint.raw['responses'][code] && ignore_not_exist_response_definition
oas_parser_endpoint.response_by_code(code.to_s)
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def call(request, params, headers)
# TODO: support @check_content_type
# check_content_type!(request, params) if @check_content_type

@operation_object.validate(params)
@operation_object.validate_request_params(params)

# TODO: support header
end
Expand Down
35 changes: 35 additions & 0 deletions lib/committee/schema_validator/open_api_3/response_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
module Committee
class SchemaValidator::OpenAPI3::ResponseValidator
attr_reader :validate_errors

# @param [Committee::SchemaValidator::OpenAPI3::OperationObject] operation_object
def initialize(operation_object, validator_option)
@operation_object = operation_object
@validate_errors = validator_option.validate_errors
end

def self.validate?(status, options = {})
validate_errors = options[:validate_errors]

status != 204 && (validate_errors || (200...300).include?(status))
end

def call(status, headers, data)
return unless self.class.validate?(status, validate_errors: validate_errors)

content_type = headers['Content-Type'].to_s.split(";").first.to_s
operation_object.validate_response_params(status, content_type, data)
end

private

# @return [Committee::SchemaValidator::OpenAPI3::OperationObject]
attr_reader :operation_object

def check_content_type!(response)
# TODO: fix
# OpenAPI3 support multi content type definitions, so we should get OperationObject by content type and this function don't need
# We should support if content exist and not exist content-type definition, raise error (if not exist content, we don't raise error)
end
end
end
86 changes: 83 additions & 3 deletions test/data/openapi3/normal.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,14 @@ paths:
content:
application/json:
schema:
type: array
items:
type: string
type: object
properties:
string_1:
type: string
array_1:
type: array
items:
type: string
post:
description: new characters
responses:
Expand Down Expand Up @@ -115,3 +120,78 @@ paths:
type: array
items:
type: string
/validate:
post:
description: validate test data
requestBody:
content:
application/json:
schema:
type: object
properties:
string:
type: string
integer:
type: integer
boolean:
type: boolean
number:
type: number
array:
type: array
items:
type: integer
object_1:
type: object
properties:
string_1:
nullable: true
type: string
integer_1:
nullable: true
type: integer
boolean_1:
nullable: true
type: boolean
number_1:
nullable: true
type: number
object_2:
type: object
required:
- string_2
- integer_2
- boolean_2
- number_2
properties:
string_2:
type: string
integer_2:
type: integer
boolean_2:
type: boolean
number_2:
type: number
responses:
'200':
description: success
content:
application/json:
schema:
type: object
properties:
string:
type: string
'204':
description: no content
/validate_response_array:
get:
responses:
'200':
description: success
content:
application/json:
schema:
type: array
items:
type: string
87 changes: 87 additions & 0 deletions test/middleware/response_validation_open_api_3_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
require_relative "../test_helper"

describe Committee::Middleware::ResponseValidation do
include Rack::Test::Methods

CHARACTERS_RESPONSE = {"Otonokizaka" => ["Honoka.Kousaka"]}

def app
@app
end

it "passes through a valid response" do
@app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), {}, open_api_3: open_api_3_schema)
get "/characters"
assert_equal 200, last_response.status
end

it "passes through a invalid json" do
@app = new_response_rack("not_json", {}, open_api_3: open_api_3_schema)

get "/characters"

assert_equal 500, last_response.status
assert_equal "{\"id\":\"invalid_response\",\"message\":\"Response wasn't valid JSON.\"}", last_response.body
end

it "passes through not definition" do
@app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), {}, open_api_3: open_api_3_schema)
get "/no_data"
assert_equal 200, last_response.status
end

it "detects a response invalid due to schema" do
@app = new_response_rack("[]", {}, open_api_3: open_api_3_schema)

e = assert_raises(Committee::InvalidRequest) { # TODO: change invalid request
get "/characters"
}

assert_match(/invalid parameter type response/i, e.message)
end

it "passes through a 204 (no content) response" do
@app = new_response_rack("", {}, {open_api_3: open_api_3_schema}, {status: 204})
post "/validate"
assert_equal 204, last_response.status
end

it "passes through a valid response with prefix" do
@app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), {}, open_api_3: open_api_3_schema, prefix: "/v1")
get "/v1/characters"
assert_equal 200, last_response.status
end

it "passes through a invalid json with prefix" do
@app = new_response_rack("not_json", {}, open_api_3: open_api_3_schema, prefix: "/v1")

get "/v1/characters"

# TODO: support prefix
assert_equal 200, last_response.status
# assert_equal 500, last_response.status
# assert_equal "{\"id\":\"invalid_response\",\"message\":\"Response wasn't valid JSON.\"}", last_response.body
end

it "rescues JSON errors" do
@app = new_response_rack("_42", {}, open_api_3: open_api_3_schema, raise: true)
assert_raises(Committee::InvalidResponse) do
get "/characters"
end
end

private

def new_response_rack(response, headers = {}, options = {}, rack_options = {})
status = rack_options[:status] || 200
headers = {
"Content-Type" => "application/json"
}.merge(headers)
Rack::Builder.new {
use Committee::Middleware::ResponseValidation, options
run lambda { |_|
[options.fetch(:app_status, status), headers, [response]]
}
}
end
end
Loading