Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
- 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`.
- 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`.
- 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.
Expand Down
3 changes: 0 additions & 3 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
1 change: 0 additions & 1 deletion benchmarks/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 0 additions & 4 deletions benchmarks/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -76,7 +73,6 @@ DEPENDENCIES
benchmark-memory
committee
openapi_first!
openapi_parameters
profile-viewer
puma
sinatra
Expand Down
3 changes: 0 additions & 3 deletions examples/rails_app/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
47 changes: 28 additions & 19 deletions lib/openapi_first/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -116,34 +117,42 @@ 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: resolve_parameters(grouped_parameters[:query]),
path: resolve_parameters(grouped_parameters[:path]),
cookie: resolve_parameters(grouped_parameters[:cookie]),
header: resolve_parameters(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 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_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)
return unless 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
Expand Down Expand Up @@ -238,8 +247,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(:all, :path_parser, :query_parser, :header_parser, :cookie_parser,
:path_schema, :query_schema, :header_schema, :cookie_schema)
private_constant :ParsedParameters
end
end
14 changes: 14 additions & 0 deletions lib/openapi_first/parameters.rb
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions lib/openapi_first/parameters/array_converter.rb
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions lib/openapi_first/parameters/content_parsers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 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|
throw :skip, value unless value.is_a?(String)

JSON.parse(value)
rescue JSON::ParserError
throw :skip, value
end)
end
end
end
69 changes: 69 additions & 0 deletions lib/openapi_first/parameters/converter.rb
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions lib/openapi_first/parameters/headers_hash.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading