From 678077e7cd8d626cfe39d286595ba802f08a980c Mon Sep 17 00:00:00 2001 From: Konstantin Munteanu Date: Thu, 18 Apr 2024 15:19:35 +0200 Subject: [PATCH] Add option to validate query params --- lib/openapi_contracts/doc/parameter.rb | 14 +++- lib/openapi_contracts/match.rb | 6 +- lib/openapi_contracts/validators.rb | 2 + .../validators/parameters.rb | 21 +++++ .../components/schemas/Polymorphism.yaml | 2 + spec/fixtures/openapi/openapi.yaml | 9 +++ spec/integration/rspec_spec.rb | 23 ++++++ .../validators/parameters_spec.rb | 78 +++++++++++++++++++ spec/support/setup_context.rb | 30 +++++++ 9 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 lib/openapi_contracts/validators/parameters.rb create mode 100644 spec/openapi_contracts/validators/parameters_spec.rb diff --git a/lib/openapi_contracts/doc/parameter.rb b/lib/openapi_contracts/doc/parameter.rb index 6c0510c..e5351cc 100644 --- a/lib/openapi_contracts/doc/parameter.rb +++ b/lib/openapi_contracts/doc/parameter.rb @@ -14,6 +14,10 @@ def in_path? @in == 'path' end + def in_query? + @in == 'query' + end + def matches?(value) case @spec.dig('schema', 'type') when 'integer' @@ -25,10 +29,18 @@ def matches?(value) end end + def required? + @required == true + end + + def schema_for_validation + @spec.navigate('schema') + end + private def schemer - @schemer ||= Validators::SchemaValidation.validation_schemer(@spec.navigate('schema')) + @schemer ||= Validators::SchemaValidation.validation_schemer(schema_for_validation) end def integer_parameter_matches?(value) diff --git a/lib/openapi_contracts/match.rb b/lib/openapi_contracts/match.rb index d230b11..312ec34 100644 --- a/lib/openapi_contracts/match.rb +++ b/lib/openapi_contracts/match.rb @@ -1,6 +1,9 @@ module OpenapiContracts class Match - DEFAULT_OPTIONS = {request_body: false}.freeze + DEFAULT_OPTIONS = { + parameters: false, + request_body: false + }.freeze MIN_REQUEST_ANCESTORS = %w(Rack::Request::Env Rack::Request::Helpers).freeze MIN_RESPONSE_ANCESTORS = %w(Rack::Response::Helpers).freeze @@ -42,6 +45,7 @@ def matchers ) validators = Validators::ALL.dup validators.delete(Validators::HttpStatus) unless @options[:status] + validators.delete(Validators::Parameters) unless @options[:parameters] validators.delete(Validators::RequestBody) unless @options[:request_body] validators.reverse .reduce(->(err) { err }) { |s, m| m.new(s, env) } diff --git a/lib/openapi_contracts/validators.rb b/lib/openapi_contracts/validators.rb index e81f1cb..c262b6c 100644 --- a/lib/openapi_contracts/validators.rb +++ b/lib/openapi_contracts/validators.rb @@ -4,6 +4,7 @@ module Validators autoload :Documented, 'openapi_contracts/validators/documented' autoload :Headers, 'openapi_contracts/validators/headers' autoload :HttpStatus, 'openapi_contracts/validators/http_status' + autoload :Parameters, 'openapi_contracts/validators/parameters' autoload :RequestBody, 'openapi_contracts/validators/request_body' autoload :ResponseBody, 'openapi_contracts/validators/response_body' autoload :SchemaValidation, 'openapi_contracts/validators/schema_validation' @@ -12,6 +13,7 @@ module Validators ALL = [ Documented, HttpStatus, + Parameters, RequestBody, ResponseBody, Headers diff --git a/lib/openapi_contracts/validators/parameters.rb b/lib/openapi_contracts/validators/parameters.rb new file mode 100644 index 0000000..f12ece7 --- /dev/null +++ b/lib/openapi_contracts/validators/parameters.rb @@ -0,0 +1,21 @@ +module OpenapiContracts::Validators + # Validates the input parameters, eg path/url parameters + class Parameters < Base + include SchemaValidation + + private + + def validate + operation.parameters.select(&:in_query?).each do |parameter| + if request.GET.key?(parameter.name) + value = request.GET[parameter.name] + unless parameter.matches?(request.GET[parameter.name]) + @errors << "#{value.inspect} is not a valid value for the query parameter #{parameter.name.inspect}" + end + elsif parameter.required? + @errors << "Missing query parameter #{parameter.name.inspect}" + end + end + end + end +end diff --git a/spec/fixtures/openapi/components/schemas/Polymorphism.yaml b/spec/fixtures/openapi/components/schemas/Polymorphism.yaml index ce258f5..ac46b3b 100644 --- a/spec/fixtures/openapi/components/schemas/Polymorphism.yaml +++ b/spec/fixtures/openapi/components/schemas/Polymorphism.yaml @@ -9,6 +9,8 @@ Pet: mapping: dog: '#/Dog' cat: '#/Cat' + required: + - type Cat: description: A cat diff --git a/spec/fixtures/openapi/openapi.yaml b/spec/fixtures/openapi/openapi.yaml index a42c22f..a4d217e 100644 --- a/spec/fixtures/openapi/openapi.yaml +++ b/spec/fixtures/openapi/openapi.yaml @@ -73,6 +73,15 @@ paths: get: operationId: pets summary: Pets + parameters: + - in: query + name: order + schema: + type: string + enum: + - asc + - desc + required: false responses: '200': description: Ok diff --git a/spec/integration/rspec_spec.rb b/spec/integration/rspec_spec.rb index 90a14b1..a613a3c 100644 --- a/spec/integration/rspec_spec.rb +++ b/spec/integration/rspec_spec.rb @@ -153,4 +153,27 @@ it { is_expected.to_not match_openapi_doc(doc, path: '/user', request_body: true).with_http_status(:ok) } end + + context 'when input parameters are validated' do + let(:path) { '/pets?order=asc' } + let(:response_json) do + [ + { + type: 'cat' + }, + { + type: 'dog' + } + ] + end + let(:response_status) { 200 } + + it { is_expected.to match_openapi_doc(doc, parameters: true) } + + context 'when input parameters are not valid' do + let(:path) { '/pets?order=wrong' } + + it { is_expected.to_not match_openapi_doc(doc, parameters: true) } + end + end end diff --git a/spec/openapi_contracts/validators/parameters_spec.rb b/spec/openapi_contracts/validators/parameters_spec.rb new file mode 100644 index 0000000..0efd2e8 --- /dev/null +++ b/spec/openapi_contracts/validators/parameters_spec.rb @@ -0,0 +1,78 @@ +require 'active_support/core_ext/object/json' + +RSpec.describe OpenapiContracts::Validators::Parameters do + subject { described_class.new(stack, env) } + + include_context 'when using GET /pets' + + let(:env) { OpenapiContracts::Env.new(operation:, request:, response:) } + let(:operation) { doc.operation_for('/pets', method) } + let(:stack) { ->(errors) { errors } } + let(:doc) do + OpenapiContracts::Doc.new( + { + paths: { + '/pets': { + get: { + parameters: [ + { + in: 'query', + name: 'order', + required:, + schema: { + type: 'string', + enum: %w(asc desc) + } + } + ], + responses: { + '200': { + description: 'Ok', + content: { + 'application/json': {} + } + } + } + } + } + } + }.as_json + ) + end + + context 'when optional parameters are missing' do + let(:path) { '/pets' } + let(:required) { false } + + it 'has no errors' do + expect(subject.call).to be_empty + end + end + + context 'when required parameters are missing' do + let(:path) { '/pets' } + let(:required) { true } + + it 'has errors' do + expect(subject.call).to contain_exactly 'Missing query parameter "order"' + end + end + + context 'when required parameters are present' do + let(:path) { '/pets?order=asc' } + let(:required) { true } + + it 'has no errors' do + expect(subject.call).to be_empty + end + end + + context 'when parameters are wrong' do + let(:path) { '/pets?order=bad' } + let(:required) { false } + + it 'has errors' do + expect(subject.call).to contain_exactly '"bad" is not a valid value for the query parameter "order"' + end + end +end diff --git a/spec/support/setup_context.rb b/spec/support/setup_context.rb index db44823..7c65e26 100644 --- a/spec/support/setup_context.rb +++ b/spec/support/setup_context.rb @@ -74,6 +74,36 @@ let(:response_status) { 201 } end +RSpec.shared_context 'when using GET /pets' do + let(:request) { TestRequest.build(path, method:) } + let(:response) do + TestResponse[response_status, response_headers, response_body].tap do |resp| + resp.request = request + end + end + let(:doc) { OpenapiContracts::Doc.parse(FIXTURES_PATH.join('openapi')) } + let(:method) { 'GET' } + let(:path) { '/pets' } + let(:response_body) { JSON.dump(response_json) } + let(:response_headers) do + { + 'Content-Type' => 'application/json;charset=utf-8', + 'X-Request-Id' => 'some-request-id' + } + end + let(:response_json) do + [ + { + type: 'cat' + }, + { + type: 'dog' + } + ] + end + let(:response_status) { 200 } +end + RSpec.shared_context 'when using PATCH /comments/{id}' do let(:response) do TestResponse[response_status, response_headers, response_body].tap do |resp|