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

Add option to validate query params #92

Merged
merged 5 commits into from
Jun 25, 2024
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ it { is_expected.to match_openapi_doc($doc, path: '/messages/{id}').with_http_st
it { is_expected.to match_openapi_doc($doc, request_body: true).with_http_status(:created) }
```

* `parameters` can be set to `true` to validate request parameters against the parameter definitions

```ruby
it { is_expected.to match_openapi_doc($doc, parameters: true) }
```

Both options can as well be used simultaneously.

### Without RSpec
Expand Down
1 change: 1 addition & 0 deletions lib/openapi_contracts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
require 'rubygems/version'

require 'json_schemer'
require 'openapi_parameters'
require 'rack'
require 'yaml'

Expand Down
37 changes: 19 additions & 18 deletions lib/openapi_contracts/doc/parameter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,33 +14,34 @@ def in_path?
@in == 'path'
end

def in_query?
@in == 'query'
end

def matches?(value)
case @spec.dig('schema', 'type')
when 'integer'
integer_parameter_matches?(value)
when 'number'
number_parameter_matches?(value)
else
schemer.valid?(value)
end
errors = schemer.validate(convert_value(value))
# debug errors.to_a here
errors.none?
end

private
def required?
@required == true
end

def schemer
@schemer ||= Validators::SchemaValidation.validation_schemer(@spec.navigate('schema'))
def schema_for_validation
@spec.navigate('schema')
end

def integer_parameter_matches?(value)
return false unless /^-?\d+$/.match?(value)
private

schemer.valid?(value.to_i)
def convert_value(original)
OpenapiParameters::Converter.convert(original, schema_for_validation)
rescue StandardError
original
end

def number_parameter_matches?(value)
return false unless /^-?(\d+\.)?\d+$/.match?(value)

schemer.valid?(value.to_f)
def schemer
@schemer ||= Validators::SchemaValidation.validation_schemer(schema_for_validation)
end
end
end
6 changes: 5 additions & 1 deletion lib/openapi_contracts/match.rb
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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) }
Expand Down
2 changes: 2 additions & 0 deletions lib/openapi_contracts/validators.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -12,6 +13,7 @@ module Validators
ALL = [
Documented,
HttpStatus,
Parameters,
RequestBody,
ResponseBody,
Headers
Expand Down
21 changes: 21 additions & 0 deletions lib/openapi_contracts/validators/parameters.rb
Original file line number Diff line number Diff line change
@@ -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?(value)
@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
5 changes: 3 additions & 2 deletions openapi_contracts.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ Gem::Specification.new do |s|

s.add_dependency 'activesupport', '>= 6.1', '< 8'
s.add_dependency 'json_schemer', '>= 2', '< 2.2'
s.add_dependency 'openapi_parameters', '>= 0.3.3', '< 0.4'
s.add_dependency 'rack', '>= 2.0.0'

s.add_development_dependency 'json_spec', '~> 1.1.5'
s.add_development_dependency 'rspec', '~> 3.13.0'
s.add_development_dependency 'rubocop', '1.60.2'
s.add_development_dependency 'rubocop-rspec', '2.26.1'
s.add_development_dependency 'rubocop', '1.64.1'
s.add_development_dependency 'rubocop-rspec', '2.31.0'
s.add_development_dependency 'simplecov', '~> 0.22.0'
s.metadata['rubygems_mfa_required'] = 'true'
end
1 change: 0 additions & 1 deletion spec/.rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ inherit_from:

require: rubocop-rspec


Metrics/BlockLength:
Enabled: false

Expand Down
2 changes: 2 additions & 0 deletions spec/fixtures/openapi/components/schemas/Polymorphism.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Pet:
mapping:
dog: '#/Dog'
cat: '#/Cat'
required:
- type

Cat:
description: A cat
Expand Down
9 changes: 9 additions & 0 deletions spec/fixtures/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions spec/integration/rspec_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
137 changes: 137 additions & 0 deletions spec/openapi_contracts/validators/parameters_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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)
}
},
{
in: 'query',
name: 'page',
required: false,
schema: {
type: 'integer'
}
},
{
in: 'query',
name: 'settings',
style: 'deepObject',
required: false,
schema: {
type: 'object',
properties: {
page: {
type: 'integer'
}
},
required: ['page']
}
}
],
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

context 'when passing invalid integer parameter' do
let(:path) { '/pets?page=word' }
let(:required) { false }

it 'has errors' do
expect(subject.call).to contain_exactly '"word" is not a valid value for the query parameter "page"'
end
end

context 'when passing valid objects' do
let(:path) { '/pets?settings[page]=1' }
let(:required) { false }

it 'has no errors' do
expect(subject.call).to be_empty
end
end

context 'when passing invalid objects' do
let(:path) { '/pets?settings[page]=one' }
let(:required) { false }

it 'has errors' do
expect(subject.call).to contain_exactly '{"page"=>"one"} is not a valid value for the query parameter "settings"'
end
end

context 'when passing non-objects as object' do
let(:path) { '/pets?settings=false' }
let(:required) { false }

it 'has errors' do
expect(subject.call).to contain_exactly '"false" is not a valid value for the query parameter "settings"'
end
end
end
30 changes: 30 additions & 0 deletions spec/support/setup_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
Loading