diff --git a/.rubocop.yml b/.rubocop.yml index c179d785..28a4b0b6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -8,6 +8,8 @@ Metrics/BlockLength: Exclude: - 'spec/**/*.rb' - '*.gemspec' +Metrics/MethodLength: + Max: 20 Layout/EmptyLinesAroundAttributeAccessor: Enabled: true Layout/SpaceAroundMethodCallOperator: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a4f02bc..02c73ac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- Support nullable: true - Request validation fails if request includes a property with `readOnly: true` - Response validation fails if response body includes a property with `writeOnly: true` diff --git a/lib/openapi_first/operation.rb b/lib/openapi_first/operation.rb index d5fb423e..12505b61 100644 --- a/lib/openapi_first/operation.rb +++ b/lib/openapi_first/operation.rb @@ -99,7 +99,7 @@ def build_parameters_json_schema end end - def generate_schema(schema, params, parameter) # rubocop:disable Metrics/MethodLength + def generate_schema(schema, params, parameter) required = Set.new(schema['required']) params.each do |key, value| required << key if parameter.required diff --git a/lib/openapi_first/request_validation.rb b/lib/openapi_first/request_validation.rb index af534d49..c0d8023a 100644 --- a/lib/openapi_first/request_validation.rb +++ b/lib/openapi_first/request_validation.rb @@ -16,7 +16,7 @@ def initialize(app, raise_error: false) @raise = raise_error end - def call(env) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def call(env) # rubocop:disable Metrics/AbcSize operation = env[OpenapiFirst::OPERATION] return @app.call(env) unless operation diff --git a/lib/openapi_first/router.rb b/lib/openapi_first/router.rb index c33a578b..07c07848 100644 --- a/lib/openapi_first/router.rb +++ b/lib/openapi_first/router.rb @@ -53,7 +53,7 @@ def call_router(env) env[Rack::PATH_INFO] = env.delete(ORIGINAL_PATH) if env[ORIGINAL_PATH] end - def build_router(operations) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def build_router(operations) # rubocop:disable Metrics/AbcSize router = Hanami::Router.new {} operations.each do |operation| normalized_path = operation.path.gsub('{', ':').gsub('}', '') diff --git a/lib/openapi_first/schema_validation.rb b/lib/openapi_first/schema_validation.rb index e0d5d08d..c0851256 100644 --- a/lib/openapi_first/schema_validation.rb +++ b/lib/openapi_first/schema_validation.rb @@ -3,7 +3,6 @@ require 'json_schemer' module OpenapiFirst - # Wraps JSONSchemer, mostly to add raw_schema method to test things :| class SchemaValidation attr_reader :raw_schema @@ -14,12 +13,24 @@ def initialize(schema, write: true) custom_keywords['readOnly'] = proc { |data| !data } if write @schemer = JSONSchemer.schema( schema, - keywords: custom_keywords + keywords: custom_keywords, + before_property_validation: proc do |data, property, property_schema, parent| + convert_nullable(data, property, property_schema, parent) + end ) end def validate(input) @schemer.validate(input) end + + private + + def convert_nullable(_data, _property, property_schema, _parent) + return unless property_schema.is_a?(Hash) && property_schema['nullable'] && property_schema['type'] + + property_schema['type'] = [*property_schema['type'], 'null'] + property_schema.delete('nullable') + end end end diff --git a/spec/data/nullable.yaml b/spec/data/nullable.yaml new file mode 100644 index 00000000..03681e78 --- /dev/null +++ b/spec/data/nullable.yaml @@ -0,0 +1,35 @@ +openapi: '3.0.2' +paths: + /test: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Test' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Test' + get: + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/Test' +components: + schemas: + Test: + type: object + required: + - name + properties: + name: + type: string + nullable: true + diff --git a/spec/request_validation/request_body_validation_spec.rb b/spec/request_validation/request_body_validation_spec.rb index 7f2c6965..0253e171 100644 --- a/spec/request_validation/request_body_validation_spec.rb +++ b/spec/request_validation/request_body_validation_spec.rb @@ -198,6 +198,35 @@ end end + describe 'with a required nullable field' do + let(:app) do + Rack::Builder.new do + spec = OpenapiFirst.load('./spec/data/nullable.yaml') + use OpenapiFirst::Router, spec: spec, raise_error: true + use OpenapiFirst::RequestValidation, raise_error: true + run lambda { |_env| + Rack::Response.new('hello', 201).finish + } + end + end + + it 'fails if field is missing' do + header Rack::CONTENT_TYPE, 'application/json' + expect do + post '/test', json_dump({}) + end.to raise_error OpenapiFirst::RequestInvalidError, 'Request body invalid: is missing required properties: name' # rubocop:disable Layout/LineLength + end + + it 'succeeds if field is nil' do + header Rack::CONTENT_TYPE, 'application/json' + request_body = { + name: nil + } + post '/test', json_dump(request_body) + expect(last_response.status).to eq 201 + end + end + describe 'raise_error: true' do let(:raise_error_option) { true } diff --git a/spec/response_validation_spec.rb b/spec/response_validation_spec.rb index 343c04fc..2ffd37bc 100644 --- a/spec/response_validation_spec.rb +++ b/spec/response_validation_spec.rb @@ -194,6 +194,31 @@ end end - describe 'unknown content-type' do + describe 'with a required nullable field' do + let(:spec) { OpenapiFirst.load('./spec/data/nullable.yaml') } + + describe 'when the field is missing' do + let(:response_body) do + json_dump({}) + end + + it 'raises an error' do + message = 'is missing required properties: name ' + expect do + get '/test' + end.to raise_error OpenapiFirst::ResponseBodyInvalidError, message + end + end + + describe 'when the field is nil' do + let(:response_body) do + json_dump({ name: nil }) + end + + it 'does not raise an error' do + get '/test' + expect(last_response.status).to eq 200 + end + end end end