From eef65fa1fc6ab13b18a83034f90db7d50039a434 Mon Sep 17 00:00:00 2001 From: Andreas Haller Date: Mon, 27 Jul 2026 16:41:25 +0200 Subject: [PATCH 1/3] Format code after rubocop update --- CHANGELOG.md | 5 + Gemfile.lock | 3 - benchmarks/Gemfile | 1 - benchmarks/Gemfile.lock | 4 - examples/rails_app/Gemfile.lock | 3 - lib/openapi_first/builder.rb | 38 +- lib/openapi_first/parameters.rb | 14 + .../parameters/array_converter.rb | 40 + .../parameters/content_parsers.rb | 55 ++ lib/openapi_first/parameters/converter.rb | 69 ++ lib/openapi_first/parameters/headers_hash.rb | 29 + .../parameters/object_converter.rb | 58 ++ lib/openapi_first/parameters/parameter.rb | 54 ++ lib/openapi_first/parameters/parser.rb | 27 + lib/openapi_first/parameters/query_parser.rb | 99 +++ lib/openapi_first/parameters/unpackers.rb | 118 +++ lib/openapi_first/request.rb | 21 +- lib/openapi_first/response_parser.rb | 20 +- openapi_first.gemspec | 1 - spec/definition_spec.rb | 68 ++ spec/parameters/content_parsers_spec.rb | 68 ++ spec/parameters/converter_spec.rb | 311 +++++++ spec/parameters/cookie_parameters_spec.rb | 132 +++ spec/parameters/header_parameters_spec.rb | 139 +++ spec/parameters/object_converter_spec.rb | 129 +++ spec/parameters/parameter_spec.rb | 167 ++++ spec/parameters/path_parameters_spec.rb | 213 +++++ spec/parameters/query-parameter-tests.yaml | 826 ++++++++++++++++++ spec/parameters/query_parser_spec.rb | 46 + spec/spec_helper.rb | 3 + spec/support/parameter_helpers.rb | 12 + 31 files changed, 2726 insertions(+), 47 deletions(-) create mode 100644 lib/openapi_first/parameters.rb create mode 100644 lib/openapi_first/parameters/array_converter.rb create mode 100644 lib/openapi_first/parameters/content_parsers.rb create mode 100644 lib/openapi_first/parameters/converter.rb create mode 100644 lib/openapi_first/parameters/headers_hash.rb create mode 100644 lib/openapi_first/parameters/object_converter.rb create mode 100644 lib/openapi_first/parameters/parameter.rb create mode 100644 lib/openapi_first/parameters/parser.rb create mode 100644 lib/openapi_first/parameters/query_parser.rb create mode 100644 lib/openapi_first/parameters/unpackers.rb create mode 100644 spec/parameters/content_parsers_spec.rb create mode 100644 spec/parameters/converter_spec.rb create mode 100644 spec/parameters/cookie_parameters_spec.rb create mode 100644 spec/parameters/header_parameters_spec.rb create mode 100644 spec/parameters/object_converter_spec.rb create mode 100644 spec/parameters/parameter_spec.rb create mode 100644 spec/parameters/path_parameters_spec.rb create mode 100644 spec/parameters/query-parameter-tests.yaml create mode 100644 spec/parameters/query_parser_spec.rb create mode 100644 spec/support/parameter_helpers.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 4719478d..64b059f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ - The content of these fields is not validated anymore, so `minLength`, `maxLength` or `pattern` on a field that was sent as a file are ignored. - An `after_request_body_property_validation` hook sees an empty String instead of the file. - Fields that were not sent as a file, and fields with a JSON `contentType` in the `encoding` map, are read and validated as before. +- Changed: The `openapi_parameters` gem was merged into openapi_first and is not a dependency anymore. Parameter parsing now lives in `OpenapiFirst::Parameters`. If you registered a parser for parameters that use a `content` field, use `OpenapiFirst::Parameters::ContentParsers.register` instead of `OpenapiParameters::ContentParsers.register`. +- Removed: `OpenapiFirst::Request#parameters` and `OpenapiFirst::Request#query_schema`, which were internal scaffolding. `#query_schema` always returned `nil`. +- Changed: `OpenapiFirst::Header` (returned by `Response#headers`) exposes `resolved_schema` instead of `node`. +- Fixed: The JSON schema of a parameter that uses a `content` field with a `$ref`'d schema is resolved now. +- Fixed: Loading a document no longer raises `NoMethodError` when a parameter has neither `schema` nor `content`. - Changed: Don't hide covered endpoints in HTML coverage reporter - Added: Filter un/covered endpoints in HTML coverage reporter - Changed: Reduced memory retained by a loaded `Definition`. Response headers with a schema no longer keep the whole raw document node alive, and a couple of build-time-only hashes were replaced with more compact structures. diff --git a/Gemfile.lock b/Gemfile.lock index 92888bef..cd265ab5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,7 +5,6 @@ PATH drb (~> 2.0) hana (~> 1.3) json_schemer (>= 2.1, < 3.0) - openapi_parameters (>= 0.12.0, < 2.0) rack (>= 2.2, < 4.0) GEM @@ -147,8 +146,6 @@ GEM racc (~> 1.4) nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) - openapi_parameters (0.13.0) - rack (>= 2.2) parallel (2.1.0) parser (3.3.12.0) ast (~> 2.4.1) diff --git a/benchmarks/Gemfile b/benchmarks/Gemfile index da80449c..53c2d5c1 100644 --- a/benchmarks/Gemfile +++ b/benchmarks/Gemfile @@ -7,7 +7,6 @@ gem 'benchmark-ips' gem 'benchmark-memory' gem 'committee' gem 'openapi_first', path: '../' -gem 'openapi_parameters' gem 'profile-viewer' gem 'puma' gem 'sinatra' diff --git a/benchmarks/Gemfile.lock b/benchmarks/Gemfile.lock index 31b45b7e..7c9b2163 100644 --- a/benchmarks/Gemfile.lock +++ b/benchmarks/Gemfile.lock @@ -5,7 +5,6 @@ PATH drb (~> 2.0) hana (~> 1.3) json_schemer (>= 2.1, < 3.0) - openapi_parameters (>= 0.12.0, < 2.0) rack (>= 2.2, < 4.0) GEM @@ -33,8 +32,6 @@ GEM memory_profiler (1.1.0) mustermann (3.1.1) nio4r (2.7.5) - openapi_parameters (0.12.0) - rack (>= 2.2) openapi_parser (2.3.1) optparse (0.8.1) profile-viewer (0.0.7) @@ -76,7 +73,6 @@ DEPENDENCIES benchmark-memory committee openapi_first! - openapi_parameters profile-viewer puma sinatra diff --git a/examples/rails_app/Gemfile.lock b/examples/rails_app/Gemfile.lock index deb15f16..db99f4b1 100644 --- a/examples/rails_app/Gemfile.lock +++ b/examples/rails_app/Gemfile.lock @@ -5,7 +5,6 @@ PATH drb (~> 2.0) hana (~> 1.3) json_schemer (>= 2.1, < 3.0) - openapi_parameters (>= 0.12.0, < 2.0) rack (>= 2.2, < 4.0) PATH @@ -161,8 +160,6 @@ GEM racc (~> 1.4) nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) - openapi_parameters (0.13.0) - rack (>= 2.2) pp (0.6.4) prettyprint prettyprint (0.2.0) diff --git a/lib/openapi_first/builder.rb b/lib/openapi_first/builder.rb index 82db85e5..0d189b45 100644 --- a/lib/openapi_first/builder.rb +++ b/lib/openapi_first/builder.rb @@ -5,6 +5,7 @@ require_relative 'failure' require_relative 'router' require_relative 'header' +require_relative 'parameters' require_relative 'request' require_relative 'response' require_relative 'schema/hash' @@ -118,10 +119,10 @@ def register_responses(router, request:, path:, request_method:, responses:) def parse_parameters(parameters) grouped_parameters = group_parameters(parameters) ParsedParameters.new( - query: resolve_parameters(grouped_parameters[:query]), - path: resolve_parameters(grouped_parameters[:path]), - cookie: resolve_parameters(grouped_parameters[:cookie]), - header: resolve_parameters(grouped_parameters[:header]), + query_parser: Parameters::QueryParser.new(build_parameters(grouped_parameters[:query])), + path_parser: build_parser(grouped_parameters[:path]), + cookie_parser: build_parser(grouped_parameters[:cookie]), + header_parser: build_parser(grouped_parameters[:header]), query_schema: build_parameter_schema(grouped_parameters[:query]), path_schema: build_parameter_schema(grouped_parameters[:path]), cookie_schema: build_parameter_schema(grouped_parameters[:cookie]), @@ -129,13 +130,22 @@ def parse_parameters(parameters) ) end - def resolve_parameters(parameters) - parameters&.map do |parameter| - result = parameter.resolved - _media_type, media_type_object = parameter['content']&.first - result['schema'] = (media_type_object || parameter)['schema'].resolved - result - end.to_a + def build_parser(parameters) + return unless parameters + + Parameters::Parser.new(build_parameters(parameters)) + end + + def build_parameters(parameters) + parameters.to_a.map do |parameter| + Parameters::Parameter.new(parameter.resolved, schema: parameter_schema_node(parameter)&.resolved) + end + end + + # The schema of a parameter is either defined directly or inside a content media type object + def parameter_schema_node(parameter) + _media_type, media_type_object = parameter['content']&.first + (media_type_object || parameter)['schema'] end def build_parameter_schema(parameters) @@ -143,7 +153,7 @@ def build_parameter_schema(parameters) required = [] schemas = parameters.each_with_object({}) do |parameter, result| - schema = parameter['schema'].schema(configuration: schemer_configuration) + schema = parameter_schema_node(parameter)&.schema(configuration: schemer_configuration) name = parameter['name']&.value required << name if parameter['required']&.value result[name] = schema if schema @@ -238,8 +248,8 @@ def group_parameters(parameter_definitions) result end - ParsedParameters = Data.define(:path, :query, :header, :cookie, :path_schema, :query_schema, :header_schema, - :cookie_schema) + ParsedParameters = Data.define(:path_parser, :query_parser, :header_parser, :cookie_parser, + :path_schema, :query_schema, :header_schema, :cookie_schema) private_constant :ParsedParameters end end diff --git a/lib/openapi_first/parameters.rb b/lib/openapi_first/parameters.rb new file mode 100644 index 00000000..e7a5bbb2 --- /dev/null +++ b/lib/openapi_first/parameters.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require_relative 'parameters/content_parsers' +require_relative 'parameters/converter' +require_relative 'parameters/headers_hash' +require_relative 'parameters/parameter' +require_relative 'parameters/parser' +require_relative 'parameters/query_parser' + +module OpenapiFirst + # Parses request parameters and response headers as described in an OpenAPI document. + module Parameters + end +end diff --git a/lib/openapi_first/parameters/array_converter.rb b/lib/openapi_first/parameters/array_converter.rb new file mode 100644 index 00000000..ed59f1c1 --- /dev/null +++ b/lib/openapi_first/parameters/array_converter.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module OpenapiFirst + module Parameters + # Converts the items of an array parameter value + # @visibility private + ArrayConverter = Data.define(:schema) do + def call(value) + return [] if value.nil? || value.empty? + + convert_array(value) + end + + private + + def convert_array(array) + return array unless array.is_a?(Array) + + item_schema = schema['items'] + prefix_schemas = schema['prefixItems'] + return convert_array_with_prefixes(array, prefix_schemas, item_schema) if prefix_schemas + + array.map { Converter.convert(_1, item_schema) } + end + + def convert_array_with_prefixes(array, prefix_schemas, item_schema) + prefixes = + array + .slice(0, prefix_schemas.size) + .each_with_index + .map { |item, index| Converter.convert(item, prefix_schemas[index]) } + array = + array[prefix_schemas.size..].map! do |item| + Converter.convert(item, item_schema) + end + prefixes + array + end + end + end +end diff --git a/lib/openapi_first/parameters/content_parsers.rb b/lib/openapi_first/parameters/content_parsers.rb new file mode 100644 index 00000000..d1ba0ec0 --- /dev/null +++ b/lib/openapi_first/parameters/content_parsers.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'json' + +module OpenapiFirst + module Parameters + # Registry of parsers for parameters that use a `content` field, keyed by media type. + # + # A parser is a callable that takes a raw string and returns the parsed value. + # It should `throw :skip, value` if the input cannot be parsed, so the + # parameter value is used as is. + # + # OpenapiFirst::Parameters::ContentParsers.register('application/xml', ->(value) { ... }) + module ContentParsers + @parsers = [] + + class << self + attr_reader :parsers + + # @param matcher [String, Regexp] exact media type or a pattern. + # @param parser [#call] callable that takes the raw string and returns the parsed value. + def register(matcher, parser) + parsers.reject! { |existing, _| existing == matcher } + parsers << [matcher, parser] + end + + # @param media_type [String, nil] + # @return [#call, nil] the parser, or nil if none is registered. + def [](media_type) + return nil if media_type.nil? + + parsers.each do |matcher, parser| + return parser if match?(matcher, media_type) + end + nil + end + + private + + def match?(matcher, media_type) + case matcher + when Regexp then matcher.match?(media_type) + else matcher == media_type + end + end + end + + register(%r{\A[\w.+-]+/(?:[\w.-]+\+)?json\z}i, lambda do |value| + JSON.parse(value) + rescue JSON::ParserError + throw :skip, value + end) + end + end +end diff --git a/lib/openapi_first/parameters/converter.rb b/lib/openapi_first/parameters/converter.rb new file mode 100644 index 00000000..d55ca821 --- /dev/null +++ b/lib/openapi_first/parameters/converter.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require_relative 'array_converter' +require_relative 'object_converter' + +module OpenapiFirst + module Parameters + # Converts a parameter value (string) to the type specified in the JSON Schema. + # @visibility private + module Converter + PASS_THROUGH = ->(value) { value } + + INTEGER = lambda do |value| + Integer(value, 10) + rescue StandardError + value + end + + NUMBER = lambda do |value| + Float(value) + rescue StandardError + value + end + + BOOLEAN = lambda do |value| + if value == 'true' + true + else + value == 'false' ? false : value + end + end + + class << self + # Returns a callable that converts a value as described in the schema + # @param schema [Hash, nil] + def [](schema) + case schema && schema['type'] + when 'integer' then INTEGER + when 'number' then NUMBER + when 'boolean' then BOOLEAN + when 'object' then ObjectConverter.new(schema) + when 'array' then ArrayConverter.new(schema) + else + return ObjectConverter.new(schema) if object_like?(schema) + + PASS_THROUGH + end + end + + # Converts a nested value, like an array item or an object property + def convert(value, schema) + return if value.nil? + return value if schema.nil? + + self[schema].call(value) + end + + private + + OBJECT_KEYWORDS = %w[properties oneOf allOf anyOf].freeze + private_constant :OBJECT_KEYWORDS + + def object_like?(schema) + schema && OBJECT_KEYWORDS.any? { schema[_1] } + end + end + end + end +end diff --git a/lib/openapi_first/parameters/headers_hash.rb b/lib/openapi_first/parameters/headers_hash.rb new file mode 100644 index 00000000..a62776c5 --- /dev/null +++ b/lib/openapi_first/parameters/headers_hash.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module OpenapiFirst + module Parameters + # A wrapper around the Rack env hash that allows accessing headers by header name + # @visibility private + class HeadersHash + # This was copied from this Rack::Request PR: https://github.com/rack/rack/pull/1881 + def initialize(env) + @env = env + end + + def [](key) + @env[header_to_env_key(key)] + end + + def key?(key) + @env.key?(header_to_env_key(key)) + end + + def header_to_env_key(key) + key = key.upcase + key.tr!('-', '_') + key = "HTTP_#{key}" unless %w[CONTENT_LENGTH CONTENT_TYPE].include?(key) + key + end + end + end +end diff --git a/lib/openapi_first/parameters/object_converter.rb b/lib/openapi_first/parameters/object_converter.rb new file mode 100644 index 00000000..514eed6b --- /dev/null +++ b/lib/openapi_first/parameters/object_converter.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module OpenapiFirst + module Parameters + # Converts the properties of an object parameter value + # @visibility private + ObjectConverter = Data.define(:schema) do + def self.get_properties(schema) # rubocop:disable Metrics + return nil if schema.nil? || schema.empty? + + direct_props = schema['properties'] + additional_props = schema['additionalProperties'] + + composition_props = [] + + %w[allOf oneOf anyOf].each do |keyword| + next unless (array = schema[keyword]) + + array.each do |sub_schema| + if (props = sub_schema['properties']) + composition_props << props + end + end + end + + %w[then else].each do |keyword| + next unless (sub_schema = schema[keyword]) + + if (props = sub_schema['properties']) + composition_props << props + end + if (add_props = sub_schema['additionalProperties']) && add_props.is_a?(Hash) && !add_props.empty? + composition_props << add_props + end + end + + composition_props << additional_props if additional_props.is_a?(Hash) && !additional_props.empty? + + return direct_props if composition_props.empty? && direct_props + return nil if direct_props.nil? && composition_props.empty? + + result = direct_props ? direct_props.dup : {} + composition_props.each { result.merge!(_1) } + result + end + + def call(value) + return value unless value.is_a?(Hash) + + properties = self.class.get_properties(schema) + + value.each_with_object({}) do |(key, val), hsh| + hsh[key] = Converter.convert(val, properties&.fetch(key, nil)) + end + end + end + end +end diff --git a/lib/openapi_first/parameters/parameter.rb b/lib/openapi_first/parameters/parameter.rb new file mode 100644 index 00000000..5824810d --- /dev/null +++ b/lib/openapi_first/parameters/parameter.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative 'converter' +require_relative 'unpackers' + +module OpenapiFirst + module Parameters + # A parameter of a request, or a header of a response. + # @visibility private + class Parameter + DEFAULT_STYLE = { + 'query' => 'form', + 'path' => 'simple', + 'header' => 'simple', + 'cookie' => 'form' + }.freeze + private_constant :DEFAULT_STYLE + + # @param definition [Hash] The OpenAPI Parameter Object. A string keyed Hash. + # @param schema [Hash, nil] The resolved JSON Schema of this parameter. + def initialize(definition, schema:) + @name = definition['name'] + @schema = schema + @location = definition['in'] + @media_type = definition['content']&.keys&.first + @style = definition['style'] || DEFAULT_STYLE.fetch(@location) + @explode = definition.fetch('explode') { @style == 'form' } + @deep_object = @style == 'deepObject' + @converter = Converter[schema] + @unpacker = Unpackers.find(self) + end + + attr_reader :name, :schema, :location, :media_type, :style + + def unpack(value) + return value if value.nil? + + @unpacker.call(value) + end + + def convert(value) = @converter.call(value) + + def explode? = @explode + + def deep_object? = @deep_object + + def type = schema && schema['type'] + + def array? = type == 'array' + + def object? = type == 'object' || deep_object? || schema&.key?('properties') + end + end +end diff --git a/lib/openapi_first/parameters/parser.rb b/lib/openapi_first/parameters/parser.rb new file mode 100644 index 00000000..9b70319d --- /dev/null +++ b/lib/openapi_first/parameters/parser.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module OpenapiFirst + module Parameters + # Unpacks parameters from a Hash of raw values, like path parameters, headers or cookies. + # @visibility private + class Parser + # @param parameters [Array] + def initialize(parameters) + @parameters = parameters + end + + attr_reader :parameters + + # @param values [Hash] The raw values, keyed by parameter name. + def unpack(values) + parameters.each_with_object({}) do |parameter, result| + next unless values.key?(parameter.name) + + result[parameter.name] = catch :skip do + parameter.convert(parameter.unpack(values[parameter.name])) + end + end + end + end + end +end diff --git a/lib/openapi_first/parameters/query_parser.rb b/lib/openapi_first/parameters/query_parser.rb new file mode 100644 index 00000000..cade656d --- /dev/null +++ b/lib/openapi_first/parameters/query_parser.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'rack' +require_relative 'object_converter' + +module OpenapiFirst + module Parameters + # Unpacks query parameters from a query string. + # @visibility private + class QueryParser + DEEP_PROP = '\[([\w-]+)\]$' + private_constant :DEEP_PROP + + # @param parameters [Array] + def initialize(parameters) + @parameters = parameters + @deep_object_properties = {} + @deep_object_regex = {} + parameters.each do |parameter| + next unless parameter.deep_object? + + @deep_object_properties[parameter.name] = ObjectConverter.get_properties(parameter.schema) + @deep_object_regex[parameter.name] = /^#{Regexp.escape(parameter.name)}#{DEEP_PROP}/ + end + end + + attr_reader :parameters + + def unpack(query_string) + parsed_query = parse_query(query_string) + parameters.each_with_object({}) do |parameter, result| + if parameter.deep_object? + if parsed_query.key?(parameter.name) + value = parsed_query[parameter.name] + else + value = parse_deep_object(parameter, parsed_query) + next if value.empty? + end + else + next unless parsed_query.key?(parameter.name) + + value = catch(:skip) { parameter.unpack(parsed_query[parameter.name]) } + end + result[parameter.name] = parameter.convert(value) + end + end + + # Returns query parameters that are not defined in the API description + def unknown_values(query_string) + parsed_query = parse_query(query_string) + known_parameter_names = parameters.to_set(&:name) + + unknown = parsed_query.each_with_object({}) do |(key, value), result| + next if known_parameter_names.include?(key) + next if parameters.any? { _1.deep_object? && key.start_with?("#{_1.name}[") } + + result[key] = value + end + return if unknown.empty? + + unknown + end + + private + + def parse_query(query_string) + Rack::Utils.parse_query(query_string) do |string| + Rack::Utils.unescape(string) + rescue ArgumentError => e + raise Rack::Utils::InvalidParameterError, e.message + end + end + + def parse_deep_object(parameter, parsed_query) + name = parameter.name + prop_regx = @deep_object_regex[name] + properties_schema = @deep_object_properties[name] + + parsed_query.each.with_object({}) do |(key, value), result| + prop_key = key.match(prop_regx)&.[](1) + next if prop_key.nil? + + is_array = properties_schema&.dig(prop_key, 'type') == 'array' + result[prop_key] = explode_value(value, parameter, is_array) + end + end + + def explode_value(value, parameter, is_array) + value = Array(value) + if is_array + return value if parameter.explode? + + return [value.last] + end + value.last + end + end + end +end diff --git a/lib/openapi_first/parameters/unpackers.rb b/lib/openapi_first/parameters/unpackers.rb new file mode 100644 index 00000000..67ca8b3e --- /dev/null +++ b/lib/openapi_first/parameters/unpackers.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'rack' +require_relative 'content_parsers' + +module OpenapiFirst + module Parameters + # Resolves the unpacker of a parameter once, at Parameter construction time. + # Each unpacker is a callable that takes a raw string (or already-parsed + # value) and returns the unpacked Ruby value, throwing :skip on unrecoverable + # parse errors. + # @visibility private + module Unpackers + ARRAY_DELIMITER = { + 'label' => '.', + 'simple' => ',', + 'form' => ',', + 'pipeDelimited' => '|', + 'spaceDelimited' => ' ' + }.freeze + private_constant :ARRAY_DELIMITER + + PREFIXED_STYLES = %w[label matrix].freeze + private_constant :PREFIXED_STYLES + + OBJECT_EXPLODE_SPLITTER = Regexp.union(',', '=').freeze + private_constant :OBJECT_EXPLODE_SPLITTER + + PassThrough = ->(value) { value } + + DelimitedArray = Data.define(:delimiter, :strip_prefix) do + def call(value) + return value if value.is_a?(::Array) + return value if value.empty? + + value = value[1..] if strip_prefix + value.split(delimiter) + end + end + + MatrixArray = Data.define(:name, :explode) do + def call(value) + return value if value.is_a?(::Array) + return value if value.empty? + + result = Rack::Utils.parse_query(value, ';')[name] + explode ? result : result.split(',') + end + end + + ExplodeFormObject = lambda do |value| + entries = value.split(OBJECT_EXPLODE_SPLITTER) + throw :skip, value if entries.length.odd? + + Hash[*entries] + end + + DelimitedObject = Data.define(:delimiter) do + def call(value) + entries = value.split(delimiter) + throw :skip, value if entries.length.odd? + + Hash[*entries] + end + end + + ExplodePathObject = ->(value) { Rack::Utils.parse_query(value, ',') } + + NonExplodePathObject = Data.define(:array_unpacker) do + def call(value) + array = array_unpacker.call(value) + throw :skip, value if array.length.odd? + + Hash[*array] + end + end + + class << self + def find(parameter) + return find_media_type(parameter) if parameter.media_type + return find_array(parameter) if parameter.array? + return find_object(parameter) if parameter.object? + + PassThrough + end + + private + + def find_media_type(parameter) + ContentParsers[parameter.media_type] || PassThrough + end + + def find_array(parameter) + style = parameter.style + return MatrixArray.new(name: parameter.name, explode: parameter.explode?) if style == 'matrix' + + DelimitedArray.new( + delimiter: ARRAY_DELIMITER[style], + strip_prefix: PREFIXED_STYLES.include?(style) + ) + end + + def find_object(parameter) + return find_path_object(parameter) if parameter.location == 'path' + return ExplodeFormObject if parameter.explode? + + DelimitedObject.new(delimiter: ARRAY_DELIMITER[parameter.style]) + end + + def find_path_object(parameter) + return ExplodePathObject if parameter.explode? + + NonExplodePathObject.new(array_unpacker: find_array(parameter)) + end + end + end + end +end diff --git a/lib/openapi_first/request.rb b/lib/openapi_first/request.rb index 5ca853a6..0e3f466c 100644 --- a/lib/openapi_first/request.rb +++ b/lib/openapi_first/request.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'openapi_parameters' +require_relative 'parameters' require_relative 'parsed_request' require_relative 'request_validator' require_relative 'validated_request' @@ -12,7 +12,7 @@ module OpenapiFirst # An 3.x Operation object can accept multiple requests, because it can handle multiple content-types. # This class represents one of those requests. class Request - def initialize(path:, request_method:, operation_object:, # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists + def initialize(path:, request_method:, operation_object:, # rubocop:disable Metrics/ParameterLists parameters:, content_type:, content_schema:, required_body:, key:, encoding: nil) @path = path @request_method = request_method @@ -21,10 +21,10 @@ def initialize(path:, request_method:, operation_object:, # rubocop:disable Metr @operation = operation_object @allow_empty_content = content_type.nil? || required_body == false @key = key - @query_parser = parameters.query&.then { |params| OpenapiParameters::Query.new(params) } - @path_parser = parameters.path&.then { |params| OpenapiParameters::Path.new(params) } - @headers_parser = parameters.header&.then { |params| OpenapiParameters::Header.new(params) } - @cookies_parser = parameters.cookie&.then { |params| OpenapiParameters::Cookie.new(params) } + @query_parser = parameters.query_parser + @path_parser = parameters.path_parser + @header_parser = parameters.header_parser + @cookie_parser = parameters.cookie_parser @body_parsers = build_body_parser(content_type, encoding) if content_type @validator = RequestValidator.new( content_schema:, @@ -35,10 +35,9 @@ def initialize(path:, request_method:, operation_object:, # rubocop:disable Metr header_schema: parameters.header_schema, cookie_schema: parameters.cookie_schema ) - @parameters = parameters end - attr_reader :content_type, :content_schema, :operation, :request_method, :path, :key, :query_schema, :parameters + attr_reader :content_type, :content_schema, :operation, :request_method, :path, :key private attr_reader :query_parser def allow_empty_content? @@ -67,15 +66,15 @@ def parse_request(request, route_params:) [ParsedRequest.new( path: @path_parser&.unpack(route_params), query:, - headers: @headers_parser&.unpack_env(request.env), - cookies: @cookies_parser&.unpack(request.env[Rack::HTTP_COOKIE]), + headers: @header_parser&.unpack(Parameters::HeadersHash.new(request.env)), + cookies: @cookie_parser&.unpack(Rack::Utils.parse_cookies_header(request.env[Rack::HTTP_COOKIE])), body: ), nil] end def parse_query(query_string) [@query_parser&.unpack(query_string), nil] - rescue OpenapiParameters::InvalidParameterError + rescue Rack::Utils::InvalidParameterError [nil, Failure.new(:invalid_query, message: 'Invalid query parameter.')] end diff --git a/lib/openapi_first/response_parser.rb b/lib/openapi_first/response_parser.rb index a08bf308..bc62212f 100644 --- a/lib/openapi_first/response_parser.rb +++ b/lib/openapi_first/response_parser.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require_relative 'parameters' require_relative 'response_body_parsers' module OpenapiFirst @@ -18,7 +19,7 @@ def parse(rack_response) [ParsedResponse.new( body:, - headers: @headers_parser&.call(rack_response.headers) || {} + headers: @headers_parser&.unpack(rack_response.headers) || {} ), nil] end @@ -39,15 +40,14 @@ def read_body(rack_response) def build_headers_parser(headers) return unless headers&.any? - headers_as_parameters = headers.map do |header| - { - 'name' => header.name, - 'explode' => false, - 'in' => 'header', - 'schema' => header.resolved_schema - } - end - OpenapiParameters::Header.new(headers_as_parameters).method(:unpack) + Parameters::Parser.new( + headers.map do |header| + Parameters::Parameter.new( + { 'name' => header.name, 'in' => 'header' }, + schema: header.resolved_schema + ) + end + ) end end end diff --git a/openapi_first.gemspec b/openapi_first.gemspec index ff8bd022..4846a737 100644 --- a/openapi_first.gemspec +++ b/openapi_first.gemspec @@ -26,6 +26,5 @@ Gem::Specification.new do |spec| spec.add_dependency 'drb', '~> 2.0' spec.add_dependency 'hana', '~> 1.3' spec.add_dependency 'json_schemer', '>= 2.1', '< 3.0' - spec.add_dependency 'openapi_parameters', '>= 0.12.0', '< 2.0' spec.add_dependency 'rack', '>= 2.2', '< 4.0' end diff --git a/spec/definition_spec.rb b/spec/definition_spec.rb index 2fd51e2f..75edea0a 100644 --- a/spec/definition_spec.rb +++ b/spec/definition_spec.rb @@ -312,6 +312,74 @@ def parse_quietly(document) end end + context 'with a parameter that uses content with a $ref schema' do + let(:definition) do + OpenapiFirst.parse({ + 'openapi' => '3.1.0', + 'paths' => { + '/search' => { + 'get' => { + 'parameters' => [ + { + 'name' => 'filter', + 'in' => 'query', + 'content' => { + 'application/json' => { + 'schema' => { '$ref' => '#/components/schemas/Filter' } + } + } + } + ] + } + } + }, + 'components' => { + 'schemas' => { + 'Filter' => { + 'type' => 'object', + 'properties' => { 'tag' => { 'type' => 'string' } } + } + } + } + }) + end + + it 'parses the parameter as described in the referenced schema' do + validated = definition.validate_request(build_request('/search?filter={"tag":"dogs"}')) + expect(validated).to be_valid + expect(validated.parsed_query).to eq('filter' => { 'tag' => 'dogs' }) + end + + it 'validates the parameter against the referenced schema' do + validated = definition.validate_request(build_request('/search?filter={"tag":42}')) + expect(validated).not_to be_valid + expect(validated.error.type).to eq(:invalid_query) + end + end + + context 'with a parameter without schema' do + let(:definition) do + OpenapiFirst.parse({ + 'openapi' => '3.1.0', + 'paths' => { + '/search' => { + 'get' => { + 'parameters' => [ + { 'name' => 'term', 'in' => 'query', 'required' => true } + ] + } + } + } + }) + end + + it 'returns the value as is' do + validated = definition.validate_request(build_request('/search?term=42')) + expect(validated).to be_valid + expect(validated.parsed_query).to eq('term' => '42') + end + end + context 'with a matching path but unknown request method' do let(:definition) { OpenapiFirst.load('./spec/data/petstore.yaml') } let(:rack_request) { build_request('/pets', method: 'PATCH') } diff --git a/spec/parameters/content_parsers_spec.rb b/spec/parameters/content_parsers_spec.rb new file mode 100644 index 00000000..e69f8aee --- /dev/null +++ b/spec/parameters/content_parsers_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +RSpec.describe OpenapiFirst::Parameters::ContentParsers do + around do |example| + original = described_class.parsers.dup + example.run + described_class.parsers.replace(original) + end + + describe '.[]' do + it 'finds a parser via a regexp matcher' do + expect(described_class['application/vnd.api+json']).to be_a Proc + end + + it 'returns nil for an unknown media type' do + expect(described_class['application/xml']).to be_nil + end + + it 'returns nil if no media type is given' do + expect(described_class[nil]).to be_nil + end + end + + describe '.register' do + it 'registers a parser for an exact media type' do + parser = ->(value) { value.split(',') } + described_class.register('text/csv', parser) + expect(described_class['text/csv']).to be parser + expect(described_class['text/plain']).to be_nil + end + + it 'replaces a parser that was registered with the same matcher' do + described_class.register('text/csv', ->(value) { value }) + parser = ->(value) { value.split(',') } + described_class.register('text/csv', parser) + expect(described_class['text/csv']).to be parser + end + + it 'is used to unpack a parameter with that media type' do + described_class.register('text/csv', ->(value) { value.split(',') }) + parameter = build_parameter( + { 'in' => 'query', 'name' => 'ids', 'content' => { 'text/csv' => { 'schema' => { 'type' => 'array' } } } } + ) + expect(unpack(parameter, 'ids' => '1,2')).to eq('ids' => %w[1 2]) + end + end + + describe 'the built-in JSON parser' do + let(:parameter) do + build_parameter( + { 'in' => 'query', 'name' => 'filter', + 'content' => { 'application/json' => { 'schema' => { 'type' => 'object' } } } } + ) + end + + it 'parses JSON' do + expect(unpack(parameter, 'filter' => '{"a":1}')).to eq('filter' => { 'a' => 1 }) + end + + it 'returns the value as is if it is not valid JSON' do + expect(unpack(parameter, 'filter' => '{')).to eq('filter' => '{') + end + end + + def unpack(parameter, values) + OpenapiFirst::Parameters::Parser.new([parameter]).unpack(values) + end +end diff --git a/spec/parameters/converter_spec.rb b/spec/parameters/converter_spec.rb new file mode 100644 index 00000000..ef5759a4 --- /dev/null +++ b/spec/parameters/converter_spec.rb @@ -0,0 +1,311 @@ +# frozen_string_literal: true + +RSpec.describe OpenapiFirst::Parameters::Converter do + def convert(value, schema) + described_class.convert(value, schema) + end + + shared_examples 'converts primitive types' do |type, valid_input, expected_output, invalid_input = 'invalid'| + context "with #{type} type" do + let(:schema) { { 'type' => type } } + + it "converts valid #{type}" do + expect(convert(valid_input, schema)).to eq(expected_output) + end + + it "returns original value for invalid #{type}" do + expect(convert(invalid_input, schema)).to eq(invalid_input) + end + end + end + + shared_examples 'preserves input when no conversion needed' do |input, schema| + it 'preserves the input' do + expect(convert(input, schema)).to eq(input) + end + end + + it_behaves_like 'preserves input when no conversion needed', '123', nil + it_behaves_like 'preserves input when no conversion needed', '1', {} + it_behaves_like 'preserves input when no conversion needed', nil, { 'type' => 'integer' } + + it_behaves_like 'converts primitive types', 'string', '123', '123' + it_behaves_like 'converts primitive types', 'integer', '123', 123, 'a' + it_behaves_like 'converts primitive types', 'number', '12.3', 12.3, 'a' + + context 'with integer type' do + let(:schema) { { 'type' => 'integer' } } + + it 'keeps integer as integer' do + expect(convert(123, schema)).to eq(123) + end + + it 'does not convert hex numbers' do + expect(convert('0x23', schema)).to eq('0x23') + end + end + + context 'with boolean type' do + let(:schema) { { 'type' => 'boolean' } } + + it 'converts "true" to true' do + expect(convert('true', schema)).to be(true) + end + + it 'converts "false" to false' do + expect(convert('false', schema)).to be(false) + end + + it 'returns original for invalid boolean' do + expect(convert('wrong', schema)).to eq('wrong') + end + end + + it 'ignores format' do + schema = { 'type' => 'string', 'format' => 'date' } + expect(convert('2020-09-15', schema)).to eq('2020-09-15') + end + + context 'with object schemas' do + let(:simple_object_schema) do + { + 'type' => 'object', + 'properties' => { + 'id' => { 'type' => 'integer' } + } + } + end + + let(:nested_object_schema) do + { + 'type' => 'object', + 'properties' => { + 'data' => { + 'type' => 'object', + 'properties' => { + 'id' => { 'type' => 'integer' } + } + } + } + } + end + + it 'converts object properties' do + input = { 'id' => '123' } + expect(convert(input, simple_object_schema)).to eq({ 'id' => 123 }) + end + + it 'converts nested objects' do + input = { 'data' => { 'id' => '123' } } + expected = { 'data' => { 'id' => 123 } } + expect(convert(input, nested_object_schema)).to eq(expected) + end + + it 'converts when schema has properties but no type' do + schema = { 'properties' => { 'id' => { 'type' => 'integer' } } } + input = { 'id' => '123' } + expect(convert(input, schema)).to eq({ 'id' => 123 }) + end + + it 'ignores unknown properties' do + input = { 'id' => '123', 'unknown' => 'value' } + expected = { 'id' => 123, 'unknown' => 'value' } + expect(convert(input, simple_object_schema)).to eq(expected) + end + + it 'does not convert string to object' do + expect(convert('foo', simple_object_schema)).to eq('foo') + end + end + + context 'with array schemas' do + let(:simple_array_schema) { { 'type' => 'array', 'items' => { 'type' => 'integer' } } } + let(:prefix_items_schema) do + { + 'type' => 'array', + 'prefixItems' => [{ 'type' => 'string' }, { 'type' => 'integer' }] + } + end + + it 'converts array items' do + expect(convert(%w[1 2 3], simple_array_schema)).to eq([1, 2, 3]) + end + + it 'converts with prefixItems' do + expect(convert(%w[1 2], prefix_items_schema)).to eq(['1', 2]) + end + + it 'handles prefixItems with additional items' do + schema = { 'type' => 'array', 'prefixItems' => [{ 'type' => 'integer' }] } + expect(convert(%w[1 2 3], schema)).to eq([1, '2', '3']) + end + + it 'handles prefixItems with items schema' do + schema = { + 'type' => 'array', + 'prefixItems' => [{ 'type' => 'integer' }, { 'type' => 'string' }], + 'items' => { 'type' => 'integer' } + } + expect(convert(%w[1 a 3 4], schema)).to eq([1, 'a', 3, 4]) + end + + it 'converts nested arrays' do + schema = { + 'type' => 'array', + 'items' => { + 'type' => 'array', + 'items' => { 'type' => 'integer' } + } + } + expect(convert([%w[1 2], %w[3 4]], schema)).to eq([[1, 2], [3, 4]]) + end + + it 'does not convert string to array' do + expect(convert('foo', simple_array_schema)).to eq('foo') + end + end + + it 'converts complex nested structures' do + schema = { + 'type' => 'object', + 'properties' => { + 'data' => { + 'type' => 'array', + 'items' => { + 'type' => 'object', + 'properties' => { + 'id' => { 'type' => 'integer' }, + 'clientIds' => { + 'type' => 'array', + 'items' => { 'type' => 'integer' } + } + } + } + } + } + } + input = { 'data' => [{ 'id' => '1', 'clientIds' => %w[1 2] }] } + expected = { 'data' => [{ 'id' => 1, 'clientIds' => [1, 2] }] } + expect(convert(input, schema)).to eq(expected) + end + + context 'with composition schemas' do + shared_examples 'composition schema conversion' do |composition_key| + it "converts properties from #{composition_key} branches" do + schema = { + composition_key => [ + { + 'type' => 'object', + 'properties' => { 'name' => { 'type' => 'string' } } + }, + { + 'type' => 'object', + 'properties' => { 'age' => { 'type' => 'integer' } } + } + ] + } + input = { 'name' => 'John', 'age' => '25' } + expect(convert(input, schema)).to eq({ 'name' => 'John', 'age' => 25 }) + end + end + + it_behaves_like 'composition schema conversion', 'oneOf' + it_behaves_like 'composition schema conversion', 'allOf' + it_behaves_like 'composition schema conversion', 'anyOf' + + context 'with oneOf' do + it 'handles overlapping properties' do + schema = { + 'oneOf' => [ + { + 'type' => 'object', + 'properties' => { + 'id' => { 'type' => 'string' }, + 'value' => { 'type' => 'integer' } + } + }, + { + 'type' => 'object', + 'properties' => { + 'id' => { 'type' => 'string' }, + 'count' => { 'type' => 'integer' } + } + } + ] + } + input = { 'id' => 'test', 'value' => '42', 'count' => '10' } + expect(convert(input, schema)).to eq({ 'id' => 'test', 'value' => 42, 'count' => 10 }) + end + + it 'ignores undefined properties' do + schema = { + 'oneOf' => [ + { 'type' => 'object', 'properties' => { 'name' => { 'type' => 'string' } } } + ] + } + input = { 'name' => 'John', 'unknown' => 'value' } + expect(convert(input, schema)).to eq({ 'name' => 'John', 'unknown' => 'value' }) + end + end + + context 'with allOf and nested objects' do + it 'handles complex nested schemas' do + schema = { + 'allOf' => [ + { + 'type' => 'object', + 'properties' => { + 'user' => { + 'type' => 'object', + 'properties' => { 'id' => { 'type' => 'integer' } } + } + } + }, + { + 'type' => 'object', + 'properties' => { + 'metadata' => { + 'type' => 'object', + 'properties' => { 'created' => { 'type' => 'string' } } + } + } + } + ] + } + input = { 'user' => { 'id' => '123' }, 'metadata' => { 'created' => '2023-01-01' } } + expected = { 'user' => { 'id' => 123 }, 'metadata' => { 'created' => '2023-01-01' } } + expect(convert(input, schema)).to eq(expected) + end + end + + context 'with mixed schemas' do + it 'combines direct properties with composition' do + schema = { + 'properties' => { 'id' => { 'type' => 'string' } }, + 'oneOf' => [ + { 'type' => 'object', 'properties' => { 'value' => { 'type' => 'integer' } } } + ] + } + input = { 'id' => 'test', 'value' => '123' } + expect(convert(input, schema)).to eq({ 'id' => 'test', 'value' => 123 }) + end + + it 'handles empty composition arrays' do + schema = { 'oneOf' => [] } + input = { 'name' => 'test' } + expect(convert(input, schema)).to eq({ 'name' => 'test' }) + end + + it 'handles composition with missing properties' do + schema = { + 'oneOf' => [ + { 'type' => 'object' }, + { 'type' => 'object', 'properties' => { 'name' => { 'type' => 'string' } } } + ] + } + input = { 'name' => 'test', 'other' => 'value' } + expect(convert(input, schema)).to eq({ 'name' => 'test', 'other' => 'value' }) + end + end + end +end diff --git a/spec/parameters/cookie_parameters_spec.rb b/spec/parameters/cookie_parameters_spec.rb new file mode 100644 index 00000000..f6eff6c3 --- /dev/null +++ b/spec/parameters/cookie_parameters_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +RSpec.describe OpenapiFirst::Parameters::Parser do + def unpack(definitions, cookie_string) + definitions = [definitions] unless definitions.is_a?(Array) + described_class.new(build_parameters(definitions)).unpack(Rack::Utils.parse_cookies_header(cookie_string)) + end + + describe 'cookie parameters' do + it 'returns the converted value' do + parameter = { 'in' => 'cookie', 'name' => 'Some', 'schema' => { 'type' => 'integer' } } + expect(unpack(parameter, 'Some=12;')).to eq('Some' => 12) + end + + describe 'No schema type defined' do + it 'returns the cookie value' do + parameter = { 'in' => 'cookie', 'name' => 'Some', 'schema' => {} } + expect(unpack(parameter, 'Some=abc;')).to eq('Some' => 'abc') + end + end + + it 'excludes unknown cookies' do + parameter = { 'in' => 'cookie', 'name' => 'Some' } + expect(unpack(parameter, 'Other=cde; Some=abc;')).to eq('Some' => 'abc') + end + + describe 'Primitive parameter' do + it 'returns the cookie value' do + parameter = { 'in' => 'cookie', 'name' => 'Some', 'schema' => { 'type' => 'integer' } } + expect(unpack(parameter, 'Some=12;')).to eq('Some' => 12) + end + + it 'excludes key if parameter not set' do + parameter = { 'in' => 'cookie', 'name' => 'Some', 'schema' => { 'type' => 'integer' } } + expect(unpack(parameter, '')).to eq({}) + end + + it 'works with special characters in names' do + parameter = { 'in' => 'cookie', 'name' => '[]some[things]%', 'schema' => { 'type' => 'integer' } } + expect(unpack(parameter, '[]some[things]%=12;')).to eq('[]some[things]%' => 12) + end + end + + describe 'Array explode true' do + # NOTE: Nobody seems to understand how explode: true should work for arrays in cookie parameters. + # So the explode flag is ignored for arrays. + it 'returns an array' do + parameter = { + 'in' => 'cookie', 'name' => 'Some', 'explode' => true, 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, 'Some=1,2;')).to eq('Some' => %w[1 2]) + end + end + + describe 'Array explode false' do + it 'returns an array' do + parameter = { + 'in' => 'cookie', 'name' => 'Some', 'explode' => false, 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, 'Some=1,2;')).to eq('Some' => %w[1 2]) + end + + it 'excludes key if parameter is not set' do + parameter = { + 'in' => 'cookie', 'name' => 'Some', 'explode' => false, 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, '')).to eq({}) + end + end + + describe 'Object explode true' do + # NOTE: Nobody seems to understand how explode: true should work + # So the explode flag is ignored for objects. + it 'applies the "form" style by default' do + parameter = { + 'in' => 'cookie', 'name' => 'Some', 'explode' => true, 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, 'Some=R=100,G=200,B=150;')).to eq( + 'Some' => { 'R' => '100', 'G' => '200', 'B' => '150' } + ) + end + + it 'excludes key if parameter is not set' do + parameter = { + 'in' => 'cookie', 'name' => 'Some', 'explode' => true, 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, '')).to eq({}) + end + + it 'accepts the "form" style' do + parameter = { + 'in' => 'cookie', 'name' => 'Some', 'explode' => true, 'style' => 'form', + 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, 'Some=R=100,G=200,B=150;')).to eq( + 'Some' => { 'R' => '100', 'G' => '200', 'B' => '150' } + ) + end + + it 'returns the unpacked value if value is malformated' do + parameter = { + 'in' => 'cookie', 'name' => 'Some', 'explode' => true, 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, 'Some=R=100,G200,B=150;')).to eq('Some' => 'R=100,G200,B=150') + end + end + + describe 'Object explode false' do + it 'applies the "simple" style and explode false by default' do + parameter = { 'in' => 'cookie', 'name' => 'Some', 'schema' => { 'type' => 'object' } } + expect(unpack(parameter, 'Some=R,100,G,200,B,150;')).to eq( + 'Some' => { 'R' => '100', 'G' => '200', 'B' => '150' } + ) + end + + it 'returns the unpacked value if value is malformated' do + parameter = { 'in' => 'cookie', 'name' => 'Some', 'schema' => { 'type' => 'object' } } + expect(unpack(parameter, 'Some=R,100,G200,B,150;')).to eq('Some' => 'R,100,G200,B,150') + end + + it 'accepts the "simple" style' do + parameter = { + 'in' => 'cookie', 'name' => 'Some', 'explode' => false, 'style' => 'simple', + 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, 'Some=R,100,G,200,B,150;')).to eq( + 'Some' => { 'R' => '100', 'G' => '200', 'B' => '150' } + ) + end + end + end +end diff --git a/spec/parameters/header_parameters_spec.rb b/spec/parameters/header_parameters_spec.rb new file mode 100644 index 00000000..4c75cc7e --- /dev/null +++ b/spec/parameters/header_parameters_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +RSpec.describe OpenapiFirst::Parameters::Parser do + def unpack(definitions, headers) + definitions = [definitions] unless definitions.is_a?(Array) + described_class.new(build_parameters(definitions)).unpack(headers) + end + + describe 'header parameters' do + it 'returns the converted value' do + parameter = { 'in' => 'header', 'name' => 'X-Some', 'schema' => { 'type' => 'integer' } } + expect(unpack(parameter, { 'X-Some' => '12' })).to eq('X-Some' => 12) + end + + it 'excludes unknown headers' do + parameter = { 'in' => 'header', 'name' => 'X-Some', 'schema' => { 'type' => 'string' } } + expect(unpack(parameter, { 'X-Some' => 'abc', 'X-Unknown' => 'xyz' })).to eq('X-Some' => 'abc') + end + + describe 'with a HeadersHash' do + it 'finds headers in a Rack env' do + parameter = { 'in' => 'header', 'name' => 'X-Some' } + headers = OpenapiFirst::Parameters::HeadersHash.new({ 'HTTP_X_SOME' => 'abc' }) + expect(unpack(parameter, headers)).to eq('X-Some' => 'abc') + end + + it 'finds headers that are not prefixed with HTTP_ in a Rack env' do + parameters = [ + { 'in' => 'header', 'name' => 'Content-Length', 'schema' => { 'type' => 'integer' } }, + { 'in' => 'header', 'name' => 'Content-Type' } + ] + env = { 'CONTENT_LENGTH' => '12', 'CONTENT_TYPE' => 'application/json' } + headers = OpenapiFirst::Parameters::HeadersHash.new(env) + expect(unpack(parameters, headers)).to eq('Content-Length' => 12, 'Content-Type' => 'application/json') + end + end + + describe 'Primitive parameter' do + it 'returns a string' do + parameter = { 'in' => 'header', 'name' => 'X-Some', 'schema' => { 'type' => 'string' } } + expect(unpack(parameter, { 'X-Some' => '12' })).to eq('X-Some' => '12') + end + + it 'does not add key if not set' do + parameter = { 'in' => 'header', 'name' => 'X-Some', 'schema' => { 'type' => 'integer' } } + expect(unpack(parameter, {})).to eq({}) + end + + it 'works with special characters in names' do + parameter = { 'in' => 'header', 'name' => '[]some[things]%', 'schema' => { 'type' => 'integer' } } + expect(unpack(parameter, { '[]some[things]%' => '12' })).to eq('[]some[things]%' => 12) + end + end + + describe 'Array explode true' do + it 'returns an array' do + parameter = { + 'in' => 'header', 'name' => 'X-Some', 'explode' => true, 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, { 'X-Some' => '1,2' })).to eq('X-Some' => %w[1 2]) + end + + it 'excludes key if not set' do + parameter = { + 'in' => 'header', 'name' => 'X-Some', 'explode' => true, 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, {})).to eq({}) + end + end + + describe 'Array explode false' do + it 'returns an array' do + parameter = { + 'in' => 'header', 'name' => 'X-Some', 'explode' => false, 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, { 'X-Some' => '1,2' })).to eq('X-Some' => %w[1 2]) + end + end + + describe 'Object explode true' do + it 'applies the "simple" style by default' do + parameter = { + 'in' => 'header', 'name' => 'X-Some', 'explode' => true, 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, { 'X-Some' => 'R=100,G=200,B=150' })).to eq( + 'X-Some' => { 'R' => '100', 'G' => '200', 'B' => '150' } + ) + end + + it 'excludes if not set' do + parameter = { + 'in' => 'header', 'name' => 'X-Some', 'explode' => true, 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, {})).to eq({}) + end + + it 'accepts the "simple" style' do + parameter = { + 'in' => 'header', 'name' => 'X-Some', 'explode' => true, 'style' => 'simple', + 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, { 'X-Some' => 'R=100,G=200,B=150' })).to eq( + 'X-Some' => { 'R' => '100', 'G' => '200', 'B' => '150' } + ) + end + + it 'returns the unpacked value if value is malformated' do + parameter = { + 'in' => 'header', 'name' => 'X-Some', 'explode' => true, 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, { 'X-Some' => 'R=100,G200,B=150' })).to eq('X-Some' => 'R=100,G200,B=150') + end + end + + describe 'Object explode false' do + it 'applies the "simple" style and explode false by default' do + parameter = { 'in' => 'header', 'name' => 'X-Some', 'schema' => { 'type' => 'object' } } + expect(unpack(parameter, { 'X-Some' => 'R,100,G,200,B,150' })).to eq( + 'X-Some' => { 'R' => '100', 'G' => '200', 'B' => '150' } + ) + end + + it 'returns the unpacked value if value is malformated' do + parameter = { 'in' => 'header', 'name' => 'X-Some', 'schema' => { 'type' => 'object' } } + expect(unpack(parameter, { 'X-Some' => 'R,100,G200,B,150' })).to eq('X-Some' => 'R,100,G200,B,150') + end + + it 'accepts the "simple" style' do + parameter = { + 'in' => 'header', 'name' => 'X-Some', 'explode' => false, 'style' => 'simple', + 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, { 'X-Some' => 'R,100,G,200,B,150' })).to eq( + 'X-Some' => { 'R' => '100', 'G' => '200', 'B' => '150' } + ) + end + end + end +end diff --git a/spec/parameters/object_converter_spec.rb b/spec/parameters/object_converter_spec.rb new file mode 100644 index 00000000..b912840f --- /dev/null +++ b/spec/parameters/object_converter_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +RSpec.describe OpenapiFirst::Parameters::ObjectConverter do + describe '.get_properties' do + it 'returns properties for a simple object' do + schema = { + 'type' => 'object', + 'properties' => { + 'name' => { 'type' => 'string' }, + 'age' => { 'type' => 'integer' } + } + } + properties = described_class.get_properties(schema) + expect(properties).to eq( + 'name' => { 'type' => 'string' }, + 'age' => { 'type' => 'integer' } + ) + end + + it 'handles additionalProperties with an object' do + schema = { + 'type' => 'object', + 'additionalProperties' => { + 'color' => { 'type' => 'string' } + }, + 'properties' => { + 'name' => { 'type' => 'string' } + } + } + properties = described_class.get_properties(schema) + expect(properties).to eq( + 'name' => { 'type' => 'string' }, + 'color' => { 'type' => 'string' } + ) + end + + it 'handles additionalProperties: false' do + schema = { + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => { + 'name' => { 'type' => 'string' } + } + } + properties = described_class.get_properties(schema) + expect(properties).to eq( + 'name' => { 'type' => 'string' } + ) + end + + it 'handles properties defined in then/else' do + schema = { + 'type' => 'object', + 'if' => { 'properties' => { 'weekday' => { 'const' => 'Monday' } } }, + 'then' => { 'properties' => { 'topping' => { 'type' => 'string' } } }, + 'else' => { 'properties' => { 'side' => { 'type' => 'string' } } } + } + properties = described_class.get_properties(schema) + expect(properties).to eq( + 'topping' => { 'type' => 'string' }, + 'side' => { 'type' => 'string' } + ) + end + + it 'returns nil for a schema without properties' do + expect(described_class.get_properties({ 'type' => 'object' })).to be_nil + end + + it 'returns nil for an empty schema' do + expect(described_class.get_properties({})).to be_nil + end + + it 'returns nil if no schema is given' do + expect(described_class.get_properties(nil)).to be_nil + end + + it 'handles if-then-else' do + schema = { + 'type' => 'object', + 'properties' => { + 'customer_name' => { + 'type' => 'string' + }, + 'weekday' => { + 'default' => 'Tuesday', + 'enum' => %w[ + Monday + Tuesday + ] + } + }, + 'if' => { + 'properties' => { + 'weekday' => { + 'const' => 'Monday' + } + } + }, + 'then' => { + 'additionalProperties' => { + 'topping' => { + 'type' => 'string' + } + } + }, + 'else' => { + 'additionalProperties' => false + } + } + + properties = described_class.get_properties(schema) + expect(properties).to eq( + 'customer_name' => { + 'type' => 'string' + }, + 'weekday' => { + 'default' => 'Tuesday', + 'enum' => %w[ + Monday + Tuesday + ] + }, + 'topping' => { + 'type' => 'string' + } + ) + end + end +end diff --git a/spec/parameters/parameter_spec.rb b/spec/parameters/parameter_spec.rb new file mode 100644 index 00000000..160883ed --- /dev/null +++ b/spec/parameters/parameter_spec.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +RSpec.describe OpenapiFirst::Parameters::Parameter do + describe '#name' do + it 'returns the name' do + parameter = build_parameter({ 'in' => 'query', 'name' => 'id' }) + expect(parameter.name).to eq 'id' + end + end + + describe '#convert' do + it 'converts the value' do + parameter = build_parameter({ 'in' => 'query', 'name' => 'id', 'schema' => { 'type' => 'integer' } }) + expect(parameter.convert('2')).to eq 2 + end + end + + describe '#location' do + it 'returns the "in" value' do + parameter = build_parameter({ 'in' => 'query', 'name' => 'id' }) + expect(parameter.location).to eq 'query' + end + end + + describe '#schema' do + it 'returns the schema' do + parameter = build_parameter({ 'in' => 'query', 'name' => 'id', 'schema' => { 'type' => 'string' } }) + expect(parameter.schema).to eq({ 'type' => 'string' }) + end + end + + describe '#array?' do + it 'returns true if type is array' do + parameter = build_parameter({ 'in' => 'query', 'schema' => { 'type' => 'array' } }) + expect(parameter.array?).to be true + end + + it 'returns false if type is not array' do + parameter = build_parameter({ 'in' => 'query', 'schema' => { 'type' => 'string' } }) + expect(parameter.array?).to be false + end + end + + describe '#object?' do + it 'returns true if type is object' do + parameter = build_parameter({ 'in' => 'query', 'schema' => { 'type' => 'object' } }) + expect(parameter.object?).to be true + end + + it 'returns true if style is deepObject' do + parameter = build_parameter({ 'in' => 'query', 'style' => 'deepObject' }) + expect(parameter.object?).to be true + end + + it 'returns true if schema defines properties' do + parameter = build_parameter( + { 'in' => 'query', 'schema' => { 'properties' => { 'a' => { 'type' => 'string' } } } } + ) + expect(parameter.object?).to be true + end + + it 'returns false if type is not object' do + parameter = build_parameter({ 'in' => 'query', 'schema' => { 'type' => 'string' } }) + expect(parameter.object?).to be false + end + end + + describe '#deep_object?' do + it 'returns true if style is deepObject' do + parameter = build_parameter({ 'in' => 'query', 'style' => 'deepObject' }) + expect(parameter.deep_object?).to be true + end + + it 'returns false if style is not deepObject' do + parameter = build_parameter({ 'in' => 'query' }) + expect(parameter.deep_object?).to be false + end + end + + describe '#style' do + it 'returns the style if defined' do + parameter = build_parameter({ 'in' => 'query', 'style' => 'spaceDelimited' }) + expect(parameter.style).to eq 'spaceDelimited' + end + + it 'returns "form" for query parameters' do + parameter = build_parameter({ 'in' => 'query' }) + expect(parameter.style).to eq 'form' + end + + it 'returns "simple" for path parameters' do + parameter = build_parameter({ 'in' => 'path' }) + expect(parameter.style).to eq 'simple' + end + + it 'returns "simple" for header parameters' do + parameter = build_parameter({ 'in' => 'header' }) + expect(parameter.style).to eq 'simple' + end + + it 'returns "form" for cookie parameters' do + parameter = build_parameter({ 'in' => 'cookie' }) + expect(parameter.style).to eq 'form' + end + end + + describe '#explode?' do + it 'returns true if explode is true' do + parameter = build_parameter({ 'in' => 'query', 'explode' => true }) + expect(parameter.explode?).to be true + end + + it 'returns false if explode is false' do + parameter = build_parameter({ 'in' => 'query', 'explode' => false }) + expect(parameter.explode?).to be false + end + + describe 'when explode is not specified' do + it 'returns true if style is "form"' do + parameter = build_parameter({ 'in' => 'query', 'style' => 'form' }) + expect(parameter.explode?).to be true + end + + it 'returns false if style is not "form"' do + parameter = build_parameter({ 'in' => 'query', 'style' => 'spaceDelimited' }) + expect(parameter.explode?).to be false + end + end + end + + describe '#media_type' do + it 'returns the media type' do + parameter = build_parameter( + { 'in' => 'query', 'content' => { 'application/json' => { 'schema' => { 'type' => 'string' } } } } + ) + expect(parameter.media_type).to eq 'application/json' + end + + it 'returns nil if "content" is not defined' do + parameter = build_parameter({ 'in' => 'query' }) + expect(parameter.media_type).to be_nil + end + end + + describe '#unpack' do + it 'returns nil if the value is nil' do + parameter = build_parameter({ 'in' => 'query', 'name' => 'id', 'schema' => { 'type' => 'string' } }) + expect(parameter.unpack(nil)).to be_nil + end + + it 'parses a value of a parameter with a JSON media type' do + parameter = build_parameter( + { 'in' => 'query', 'name' => 'filter', + 'content' => { 'application/json' => { 'schema' => { 'type' => 'object' } } } } + ) + expect(parameter.unpack('{"a":1}')).to eq({ 'a' => 1 }) + end + + it 'returns the value as is if a parameter with an unknown media type' do + parameter = build_parameter( + { 'in' => 'query', 'name' => 'filter', + 'content' => { 'application/xml' => { 'schema' => { 'type' => 'object' } } } } + ) + expect(parameter.unpack('1')).to eq('1') + end + end +end diff --git a/spec/parameters/path_parameters_spec.rb b/spec/parameters/path_parameters_spec.rb new file mode 100644 index 00000000..7067e1dd --- /dev/null +++ b/spec/parameters/path_parameters_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +RSpec.describe OpenapiFirst::Parameters::Parser do + def unpack(definitions, path_params) + definitions = [definitions] unless definitions.is_a?(Array) + described_class.new(build_parameters(definitions)).unpack(path_params) + end + + describe 'path parameters' do + it 'returns the converted value' do + parameter = { 'in' => 'path', 'name' => 'id', 'schema' => { 'type' => 'integer' } } + expect(unpack(parameter, { 'id' => '12' })).to eq('id' => 12) + end + + it 'excludes unknown keys' do + parameter = { 'in' => 'path', 'name' => 'id', 'schema' => { 'type' => 'string' } } + expect(unpack(parameter, { 'a' => 'b' })).to eq({}) + end + + describe 'Primitive parameter' do + it 'returns multiple values' do + parameters = [ + { 'in' => 'path', 'name' => 'id', 'schema' => { 'type' => 'integer' } }, + { 'in' => 'path', 'name' => 'year', 'schema' => { 'type' => 'integer' } } + ] + expect(unpack(parameters, { 'id' => '12', 'year' => '2022' })).to eq('id' => 12, 'year' => 2022) + end + + it 'supports /{start_date}..{end_date}' do + parameters = [ + { 'in' => 'path', 'name' => 'start_date' }, + { 'in' => 'path', 'name' => 'end_date' } + ] + path_params = { 'start_date' => '2021-01-01', 'end_date' => '2021-01-31' } + expect(unpack(parameters, path_params)).to eq(path_params) + end + end + + describe 'Array explode true' do + it 'returns an array' do + parameter = { + 'in' => 'path', 'name' => 'id', 'explode' => true, 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, { 'id' => '1,2' })).to eq('id' => %w[1 2]) + end + + it 'excludes key if parameter is not set' do + parameter = { + 'in' => 'path', 'name' => 'id', 'explode' => true, 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, {})).to eq({}) + end + + it 'returns an empty array if the value is empty' do + parameter = { + 'in' => 'path', 'name' => 'id', 'explode' => true, 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, { 'id' => '' })).to eq('id' => []) + end + + it 'supports the simple style' do + parameter = { + 'in' => 'path', 'name' => 'id', 'style' => 'simple', 'explode' => true, + 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, { 'id' => '1,2' })).to eq('id' => %w[1 2]) + end + + it 'supports the label style' do + parameter = { + 'in' => 'path', 'name' => 'color', 'explode' => true, 'style' => 'label', + 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, { 'color' => '.blue.black.brown' })).to eq('color' => %w[blue black brown]) + end + + it 'supports the matrix style' do + parameter = { + 'in' => 'path', 'name' => 'color', 'explode' => true, 'style' => 'matrix', + 'schema' => { 'type' => 'array' } + } + path_params = { 'color' => ';color=blue;color=black;color=brown' } + expect(unpack(parameter, path_params)).to eq('color' => %w[blue black brown]) + end + end + + describe 'Array explode false' do + it 'returns an array' do + parameter = { + 'in' => 'path', 'name' => 'id', 'explode' => false, 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, { 'id' => '1,2' })).to eq('id' => %w[1 2]) + end + + it 'supports the simple style' do + parameter = { + 'in' => 'path', 'name' => 'id', 'style' => 'simple', 'explode' => false, + 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, { 'id' => '1,2' })).to eq('id' => %w[1 2]) + end + + it 'supports the label style' do + parameter = { + 'in' => 'path', 'name' => 'id', 'explode' => false, 'style' => 'label', + 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, { 'id' => '.1.2' })).to eq('id' => %w[1 2]) + end + + it 'supports the matrix style' do + parameter = { + 'in' => 'path', 'name' => 'color', 'explode' => false, 'style' => 'matrix', + 'schema' => { 'type' => 'array' } + } + path_params = { 'color' => ';color=blue,black,brown' } + expect(unpack(parameter, path_params)).to eq('color' => %w[blue black brown]) + end + + it 'returns an empty array if a matrix style value is empty' do + parameter = { + 'in' => 'path', 'name' => 'color', 'explode' => false, 'style' => 'matrix', + 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, { 'color' => '' })).to eq('color' => []) + end + end + + describe 'Object explode true' do + it 'applies the "simple" style by default' do + parameter = { + 'in' => 'path', 'name' => 'color', 'explode' => true, 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, { 'color' => 'R=100,G=200,B=150' })).to eq( + 'color' => { 'R' => '100', 'G' => '200', 'B' => '150' } + ) + end + + it 'excludes key if not set' do + parameter = { + 'in' => 'path', 'name' => 'id', 'explode' => true, 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, {})).to eq({}) + end + + it 'accepts the "simple" style' do + parameter = { + 'in' => 'path', 'name' => 'color', 'explode' => true, 'style' => 'simple', + 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, { 'color' => 'R=100,G=200,B=150' })).to eq( + 'color' => { 'R' => '100', 'G' => '200', 'B' => '150' } + ) + end + + it 'returns the unpacked value if value is malformated' do + parameter = { + 'in' => 'path', 'name' => 'color', 'explode' => true, 'style' => 'simple', + 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, { 'color' => 'R=100,G200,B=150' })).to eq( + 'color' => { 'B' => '150', 'G200' => nil, 'R' => '100' } + ) + end + end + + describe 'Object explode false' do + it 'applies the "simple" style and explode false by default' do + parameter = { 'in' => 'path', 'name' => 'color', 'schema' => { 'type' => 'object' } } + expect(unpack(parameter, { 'color' => 'R,100,G,200,B,150' })).to eq( + 'color' => { 'R' => '100', 'G' => '200', 'B' => '150' } + ) + end + + it 'returns the unpacked value if value is malformated' do + parameter = { + 'in' => 'path', 'name' => 'color', 'style' => 'simple', 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, { 'color' => 'R,100,G200,B,150' })).to eq('color' => 'R,100,G200,B,150') + end + + it 'accepts "simple" style' do + parameter = { + 'in' => 'path', 'name' => 'color', 'explode' => false, 'style' => 'simple', + 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, { 'color' => 'R,100,G,200,B,150' })).to eq( + 'color' => { 'R' => '100', 'G' => '200', 'B' => '150' } + ) + end + + it 'accepts "matrix" style' do + parameter = { + 'in' => 'path', 'name' => 'color', 'explode' => false, 'style' => 'matrix', + 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, { 'color' => ';color=R,100,G,200,B,150' })).to eq( + 'color' => { 'R' => '100', 'G' => '200', 'B' => '150' } + ) + end + + it 'accepts "label" style' do + parameter = { + 'in' => 'path', 'name' => 'color', 'explode' => false, 'style' => 'label', + 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, { 'color' => '.R.100.G.200.B.150' })).to eq( + 'color' => { 'R' => '100', 'G' => '200', 'B' => '150' } + ) + end + end + end +end diff --git a/spec/parameters/query-parameter-tests.yaml b/spec/parameters/query-parameter-tests.yaml new file mode 100644 index 00000000..b540de83 --- /dev/null +++ b/spec/parameters/query-parameter-tests.yaml @@ -0,0 +1,826 @@ +--- +# OpenAPI Parameters Query Tests +- description: Returns a string value if no type is defined + parameter: + in: query + name: id + query_string: id=abc + unpacked_value: + id: abc + +- description: Returns an empty string for empty string parameters + parameter: + in: query + name: id + query_string: id=& + unpacked_value: + id: "" + +- description: Returns an empty string for empty integer parameters + parameter: + in: query + name: id + schema: + type: integer + query_string: id=& + unpacked_value: + id: "" + +- description: Excludes unknown query parameters + parameter: + in: query + name: id + schema: + type: string + query_string: id=abc&unknown=xyz + unpacked_value: + id: abc + +- description: Returns a converted value by default + parameter: + in: query + name: id + schema: + type: integer + query_string: id=1 + unpacked_value: + id: 1 + +- description: Converts 'true' + parameter: + in: query + name: bool + schema: + type: boolean + query_string: bool=true + unpacked_value: + bool: true + +- description: Converts 'false' + parameter: + in: query + name: bool + schema: + type: boolean + query_string: bool=false + unpacked_value: + bool: false + +- description: Returns a string value + parameter: + in: query + name: id + schema: + type: string + query_string: id=abc + unpacked_value: + id: abc + +- description: Does not add key if query string is empty + parameter: + in: query + name: id + schema: + type: string + query_string: "" + unpacked_value: {} + +- description: Works with parameters containing dashes + parameter: + in: query + name: pet-id + schema: + type: string + query_string: pet-id=abc + unpacked_value: + pet-id: abc + +- description: Works with brackets in parameter name + parameter: + in: query + name: filter[name] + schema: + type: string + query_string: filter[name]=abc + unpacked_value: + filter[name]: abc + +- description: Works with complex parameter names + parameter: + in: query + name: x[[]abc] + schema: + type: string + query_string: x[[]abc]=abc + unpacked_value: + x[[]abc]: abc + +- description: Applies explode true if explode is undefined + parameter: + in: query + name: name + schema: + type: array + items: + type: string + query_string: name=a&name=b&name=c + unpacked_value: + name: [a, b, c] + +- description: Returns an empty array if query parameter is missing '=' + parameter: + in: query + name: name + schema: + type: array + items: + type: string + query_string: name + unpacked_value: + name: [] + +- description: Returns an empty array if query parameter as an empty value + parameter: + in: query + name: name + schema: + type: array + items: + type: string + query_string: name= + unpacked_value: + name: [] + +- description: Returns an array with explode true + parameter: + in: query + name: name + explode: true + style: form + schema: + type: array + items: + type: string + query_string: name=a&name=b&name=c + unpacked_value: + name: [a, b, c] + +- description: Returns an array with one element + parameter: + in: query + name: name + explode: true + style: form + schema: + type: array + query_string: name=a + unpacked_value: + name: [a] + +- description: Returns an empty array for empty explode parameter + parameter: + in: query + name: name + explode: true + style: form + schema: + type: array + query_string: name= + unpacked_value: + name: [] + +- description: Works with brackets in name + parameter: + in: query + name: names[] + explode: true + style: form + schema: + type: array + items: + type: string + query_string: names[]=a&names[]=b&names[]=c + unpacked_value: + names[]: [a, b, c] + +- description: Converts values with brackets in name + parameter: + in: query + name: ids[] + explode: true + schema: + type: array + items: + type: integer + query_string: ids[]=1&ids[]=2&ids[]=3 + unpacked_value: + ids[]: [1, 2, 3] + +- description: Works with escaped brackets in name + parameter: + in: query + name: names[] + explode: true + style: form + schema: + type: array + items: + type: string + query_string: "names%5B%5D=a&names%5B%5D=b&names%5B%5D=c" + unpacked_value: + names[]: [a, b, c] + +- description: Works with filter[id] pattern + parameter: + in: query + name: filter[id] + explode: true + style: form + schema: + type: array + items: + type: string + query_string: filter[id]=a&filter[id]=b&filter[id]=c + unpacked_value: + filter[id]: [a, b, c] + +- description: Returns an array with form style + parameter: + in: query + name: name + explode: false + style: form + schema: + type: array + items: + type: string + query_string: name=a,b,c + unpacked_value: + name: [a, b, c] + +- description: Returns an array with one element + parameter: + in: query + name: name + explode: false + style: form + schema: + type: array + query_string: name=a + unpacked_value: + name: [a] + +- description: Returns an empty array for empty non-explode parameter + parameter: + in: query + name: name + explode: false + style: form + schema: + type: array + query_string: name=& + unpacked_value: + name: [] + +- description: Supports spaceDelimited style + parameter: + in: query + name: name + explode: false + style: spaceDelimited + schema: + type: array + items: + type: string + query_string: name=a%20b%20c + unpacked_value: + name: [a, b, c] + +- description: Supports pipeDelimited style + parameter: + in: query + name: name + explode: false + style: pipeDelimited + schema: + type: array + items: + type: string + query_string: name=a%7Cb%7Cc + unpacked_value: + name: [a, b, c] + +- description: Returns an object with deep object style + parameter: + - in: query + name: color + explode: true + style: deepObject + schema: + type: object + properties: + R: + type: integer + G: + type: integer + B: + type: integer + - in: query + name: name + schema: + type: string + query_string: color[R]=100&color[G]=200&color[B]=150&name=ada + unpacked_value: + color: + R: 100 + G: 200 + B: 150 + name: ada + +- description: Does not double-unescape deepObject values (issue #25) + parameter: + - in: query + name: filter + explode: true + style: deepObject + schema: + type: object + properties: + from: + type: string + format: date-time + query_string: filter[from]=2026-01-01T00%3A00%3A00.000%2B00%3A00 + unpacked_value: + filter: + from: "2026-01-01T00:00:00.000+00:00" + +- description: Returns the plain query parameter value if it does not look like deepObject + parameter: + - in: query + name: color + explode: true + style: deepObject + schema: + type: object + properties: + R: + type: integer + - in: query + name: name + schema: + type: string + query_string: color=RGB&name=ada + unpacked_value: { color: "RGB", name: "ada" } + +- description: Does not add key if not set + parameter: + in: query + name: color + explode: true + style: deepObject + schema: + type: object + properties: + R: + type: integer + G: + type: integer + B: + type: integer + nested: + type: object + properties: + a: + type: integer + query_string: "" + unpacked_value: {} + +- description: Ignores nested objects + parameter: + in: query + name: color + style: deepObject + schema: + type: object + properties: + R: + type: integer + G: + type: integer + B: + type: integer + nested: + type: object + properties: + a: + type: integer + query_string: color[R]=100&color[nested][a]=42 + unpacked_value: + color: + R: 100 + +- description: Still returns an object without object type specified + parameter: + in: query + name: color + style: deepObject + schema: + properties: + R: + type: integer + query_string: color[R]=100 + unpacked_value: + color: + R: 100 + +- description: Converts single value in nested array + parameter: + in: query + name: color + explode: true + style: deepObject + schema: + type: object + properties: + values: + type: array + items: + type: integer + query_string: color[values]=100 + unpacked_value: + color: + values: [100] + +- description: Converts multiple values in nested array + parameter: + in: query + name: color + explode: true + style: deepObject + schema: + type: object + properties: + values: + type: array + items: + type: integer + query_string: color[values]=100&color[values]=255 + unpacked_value: + color: + values: [100, 255] + +- description: Does not double-unescape exploded deepObject array values (issue #25) + parameter: + in: query + name: filter + explode: true + style: deepObject + schema: + type: object + properties: + tags: + type: array + items: + type: string + query_string: filter[tags]=a%2Bb&filter[tags]=c%20d + unpacked_value: + filter: + tags: + - "a+b" + - "c d" + +- description: Does not double-unescape single-value deepObject array values (issue #25) + parameter: + in: query + name: filter + explode: true + style: deepObject + schema: + type: object + properties: + tags: + type: array + items: + type: string + query_string: filter[tags]=a%2Bb + unpacked_value: + filter: + tags: + - "a+b" + +- description: Does does not add rack-style parameters unless described + parameter: + in: query + name: color + explode: true + style: deepObject + schema: + type: object + properties: + values: + type: array + items: + type: integer + query_string: color[values][]=100&color[values][]=255 + unpacked_value: {} + +- description: Unpacks rack-style array values if they are described alongside other deepObjects + parameter: + - in: query + name: filter + explode: true + style: deepObject + schema: + type: object + properties: + id: + type: array + items: + type: integer + - in: query + name: color[values][] + explode: true + schema: + type: array + items: + type: integer + query_string: color[values][]=100&color[values][]=255&filter[id]=200 + unpacked_value: + filter: + id: [200] + "color[values][]": [100, 255] + +- description: Converts non-exploded nested arrays + parameter: + in: query + name: color + style: deepObject + schema: + type: object + properties: + values: + type: array + items: + type: integer + query_string: color[values]=100&color[values]=255 + unpacked_value: + color: + values: [255] + +- description: Does not convert comma-separated values + parameter: + in: query + name: color + explode: true + style: deepObject + schema: + type: object + properties: + values: + type: array + items: + type: integer + query_string: color[values]=100,255 + unpacked_value: + color: + values: ["100,255"] + +- description: Works with dashes in deepObject property names + parameter: + in: query + name: filter + style: deepObject + schema: + type: object + properties: + first-name: + type: string + query_string: filter[first-name]=ada + unpacked_value: + filter: + first-name: ada + +- description: Without matching query + parameter: + in: query + name: filter + style: deepObject + schema: + type: object + properties: + first-name: + type: string + query_string: some[thing]=ada + unpacked_value: {} + +- description: Supports form style with explode false + parameter: + in: query + name: color + explode: false + style: form + schema: + type: object + properties: + R: + type: integer + G: + type: integer + B: + type: integer + query_string: color=R,100,G,200,B,150 + unpacked_value: + color: + R: 100 + G: 200 + B: 150 +- description: With deepObject and oneOf + parameter: + in: query + name: filter + style: deepObject + schema: + oneOf: + - type: object + required: + - departement + properties: + end_date: + type: string + format: date + start_date: + type: string + format: date + departement: + type: string + - type: object + required: + - floor + properties: + end_date: + type: string + format: date + start_date: + type: string + format: date + floor: + type: string + query_string: "filter%5Bend_date%5D=2025-02-01&filter%5Bdepartment%5D=paper&filter%5Bstart_date%5D=2025-01-01&filter%5Bfloor%5D=1" + unpacked_value: + filter: + department: paper + end_date: "2025-02-01" + start_date: "2025-01-01" + floor: "1" +- description: Convert deepObject properties with oneOf + parameter: + in: query + name: filter + style: deepObject + schema: + oneOf: + - type: object + properties: + dep: + type: string + - type: object + required: + - floor + properties: + floor: + type: integer + query_string: "filter[dep]=paper&filter[floor]=1" + unpacked_value: + filter: + dep: paper + floor: 1 + +- description: Returns unknown values + parameter: + in: query + name: id + query_string: id=abc&unknown=value + unknown_values: + unknown: value + +- description: Returns no unknown values + parameter: + in: query + name: id + query_string: id=abc + unknown_values: null + +- description: Returns unknown exploded values + parameter: + in: query + name: id + query_string: id=abc&unknown=value&unknown=value + unknown_values: + unknown: [value, value] + +- description: Returns unknown values with deepObject parameters + parameter: + in: query + name: filter + style: deepObject + schema: + type: object + properties: + name: + type: string + query_string: filter[name]=ada&unknown=value + unpacked_value: + filter: + name: ada + unknown_values: + unknown: value + +- description: Returns unknown deepObject like values + parameter: + in: query + name: filter + style: deepObject + schema: + type: object + properties: + name: + type: string + query_string: filter[name]=ada&unknown[name]=value + unpacked_value: + filter: + name: ada + unknown_values: + unknown[name]: value + +- description: Returns unknown deepObject like values + parameter: + in: query + name: ids[] + schema: + type: array + items: + type: integer + query_string: ids[]=1&ids[]=2&unknown=value + unpacked_value: + ids[]: [1, 2] + unknown_values: + unknown: value + +- description: support application/json data via content attribute + parameter: + name: filters + in: query + content: + application/json: + schema: + type: object + properties: + from_date: + type: string + to_date: + type: string + query_string: 'filters={"from_date":"2026-05-18","to_date":"2026-05-19"}&ids[]=2&unknown=value' + unpacked_value: + filters: + from_date: "2026-05-18" + to_date: "2026-05-19" + unknown_values: + "ids[]": "2" + unknown: value + +- description: content parameter falls through to raw string on invalid JSON + parameter: + name: filters + in: query + content: + application/json: + schema: + type: object + query_string: filters=not-json + unpacked_value: + filters: not-json + +- description: Unpacks a deepObject parameter that does not define properties + parameter: + in: query + name: filter + style: deepObject + explode: true + schema: + type: object + query_string: filter[a]=1&filter[b]=2 + unpacked_value: + filter: + a: "1" + b: "2" + +- description: Returns the values as is for a matrix style parameter with repeated values + parameter: + in: query + name: color + style: matrix + schema: + type: array + query_string: color=blue&color=black + unpacked_value: + color: [blue, black] diff --git a/spec/parameters/query_parser_spec.rb b/spec/parameters/query_parser_spec.rb new file mode 100644 index 00000000..65358586 --- /dev/null +++ b/spec/parameters/query_parser_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'yaml' + +RSpec.describe OpenapiFirst::Parameters::QueryParser do + def parser_for(definitions) + definitions = [definitions] unless definitions.is_a?(Array) + described_class.new(build_parameters(definitions)) + end + + describe '#unpack' do + tests = YAML.load_file(File.expand_path('./query-parameter-tests.yaml', __dir__)) + + tests.each do |test| + description = test['description'] + next unless test['unpacked_value'] + + it description do + parameter, query_string, unpacked_value = test.values_at('parameter', 'query_string', 'unpacked_value') + expect(parser_for(parameter).unpack(query_string)).to eq(unpacked_value) + end + end + + context 'with invalid query string encoding' do + it 'raises an exception' do + parser = parser_for({ 'in' => 'query', 'name' => 'limit' }) + expect do + parser.unpack('limit=%E0%A4%A') + end.to raise_error(Rack::Utils::InvalidParameterError, 'invalid %-encoding (%E0%A4%A)') + end + end + end + + describe '#unknown_values' do + tests = YAML.load_file(File.expand_path('./query-parameter-tests.yaml', __dir__)) + + tests.each do |test| + next unless test.key?('unknown_values') + + it test['description'] do + parameter, query_string, unknown_values = test.values_at('parameter', 'query_string', 'unknown_values') + expect(parser_for(parameter).unknown_values(query_string)).to eq(unknown_values) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 31a5f085..a0e2ae2d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -8,6 +8,7 @@ require 'json' require 'simplecov' require 'rack/test' +require_relative 'support/parameter_helpers' SimpleCov.start do enable_coverage :branch @@ -27,6 +28,8 @@ c.syntax = :expect end + config.include ParameterHelpers + config.after(:each) do OpenapiFirst::Test.definitions.clear OpenapiFirst.definitions.clear diff --git a/spec/support/parameter_helpers.rb b/spec/support/parameter_helpers.rb new file mode 100644 index 00000000..72c9e50a --- /dev/null +++ b/spec/support/parameter_helpers.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module ParameterHelpers + def build_parameter(definition) + _media_type, media_type_object = definition['content']&.first + OpenapiFirst::Parameters::Parameter.new(definition, schema: (media_type_object || definition)['schema']) + end + + def build_parameters(definitions) + definitions.map { build_parameter(_1) } + end +end From 50ee191bf9d1b8d90f64675b739c9895ea6532da Mon Sep 17 00:00:00 2001 From: Andreas Haller Date: Mon, 27 Jul 2026 16:45:09 +0200 Subject: [PATCH 2/3] Add Request#parameters to introspect parameter definitions Request#parameters returns the parameters defined for a request as Parameters::Parameter objects, ordered path, query, header, cookie, and excluding the header parameters openapi_first ignores. Parameter is public API now and answers required? and deprecated? again, which describing a parameter needs. This replaces the reader that was removed when openapi_parameters was merged in, which handed out Builder's private_constant bundle of raw parameter hashes and JSON schemas. Deliberately not delegated from ValidatedRequest: that would shadow ActionDispatch::Request#parameters for Rails users. Co-Authored-By: Claude --- CHANGELOG.md | 3 +- README.md | 12 ++++ lib/openapi_first/builder.rb | 31 +++++---- lib/openapi_first/parameters/parameter.rb | 7 +- lib/openapi_first/request.rb | 5 +- spec/parameters/parameter_spec.rb | 40 +++++++++++ spec/request_spec.rb | 82 +++++++++++++++++++++++ 7 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 spec/request_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 64b059f6..1e3c5483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ - An `after_request_body_property_validation` hook sees an empty String instead of the file. - Fields that were not sent as a file, and fields with a JSON `contentType` in the `encoding` map, are read and validated as before. - Changed: The `openapi_parameters` gem was merged into openapi_first and is not a dependency anymore. Parameter parsing now lives in `OpenapiFirst::Parameters`. If you registered a parser for parameters that use a `content` field, use `OpenapiFirst::Parameters::ContentParsers.register` instead of `OpenapiParameters::ContentParsers.register`. -- Removed: `OpenapiFirst::Request#parameters` and `OpenapiFirst::Request#query_schema`, which were internal scaffolding. `#query_schema` always returned `nil`. +- Added: `OpenapiFirst::Request#parameters` returns the parameters that are defined for a request as `OpenapiFirst::Parameters::Parameter` objects, which expose `name`, `location`, `schema`, `required?`, `deprecated?`, `style`, `explode?` and `media_type`. It used to return an internal object with a different interface. +- Removed: `OpenapiFirst::Request#query_schema`, which was internal scaffolding and always returned `nil`. - Changed: `OpenapiFirst::Header` (returned by `Response#headers`) exposes `resolved_schema` instead of `node`. - Fixed: The JSON schema of a parameter that uses a `content` field with a `$ref`'d schema is resolved now. - Fixed: Loading a document no longer raises `NoMethodError` when a parameter has neither `schema` nor `content`. diff --git a/README.md b/README.md index 1e667335..ee84b70e 100644 --- a/README.md +++ b/README.md @@ -357,6 +357,18 @@ validated_request.operation['operationId'] => "getStuff" validated_request.request_definition.path # => "/pets/{petId}" validated_request.request_definition.operation_id # => "showPetById" +# Inspect the parameters that are defined for this request, in the order path, query, header, cookie. +# Parameters that openapi_first ignores (Content-Type, Accept, Authorization) are not included. +parameter = validated_request.request_definition.parameters.first +parameter.name # => "petId" +parameter.location # => "path" ("path", "query", "header" or "cookie") +parameter.schema # => { "type" => "integer" } (the JSON Schema, with $refs resolved) +parameter.required? # => true +parameter.deprecated? # => false +parameter.style # => "simple" +parameter.explode? # => false +parameter.media_type # => "application/json" if the parameter uses `content`, otherwise nil + # Or you can raise an exception if validation fails: definition.validate_request(rack_request, raise_error: true) # Raises OpenapiFirst::RequestInvalidError or OpenapiFirst::NotFoundError if request is invalid ``` diff --git a/lib/openapi_first/builder.rb b/lib/openapi_first/builder.rb index 0d189b45..56181fba 100644 --- a/lib/openapi_first/builder.rb +++ b/lib/openapi_first/builder.rb @@ -117,25 +117,24 @@ def register_responses(router, request:, path:, request_method:, responses:) end def parse_parameters(parameters) - grouped_parameters = group_parameters(parameters) + grouped = group_parameters(parameters) + path = build_parameters(grouped[:path]) + query = build_parameters(grouped[:query]) + header = build_parameters(grouped[:header]) + cookie = build_parameters(grouped[:cookie]) ParsedParameters.new( - query_parser: Parameters::QueryParser.new(build_parameters(grouped_parameters[:query])), - path_parser: build_parser(grouped_parameters[:path]), - cookie_parser: build_parser(grouped_parameters[:cookie]), - header_parser: build_parser(grouped_parameters[:header]), - query_schema: build_parameter_schema(grouped_parameters[:query]), - path_schema: build_parameter_schema(grouped_parameters[:path]), - cookie_schema: build_parameter_schema(grouped_parameters[:cookie]), - header_schema: build_parameter_schema(grouped_parameters[:header]) + all: [*path, *query, *header, *cookie].freeze, + path_parser: grouped[:path] && Parameters::Parser.new(path), + query_parser: Parameters::QueryParser.new(query), + header_parser: grouped[:header] && Parameters::Parser.new(header), + cookie_parser: grouped[:cookie] && Parameters::Parser.new(cookie), + path_schema: build_parameter_schema(grouped[:path]), + query_schema: build_parameter_schema(grouped[:query]), + header_schema: build_parameter_schema(grouped[:header]), + cookie_schema: build_parameter_schema(grouped[:cookie]) ) end - def build_parser(parameters) - return unless parameters - - Parameters::Parser.new(build_parameters(parameters)) - end - def build_parameters(parameters) parameters.to_a.map do |parameter| Parameters::Parameter.new(parameter.resolved, schema: parameter_schema_node(parameter)&.resolved) @@ -248,7 +247,7 @@ def group_parameters(parameter_definitions) result end - ParsedParameters = Data.define(:path_parser, :query_parser, :header_parser, :cookie_parser, + ParsedParameters = Data.define(:all, :path_parser, :query_parser, :header_parser, :cookie_parser, :path_schema, :query_schema, :header_schema, :cookie_schema) private_constant :ParsedParameters end diff --git a/lib/openapi_first/parameters/parameter.rb b/lib/openapi_first/parameters/parameter.rb index 5824810d..95bc0bf6 100644 --- a/lib/openapi_first/parameters/parameter.rb +++ b/lib/openapi_first/parameters/parameter.rb @@ -6,7 +6,6 @@ module OpenapiFirst module Parameters # A parameter of a request, or a header of a response. - # @visibility private class Parameter DEFAULT_STYLE = { 'query' => 'form', @@ -26,6 +25,8 @@ def initialize(definition, schema:) @style = definition['style'] || DEFAULT_STYLE.fetch(@location) @explode = definition.fetch('explode') { @style == 'form' } @deep_object = @style == 'deepObject' + @required = @location == 'path' || definition['required'] == true + @deprecated = definition['deprecated'] == true @converter = Converter[schema] @unpacker = Unpackers.find(self) end @@ -42,6 +43,10 @@ def convert(value) = @converter.call(value) def explode? = @explode + def required? = @required + + def deprecated? = @deprecated + def deep_object? = @deep_object def type = schema && schema['type'] diff --git a/lib/openapi_first/request.rb b/lib/openapi_first/request.rb index 0e3f466c..c35b95d3 100644 --- a/lib/openapi_first/request.rb +++ b/lib/openapi_first/request.rb @@ -12,7 +12,7 @@ module OpenapiFirst # An 3.x Operation object can accept multiple requests, because it can handle multiple content-types. # This class represents one of those requests. class Request - def initialize(path:, request_method:, operation_object:, # rubocop:disable Metrics/ParameterLists + def initialize(path:, request_method:, operation_object:, # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists parameters:, content_type:, content_schema:, required_body:, key:, encoding: nil) @path = path @request_method = request_method @@ -21,6 +21,7 @@ def initialize(path:, request_method:, operation_object:, # rubocop:disable Metr @operation = operation_object @allow_empty_content = content_type.nil? || required_body == false @key = key + @parameters = parameters.all @query_parser = parameters.query_parser @path_parser = parameters.path_parser @header_parser = parameters.header_parser @@ -37,7 +38,7 @@ def initialize(path:, request_method:, operation_object:, # rubocop:disable Metr ) end - attr_reader :content_type, :content_schema, :operation, :request_method, :path, :key + attr_reader :content_type, :content_schema, :operation, :request_method, :path, :key, :parameters private attr_reader :query_parser def allow_empty_content? diff --git a/spec/parameters/parameter_spec.rb b/spec/parameters/parameter_spec.rb index 160883ed..265e9c87 100644 --- a/spec/parameters/parameter_spec.rb +++ b/spec/parameters/parameter_spec.rb @@ -128,6 +128,46 @@ end end + describe '#required?' do + it 'returns true if required is true' do + parameter = build_parameter({ 'in' => 'query', 'required' => true }) + expect(parameter.required?).to be true + end + + it 'returns false if required is false' do + parameter = build_parameter({ 'in' => 'query', 'required' => false }) + expect(parameter.required?).to be false + end + + it 'returns false if required is not specified' do + %w[query header cookie].each do |location| + expect(build_parameter({ 'in' => location }).required?).to be false + end + end + + it 'returns true for path parameters' do + parameter = build_parameter({ 'in' => 'path' }) + expect(parameter.required?).to be true + end + end + + describe '#deprecated?' do + it 'returns true if deprecated is true' do + parameter = build_parameter({ 'in' => 'query', 'deprecated' => true }) + expect(parameter.deprecated?).to be true + end + + it 'returns false if deprecated is false' do + parameter = build_parameter({ 'in' => 'query', 'deprecated' => false }) + expect(parameter.deprecated?).to be false + end + + it 'returns false if deprecated is not specified' do + parameter = build_parameter({ 'in' => 'query' }) + expect(parameter.deprecated?).to be false + end + end + describe '#media_type' do it 'returns the media type' do parameter = build_parameter( diff --git a/spec/request_spec.rb b/spec/request_spec.rb new file mode 100644 index 00000000..dc93b4f4 --- /dev/null +++ b/spec/request_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +RSpec.describe OpenapiFirst::Request do + describe '#parameters' do + let(:definition) do + OpenapiFirst.parse({ + 'openapi' => '3.1.0', + 'paths' => { + '/stuff/{id}' => { + 'parameters' => [ + { 'name' => 'id', 'in' => 'path', 'required' => true, + 'schema' => { 'type' => 'integer' } } + ], + 'get' => { + 'parameters' => [ + { 'name' => 'Accept', 'in' => 'header' }, + { 'name' => 'tags', 'in' => 'query', 'style' => 'form', 'explode' => false, + 'deprecated' => true, 'schema' => { 'type' => 'array' } }, + { 'name' => 'X-Key', 'in' => 'header', 'required' => true, + 'schema' => { 'type' => 'string' } }, + { 'name' => 'session', 'in' => 'cookie', 'schema' => { 'type' => 'string' } }, + { 'name' => 'filter', 'in' => 'query', + 'content' => { 'application/json' => { 'schema' => { 'type' => 'object' } } } } + ] + } + } + } + }) + end + + let(:request_definition) do + definition.validate_request(Rack::Request.new(Rack::MockRequest.env_for('/stuff/1'))).request_definition + end + + it 'returns the parameters of the operation and the path item, grouped by location' do + expect(request_definition.parameters.map(&:name)).to eq(%w[id tags filter X-Key session]) + expect(request_definition.parameters.map(&:location)).to eq(%w[path query query header cookie]) + end + + it 'describes a parameter' do + parameter = request_definition.parameters.find { _1.name == 'tags' } + expect(parameter).to have_attributes( + name: 'tags', + location: 'query', + style: 'form', + explode?: false, + required?: false, + deprecated?: true, + media_type: nil, + schema: { 'type' => 'array' } + ) + end + + it 'knows that path parameters are required' do + parameter = request_definition.parameters.find { _1.name == 'id' } + expect(parameter).to have_attributes(required?: true, deprecated?: false, schema: { 'type' => 'integer' }) + end + + it 'describes a parameter that uses content' do + parameter = request_definition.parameters.find { _1.name == 'filter' } + expect(parameter).to have_attributes(media_type: 'application/json', schema: { 'type' => 'object' }) + end + + it 'excludes parameters that openapi_first ignores' do + expect(request_definition.parameters.map(&:name)).not_to include('Accept') + end + + it 'returns a frozen Array' do + expect(request_definition.parameters).to be_frozen + end + + context 'without parameters' do + let(:definition) do + OpenapiFirst.parse({ 'openapi' => '3.1.0', 'paths' => { '/stuff/{id}' => { 'get' => {} } } }) + end + + it 'is empty' do + expect(request_definition.parameters).to eq([]) + end + end + end +end From 334d3f2fd49e9eaa3db755736042e7dcc320d4a0 Mon Sep 17 00:00:00 2001 From: Andreas Haller Date: Mon, 27 Jul 2026 22:46:16 +0200 Subject: [PATCH 3/3] Return a validation error for values that cannot be unpacked Malformed parameter values escaped as exceptions out of validate_request instead of failing validation, all reachable from untrusted input: - Repeated values for a query parameter that describes an object or uses content (?filter=a&filter=b) reached String#split or JSON.parse with an Array. These now throw :skip, so the raw values fall through to schema validation, like the other values these unpackers cannot unpack. - A style: matrix value that does not contain the parameter name, or contains it more than once, reached nil.split or Array#split. These are now left to the converter, which is what the same values already did with explode: true, so both spellings agree. - An invalid %-encoding in a matrix or path object value made Rack::Utils.parse_query raise, and the value now falls through to schema validation. Rack 2.2 and 3.0 raise a plain ArgumentError here, Rack 3.2 an InvalidParameterError, which is one. Every value that could be unpacked before is unpacked to exactly the same thing. The behaviour was inherited from openapi_parameters 0.13.0. Co-Authored-By: Claude --- CHANGELOG.md | 3 ++ .../parameters/content_parsers.rb | 2 + lib/openapi_first/parameters/unpackers.rb | 20 +++++-- spec/definition_spec.rb | 54 +++++++++++++++++++ spec/parameters/path_parameters_spec.rb | 31 +++++++++++ spec/parameters/query-parameter-tests.yaml | 33 ++++++++++++ 6 files changed, 140 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e3c5483..18906b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ - Changed: `OpenapiFirst::Header` (returned by `Response#headers`) exposes `resolved_schema` instead of `node`. - Fixed: The JSON schema of a parameter that uses a `content` field with a `$ref`'d schema is resolved now. - Fixed: Loading a document no longer raises `NoMethodError` when a parameter has neither `schema` nor `content`. +- Fixed: Repeated values for a query parameter that describes an object or uses `content` (`?filter=a&filter=b`) raised a `NoMethodError` or `TypeError`. The values are validated against the schema now, which returns an `:invalid_query` failure. +- Fixed: A parameter with `style: matrix` raised a `NoMethodError` if its value did not contain the parameter name, or contained it more than once. Such values are parsed like their `explode` counterpart now. +- Fixed: A parameter with `style: matrix`, or a path parameter that describes an object, raised an `ArgumentError` if its value had an invalid `%`-encoding. Such values are validated against the schema now. - Changed: Don't hide covered endpoints in HTML coverage reporter - Added: Filter un/covered endpoints in HTML coverage reporter - Changed: Reduced memory retained by a loaded `Definition`. Response headers with a schema no longer keep the whole raw document node alive, and a couple of build-time-only hashes were replaced with more compact structures. diff --git a/lib/openapi_first/parameters/content_parsers.rb b/lib/openapi_first/parameters/content_parsers.rb index d1ba0ec0..4e760bf2 100644 --- a/lib/openapi_first/parameters/content_parsers.rb +++ b/lib/openapi_first/parameters/content_parsers.rb @@ -46,6 +46,8 @@ def match?(matcher, media_type) end register(%r{\A[\w.+-]+/(?:[\w.-]+\+)?json\z}i, lambda do |value| + throw :skip, value unless value.is_a?(String) + JSON.parse(value) rescue JSON::ParserError throw :skip, value diff --git a/lib/openapi_first/parameters/unpackers.rb b/lib/openapi_first/parameters/unpackers.rb index 67ca8b3e..09046ae2 100644 --- a/lib/openapi_first/parameters/unpackers.rb +++ b/lib/openapi_first/parameters/unpackers.rb @@ -43,12 +43,17 @@ def call(value) return value if value.is_a?(::Array) return value if value.empty? - result = Rack::Utils.parse_query(value, ';')[name] - explode ? result : result.split(',') + result = Unpackers.parse_query(value, ';')[name] + return result if explode + return result unless result.is_a?(::String) + + result.split(',') end end ExplodeFormObject = lambda do |value| + throw :skip, value unless value.is_a?(::String) + entries = value.split(OBJECT_EXPLODE_SPLITTER) throw :skip, value if entries.length.odd? @@ -57,6 +62,8 @@ def call(value) DelimitedObject = Data.define(:delimiter) do def call(value) + throw :skip, value unless value.is_a?(::String) + entries = value.split(delimiter) throw :skip, value if entries.length.odd? @@ -64,7 +71,7 @@ def call(value) end end - ExplodePathObject = ->(value) { Rack::Utils.parse_query(value, ',') } + ExplodePathObject = ->(value) { Unpackers.parse_query(value, ',') } NonExplodePathObject = Data.define(:array_unpacker) do def call(value) @@ -76,6 +83,13 @@ def call(value) end class << self + # Values that are not encoded as described are left to schema validation + def parse_query(value, delimiter) + Rack::Utils.parse_query(value, delimiter) + rescue ArgumentError + throw :skip, value + end + def find(parameter) return find_media_type(parameter) if parameter.media_type return find_array(parameter) if parameter.array? diff --git a/spec/definition_spec.rb b/spec/definition_spec.rb index 75edea0a..1743e42a 100644 --- a/spec/definition_spec.rb +++ b/spec/definition_spec.rb @@ -357,6 +357,60 @@ def parse_quietly(document) end end + context 'with repeated values for a parameter that cannot be repeated' do + let(:definition) do + OpenapiFirst.parse({ + 'openapi' => '3.1.0', + 'paths' => { + '/search' => { + 'get' => { + 'parameters' => [ + { 'name' => 'filter', 'in' => 'query', 'schema' => { 'type' => 'object' } }, + { 'name' => 'sort', 'in' => 'query', 'explode' => false, + 'schema' => { 'type' => 'object' } }, + { 'name' => 'json', 'in' => 'query', + 'content' => { 'application/json' => { 'schema' => { 'type' => 'object' } } } } + ] + } + } + } + }) + end + + it 'returns an invalid request' do + %w[filter sort json].each do |name| + validated = definition.validate_request(build_request("/search?#{name}=a&#{name}=b")) + expect(validated).not_to be_valid, "expected #{name} to be invalid" + expect(validated.error.type).to eq(:invalid_query) + end + end + end + + context 'with a path parameter value that has an invalid encoding' do + let(:definition) do + OpenapiFirst.parse({ + 'openapi' => '3.1.0', + 'paths' => { + '/things/{color}' => { + 'get' => { + 'parameters' => [ + { 'name' => 'color', 'in' => 'path', 'style' => 'matrix', 'required' => true, + 'schema' => { 'type' => 'array', 'items' => { 'type' => 'string' } } } + ] + } + } + } + }) + end + + it 'returns an invalid request' do + env = Rack::MockRequest.env_for('/').merge('PATH_INFO' => '/things/;color=%E0%A4%A') + validated = definition.validate_request(Rack::Request.new(env)) + expect(validated).not_to be_valid + expect(validated.error.type).to eq(:invalid_path) + end + end + context 'with a parameter without schema' do let(:definition) do OpenapiFirst.parse({ diff --git a/spec/parameters/path_parameters_spec.rb b/spec/parameters/path_parameters_spec.rb index 7067e1dd..58dcef1f 100644 --- a/spec/parameters/path_parameters_spec.rb +++ b/spec/parameters/path_parameters_spec.rb @@ -124,6 +124,29 @@ def unpack(definitions, path_params) } expect(unpack(parameter, { 'color' => '' })).to eq('color' => []) end + + it 'returns an empty array if a matrix style value does not contain the parameter name' do + parameter = { + 'in' => 'path', 'name' => 'color', 'explode' => false, 'style' => 'matrix', + 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, { 'color' => 'blue' })).to eq('color' => []) + end + + it 'returns the values if a matrix style value contains the parameter name multiple times' do + parameter = { + 'in' => 'path', 'name' => 'color', 'explode' => false, 'style' => 'matrix', + 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, { 'color' => ';color=a;color=b' })).to eq('color' => %w[a b]) + end + + it 'returns the value as is if a matrix style value has an invalid encoding' do + parameter = { + 'in' => 'path', 'name' => 'color', 'style' => 'matrix', 'schema' => { 'type' => 'array' } + } + expect(unpack(parameter, { 'color' => ';color=%E0%A4%A' })).to eq('color' => ';color=%E0%A4%A') + end end describe 'Object explode true' do @@ -162,6 +185,14 @@ def unpack(definitions, path_params) 'color' => { 'B' => '150', 'G200' => nil, 'R' => '100' } ) end + + it 'returns the value as is if the value has an invalid encoding' do + parameter = { + 'in' => 'path', 'name' => 'color', 'explode' => true, 'style' => 'simple', + 'schema' => { 'type' => 'object' } + } + expect(unpack(parameter, { 'color' => 'R=%E0%A4%A' })).to eq('color' => 'R=%E0%A4%A') + end end describe 'Object explode false' do diff --git a/spec/parameters/query-parameter-tests.yaml b/spec/parameters/query-parameter-tests.yaml index b540de83..aa7c260e 100644 --- a/spec/parameters/query-parameter-tests.yaml +++ b/spec/parameters/query-parameter-tests.yaml @@ -824,3 +824,36 @@ query_string: color=blue&color=black unpacked_value: color: [blue, black] + +- description: Returns the raw values if an object parameter has repeated values + parameter: + in: query + name: filter + schema: + type: object + query_string: filter=a&filter=b + unpacked_value: + filter: [a, b] + +- description: Returns the raw values if a non-exploded object parameter has repeated values + parameter: + in: query + name: filter + explode: false + schema: + type: object + query_string: filter=a&filter=b + unpacked_value: + filter: [a, b] + +- description: Returns the raw values if a content parameter has repeated values + parameter: + in: query + name: filter + content: + application/json: + schema: + type: object + query_string: filter=a&filter=b + unpacked_value: + filter: [a, b]