Skip to content

Commit aadda72

Browse files
elasti-roniNecas
authored andcommitted
response descriptions and response validation
1 parent 1f7d841 commit aadda72

File tree

7 files changed

+377
-10
lines changed

7 files changed

+377
-10
lines changed

lib/apipie/apipie_module.rb

+11
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ def self.to_swagger_json(version = nil, resource_name = nil, method_name = nil,
1818
app.to_swagger_json(version, resource_name, method_name, lang, clear_warnings)
1919
end
2020

21+
def self.json_schema_for_method_response(controller_name, method_name, return_code, allow_nulls)
22+
# note: this does not support versions (only the default version is queried)!
23+
version ||= Apipie.configuration.default_version
24+
app.json_schema_for_method_response(version, controller_name, method_name, return_code, allow_nulls)
25+
end
26+
27+
def self.json_schema_for_self_describing_class(cls, allow_nulls=true)
28+
app.json_schema_for_self_describing_class(cls, allow_nulls)
29+
end
30+
31+
2132
# all calls delegated to Apipie::Application instance
2233
def self.method_missing(method, *args, &block)
2334
app.respond_to?(method) ? app.send(method, *args, &block) : super

lib/apipie/application.rb

+9
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,15 @@ def reload_examples
255255
@recorded_examples = nil
256256
end
257257

258+
def json_schema_for_method_response(version, controller_name, method_name, return_code, allow_nulls)
259+
method = @resource_descriptions[version][controller_name].method_description(method_name)
260+
@swagger_generator.json_schema_for_method_response(method, return_code, allow_nulls)
261+
end
262+
263+
def json_schema_for_self_describing_class(cls, allow_nulls)
264+
@swagger_generator.json_schema_for_self_describing_class(cls, allow_nulls)
265+
end
266+
258267
def to_swagger_json(version, resource_name, method_name, lang, clear_warnings=false)
259268
return unless valid_search_args?(version, resource_name, method_name)
260269

lib/apipie/errors.rb

+15
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,19 @@ def to_s
5757
"Invalid parameter '#{@param}' value #{@value.inspect}: #{@error}"
5858
end
5959
end
60+
61+
class ResponseDoesNotMatchSwaggerSchema < Error
62+
def initialize(controller_name, method_name, response_code, error_messages, schema, returned_object)
63+
@controller_name = controller_name
64+
@method_name = method_name
65+
@response_code = response_code
66+
@error_messages = error_messages
67+
@schema = schema
68+
@returned_object = returned_object
69+
end
70+
71+
def to_s
72+
"Response does not match swagger schema (#{@controller_name}##{@method_name} #{@response_code}): #{@error_messages}\nSchema: #{JSON(@schema)}\nReturned object: #{@returned_object}"
73+
end
74+
end
6075
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
#----------------------------------------------------------------------------------------------
2+
# response_validation_helper.rb:
3+
#
4+
# this is an rspec utility to allow validation of responses against the swagger schema generated
5+
# from the Apipie 'returns' definition for the call.
6+
#
7+
#
8+
# to use this file in a controller rspec you should
9+
# require 'apipie/rspec/response_validation_helper' in the spec file
10+
#
11+
#
12+
# this utility provides two mechanisms: matcher-based validation and auto-validation
13+
#
14+
# matcher-based: an rspec matcher allowing 'expect(response).to match_declared_responses'
15+
# auto-validation: all responses returned from 'get', 'post', etc. are automatically tested
16+
#
17+
# ===================================
18+
# Matcher-based validation - example
19+
# ===================================
20+
# Assume the file 'my_controller_spec.rb':
21+
#
22+
# require 'apipie/rspec/response_validation_helper'
23+
#
24+
# RSpec.describe MyController, :type => :controller, :show_in_doc => true do
25+
#
26+
# describe "GET stuff with response validation" do
27+
# render_views # this makes sure the 'get' operation will actually
28+
# # return the rendered view even though this is a Controller spec
29+
#
30+
# it "does something" do
31+
# response = get :index, {format: :json}
32+
#
33+
# # the following expectation will fail if the returned object
34+
# # does not match the 'returns' declaration in the Controller,
35+
# # or if there is no 'returns' declaration for the returned
36+
# # HTTP status code
37+
# expect(response).to match_declared_responses
38+
# end
39+
# end
40+
#
41+
#
42+
# ===================================
43+
# Auto-validation
44+
# ===================================
45+
# To use auto-validation, at the beginning of the block in which you want to turn on validation:
46+
# -) turn on view rendering (by stating 'render_views')
47+
# -) turn on response validation by stating 'auto_validate_rendered_views'
48+
#
49+
# For example, assume the file 'my_controller_spec.rb':
50+
#
51+
# require 'apipie/rspec/response_validation_helper'
52+
#
53+
# RSpec.describe MyController, :type => :controller, :show_in_doc => true do
54+
#
55+
# describe "GET stuff with response validation" do
56+
# render_views
57+
# auto_validate_rendered_views
58+
#
59+
# it "does something" do
60+
# get :index, {format: :json}
61+
# end
62+
# it "does something else" do
63+
# get :another_index, {format: :json}
64+
# end
65+
# end
66+
#
67+
# describe "GET stuff without response validation" do
68+
# it "does something" do
69+
# get :index, {format: :json}
70+
# end
71+
# it "does something else" do
72+
# get :another_index, {format: :json}
73+
# end
74+
# end
75+
#
76+
#
77+
# Once this is done, responses from http operations ('get', 'post', 'delete', etc.)
78+
# will fail the test if the response structure does not match the 'returns' declaration
79+
# on the method (for the actual HTTP status code), or if there is no 'returns' declaration
80+
# for the HTTP status code.
81+
#----------------------------------------------------------------------------------------------
82+
83+
84+
#----------------------------------------------------------------------------------------------
85+
# Response validation: core logic (used by auto-validation and manual-validation mechanisms)
86+
#----------------------------------------------------------------------------------------------
87+
class ActionController::Base
88+
module Apipie::ControllerValidationHelpers
89+
# this method is injected into ActionController::Base in order to
90+
# get access to the names of the current controller, current action, as well as to the response
91+
def schema_validation_errors_for_response
92+
unprocessed_schema = Apipie::json_schema_for_method_response(controller_name, action_name, response.code, true)
93+
94+
if unprocessed_schema.nil?
95+
err = "no schema defined for #{controller_name}##{action_name}[#{response.code}]"
96+
return [nil, [err], RuntimeError.new(err)]
97+
end
98+
99+
schema = JSON.parse(JSON(unprocessed_schema))
100+
101+
error_list = JSON::Validator.fully_validate(schema, response.body, :strict => false, :version => :draft4, :json => true)
102+
103+
error_object = Apipie::ResponseDoesNotMatchSwaggerSchema.new(controller_name, action_name, response.code, error_list, schema, response.body)
104+
105+
[schema, error_list, error_object]
106+
end
107+
108+
end
109+
110+
include Apipie::ControllerValidationHelpers
111+
end
112+
113+
module Apipie
114+
def self.print_validation_errors(validation_errors, schema, response, error_object=nil)
115+
Rails.logger.warn(validation_errors.to_s)
116+
if Rails.env.test?
117+
puts "schema validation errors:"
118+
validation_errors.each { |e| puts "--> #{e.to_s}" }
119+
puts "schema: #{schema.nil? ? '<none>' : JSON(schema)}"
120+
puts "response: #{response.body}"
121+
raise error_object if error_object
122+
end
123+
end
124+
end
125+
126+
#---------------------------------
127+
# Manual-validation (RSpec matcher)
128+
#---------------------------------
129+
RSpec::Matchers.define :match_declared_responses do
130+
match do |actual|
131+
(schema, validation_errors) = subject.send(:schema_validation_errors_for_response)
132+
valid = (validation_errors == [])
133+
Apipie::print_validation_errors(validation_errors, schema, response) unless valid
134+
135+
valid
136+
end
137+
end
138+
139+
140+
#---------------------------------
141+
# Auto-validation logic
142+
#---------------------------------
143+
module RSpec::Rails::ViewRendering
144+
# Augment the RSpec DSL
145+
module ClassMethods
146+
def auto_validate_rendered_views
147+
before do
148+
@is_response_validation_on = true
149+
end
150+
151+
after do
152+
@is_response_validation_on = false
153+
end
154+
end
155+
end
156+
end
157+
158+
159+
ActionController::TestCase::Behavior.instance_eval do
160+
# instrument the 'process' method in ActionController::TestCase to enable response validation
161+
module Apipie::ResponseValidationHelpers
162+
@is_response_validation_on = false
163+
def process(*args)
164+
result = super(*args)
165+
validate_response if @is_response_validation_on
166+
167+
result
168+
end
169+
170+
def validate_response
171+
controller.send(:validate_response_and_abort_with_info_if_errors)
172+
end
173+
end
174+
175+
prepend Apipie::ResponseValidationHelpers
176+
end
177+
178+
179+
class ActionController::Base
180+
module Apipie::ControllerValidationHelpers
181+
def validate_response_and_abort_with_info_if_errors
182+
183+
(schema, validation_errors, error_object) = schema_validation_errors_for_response
184+
185+
valid = (validation_errors == [])
186+
if !valid
187+
Apipie::print_validation_errors(validation_errors, schema, response, error_object)
188+
end
189+
end
190+
end
191+
end
192+
193+

lib/apipie/swagger_generator.rb

+35-10
Original file line numberDiff line numberDiff line change
@@ -334,19 +334,31 @@ def swagger_param_type(param_desc)
334334
# Responses
335335
#--------------------------------------------------------------------------
336336

337-
def response_schema(response)
337+
def json_schema_for_method_response(method, return_code, allow_nulls)
338+
for response in method.returns
339+
return response_schema(response, allow_nulls) if response.code.to_s == return_code.to_s
340+
end
341+
nil
342+
end
343+
344+
def json_schema_for_self_describing_class(cls, allow_nulls)
345+
adapter = ResponseDescriptionAdapter.from_self_describing_class(cls)
346+
response_schema(adapter, allow_nulls)
347+
end
348+
349+
def response_schema(response, allow_nulls=false)
338350
begin
339351
# no need to warn about "missing default value for optional param" when processing response definitions
340352
prev_value = @disable_default_value_warning
341353
@disable_default_value_warning = true
342-
schema = json_schema_obj_from_params_array(response.params_ordered)
354+
schema = json_schema_obj_from_params_array(response.params_ordered, allow_nulls)
343355
ensure
344356
@disable_default_value_warning = prev_value
345357
end
346358

347359
if response.is_array? && schema
348360
schema = {
349-
type: "array",
361+
type: allow_nulls ? ["array","null"] : "array",
350362
items: schema
351363
}
352364
end
@@ -423,7 +435,7 @@ def add_missing_params(method, path)
423435
# The core routine for creating a swagger parameter definition block.
424436
# The output is slightly different when the parameter is inside a schema block.
425437
#--------------------------------------------------------------------------
426-
def swagger_atomic_param(param_desc, in_schema, name)
438+
def swagger_atomic_param(param_desc, in_schema, name, allow_nulls)
427439
def save_field(entry, openapi_key, v, apipie_key=openapi_key, translate=false)
428440
if v.key?(apipie_key)
429441
if translate
@@ -457,6 +469,10 @@ def save_field(entry, openapi_key, v, apipie_key=openapi_key, translate=false)
457469
warn_hash_without_internal_typespec(param_desc.name)
458470
end
459471

472+
if allow_nulls
473+
swagger_def[:type] = [swagger_def[:type], "null"]
474+
end
475+
460476
if !in_schema
461477
swagger_def[:in] = param_desc.options.fetch(:in, @default_value_for_param_in)
462478
swagger_def[:required] = param_desc.required if param_desc.required
@@ -487,8 +503,8 @@ def ref_to(name)
487503
end
488504

489505

490-
def json_schema_obj_from_params_array(params_array)
491-
(param_defs, required_params) = json_schema_param_defs_from_params_array(params_array)
506+
def json_schema_obj_from_params_array(params_array, allow_nulls = false)
507+
(param_defs, required_params) = json_schema_param_defs_from_params_array(params_array, allow_nulls)
492508

493509
result = {type: "object"}
494510
result[:properties] = param_defs
@@ -508,7 +524,7 @@ def gen_referenced_block_from_params_array(name, params_array)
508524
ref_to(name.to_sym)
509525
end
510526

511-
def json_schema_param_defs_from_params_array(params_array)
527+
def json_schema_param_defs_from_params_array(params_array, allow_nulls = false)
512528
param_defs = {}
513529
required_params = []
514530

@@ -526,7 +542,7 @@ def json_schema_param_defs_from_params_array(params_array)
526542
param_type = swagger_param_type(param_desc)
527543

528544
if param_type == "object" && param_desc.validator.params_ordered
529-
schema = json_schema_obj_from_params_array(param_desc.validator.params_ordered)
545+
schema = json_schema_obj_from_params_array(param_desc.validator.params_ordered, allow_nulls)
530546
if param_desc.additional_properties
531547
schema[:additionalProperties] = true
532548
end
@@ -539,9 +555,18 @@ def json_schema_param_defs_from_params_array(params_array)
539555
schema = new_schema
540556
end
541557

558+
if allow_nulls
559+
# ideally we would write schema[:type] = ["object", "null"]
560+
# but due to a bug in the json-schema gem, we need to use anyOf
561+
# see https://github.com/ruby-json-schema/json-schema/issues/404
562+
new_schema = {
563+
anyOf: [schema, {type: "null"}]
564+
}
565+
schema = new_schema
566+
end
542567
param_defs[param_desc.name.to_sym] = schema if !schema.nil?
543568
else
544-
param_defs[param_desc.name.to_sym] = swagger_atomic_param(param_desc, true, nil)
569+
param_defs[param_desc.name.to_sym] = swagger_atomic_param(param_desc, true, nil, allow_nulls)
545570
end
546571
end
547572

@@ -619,7 +644,7 @@ def add_params_from_hash(swagger_params_array, param_defs, prefix=nil, default_v
619644
warn_param_ignored_in_form_data(desc.name)
620645
end
621646
else
622-
param_entry = swagger_atomic_param(desc, false, name)
647+
param_entry = swagger_atomic_param(desc, false, name, false)
623648
if param_entry[:required]
624649
swagger_params_array.unshift(param_entry)
625650
else

spec/dummy/config/routes.rb

+15
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@
2626
get :contributors
2727
end
2828
end
29+
30+
get "/pets/return_and_validate_expected_response" => "pets#return_and_validate_expected_response"
31+
get "/pets/return_and_validate_expected_array_response" => "pets#return_and_validate_expected_array_response"
32+
get "/pets/return_and_validate_type_mismatch" => "pets#return_and_validate_type_mismatch"
33+
get "/pets/return_and_validate_missing_field" => "pets#return_and_validate_missing_field"
34+
get "/pets/return_and_validate_extra_property" => "pets#return_and_validate_extra_property"
35+
get "/pets/return_and_validate_allowed_extra_property" => "pets#return_and_validate_allowed_extra_property"
36+
get "/pets/sub_object_invalid_extra_property" => "pets#sub_object_invalid_extra_property"
37+
get "/pets/sub_object_allowed_extra_property" => "pets#sub_object_allowed_extra_property"
38+
get "/pets/return_and_validate_unexpected_array_response" => "pets#return_and_validate_unexpected_array_response"
39+
get "/pets/return_and_validate_expected_response_with_null" => "pets#return_and_validate_expected_response_with_null"
40+
get "/pets/return_and_validate_expected_response_with_null_object" => "pets#return_and_validate_expected_response_with_null_object"
41+
42+
get "/pets/returns_response_with_valid_array" => "pets#returns_response_with_valid_array"
43+
get "/pets/returns_response_with_invalid_array" => "pets#returns_response_with_invalid_array"
2944
end
3045

3146
apipie

0 commit comments

Comments
 (0)