Skip to content
25 changes: 12 additions & 13 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2025-03-23 14:23:53 UTC using RuboCop version 1.74.0.
# on 2025-04-02 15:12:27 UTC using RuboCop version 1.74.0.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.

# Offense count: 9
# Offense count: 12
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
Metrics/AbcSize:
Max: 28
Max: 32

# Offense count: 1
# Offense count: 4
# Configuration parameters: AllowedMethods, AllowedPatterns.
Metrics/CyclomaticComplexity:
Max: 9
Max: 14

# Offense count: 1
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/MethodLength:
Exclude:
- 'lib/openapi_first/schema/validation_error.rb'

# Offense count: 3
# Offense count: 2
# Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
Metrics/ParameterLists:
Max: 8
Expand All @@ -25,10 +31,3 @@ Metrics/ParameterLists:
# Configuration parameters: AllowedMethods, AllowedPatterns.
Metrics/PerceivedComplexity:
Max: 9

# Offense count: 1
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings.
# URISchemes: http, https
Layout/LineLength:
Max: 121
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Changelog

## Unreleased
- No longer merge parameter schemas per of the same location (for example "query") in order to fix https://github.com/ahx/openapi_first/issues/320

- `OpenapiFirst::Test::Methods[MyApplication]` returns a Module which adds an `app` method to be used by rack-test alonside the `assert_api_conform` method.
- Make default coverage report less verbose
The default formatter (TerminalFormatter) no longer prints all un-requested requests by default. You can set `test.coverage_formatter_options = { focused: false }` to get back the old behavior
Expand Down
118 changes: 60 additions & 58 deletions lib/openapi_first/builder.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# frozen_string_literal: true

require 'json_schemer'

require_relative 'failure'
require_relative 'router'
require_relative 'header'
require_relative 'request'
require_relative 'response'
require_relative 'schema/hash'
require_relative 'ref_resolver'

module OpenapiFirst
Expand All @@ -17,17 +24,27 @@ def self.build_router(contents, filepath:, config:)
end

def initialize(contents, filepath:, config:)
@schemer_configuration = JSONSchemer.configuration.clone
@schemer_configuration.meta_schema = detect_meta_schema(contents, filepath)
@schemer_configuration.insert_property_defaults = true

meta_schema = detect_meta_schema(contents, filepath)
@schemer_configuration = build_schemer_config(filepath:, meta_schema:)
@config = config
@contents = RefResolver.for(contents, filepath:)
end

attr_reader :config
private attr_reader :schemer_configuration

def build_schemer_config(filepath:, meta_schema:)
result = JSONSchemer.configuration.clone
dir = (filepath && File.absolute_path(File.dirname(filepath))) || Dir.pwd
result.base_uri = URI::File.build({ path: "#{dir}/" })
result.ref_resolver = JSONSchemer::CachedResolver.new do |uri|
FileLoader.load(uri.path)
end
result.meta_schema = meta_schema
result.insert_property_defaults = true
result
end

def detect_meta_schema(document, filepath)
# Copied from JSONSchemer 🙇🏻‍♂️
version = document['openapi']
Expand All @@ -46,10 +63,10 @@ def detect_meta_schema(document, filepath)
def router # rubocop:disable Metrics/MethodLength
router = OpenapiFirst::Router.new
@contents.fetch('paths').each do |path, path_item_object|
path_parameters = resolve_parameters(path_item_object['parameters'])
path_parameters = path_item_object['parameters'] || []
path_item_object.resolved.keys.intersection(REQUEST_METHODS).map do |request_method|
operation_object = path_item_object[request_method]
operation_parameters = resolve_parameters(operation_object['parameters'])
operation_parameters = operation_object['parameters'] || []
parameters = parse_parameters(operation_parameters.chain(path_parameters))

build_requests(path:, request_method:, operation_object:,
Expand Down Expand Up @@ -79,10 +96,10 @@ def router # rubocop:disable Metrics/MethodLength
def parse_parameters(parameters)
grouped_parameters = group_parameters(parameters)
ParsedParameters.new(
query: grouped_parameters[:query],
path: grouped_parameters[:path],
cookie: grouped_parameters[:cookie],
header: grouped_parameters[:header],
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]),
Expand All @@ -99,11 +116,18 @@ def resolve_parameters(parameters)
end

def build_parameter_schema(parameters)
schema = build_parameters_schema(parameters)
return unless parameters

JSONSchemer.schema(schema,
configuration: schemer_configuration,
after_property_validation: config.hooks[:after_request_parameter_property_validation])
required = []
schemas = parameters.each_with_object({}) do |parameter, result|
schema = parameter['schema'].schema(configuration: schemer_configuration)
name = parameter['name']&.value
required << name if parameter['required']&.value
result[name] = schema if schema
end

Schema::Hash.new(schemas, required:, configuration: schemer_configuration,
after_property_validation: config.hooks[:after_request_parameter_property_validation])
end

def build_requests(path:, request_method:, operation_object:, parameters:)
Expand Down Expand Up @@ -141,70 +165,48 @@ def build_responses(responses:, request:)
return [] unless responses

responses.flat_map do |status, response_object|
headers = response_object['headers']&.resolved
headers_schema = JSONSchemer::Schema.new(
build_headers_schema(headers),
configuration: schemer_configuration
)
headers = build_response_headers(response_object['headers'])
response_object['content']&.map do |content_type, content_object|
content_schema = content_object['schema'].schema(configuration: schemer_configuration)
Response.new(status:,
headers:,
headers_schema:,
content_type:,
content_schema:,
key: [request.key, status, content_type].join(':'))
end || Response.new(status:, headers:, headers_schema:, content_type: nil,
end || Response.new(status:, headers:, content_type: nil,
content_schema: nil, key: [request.key, status, nil].join(':'))
end
end

IGNORED_HEADER_PARAMETERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
private_constant :IGNORED_HEADER_PARAMETERS

def group_parameters(parameter_definitions)
result = {}
parameter_definitions&.each do |parameter|
(result[parameter['in'].to_sym] ||= []) << parameter
end
result[:header]&.reject! { IGNORED_HEADER_PARAMETERS.include?(_1['name']) }
result
end

def build_headers_schema(headers_object)
return unless headers_object&.any?
def build_response_headers(headers_object)
return if headers_object.nil?

properties = {}
required = []
result = []
headers_object.each do |name, header|
schema = header['schema']
next if name.casecmp('content-type').zero?

properties[name] = schema if schema
required << name if header['required']
next if header['schema'].nil?
next if IGNORED_HEADER_PARAMETERS.include?(name)

header = Header.new(
name:,
schema: header['schema'].schema(configuration: schemer_configuration),
required?: header['required']&.value == true,
node: header
)
result << header
end
{
'properties' => properties,
'required' => required
}
result
end

def build_parameters_schema(parameters)
return unless parameters

properties = {}
required = []
parameters.each do |parameter|
schema = parameter['schema']
name = parameter['name']
properties[name] = schema if schema
required << name if parameter['required']
def group_parameters(parameter_definitions)
result = {}
parameter_definitions&.each do |parameter|
(result[parameter['in']&.value&.to_sym] ||= []) << parameter
end

{
'properties' => properties,
'required' => required
}
result[:header]&.reject! { IGNORED_HEADER_PARAMETERS.include?(_1['name']&.value) }
result
end

ParsedParameters = Data.define(:path, :query, :header, :cookie, :path_schema, :query_schema, :header_schema,
Expand Down
4 changes: 0 additions & 4 deletions lib/openapi_first/definition.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
# frozen_string_literal: true

require_relative 'failure'
require_relative 'router'
require_relative 'request'
require_relative 'response'
require_relative 'builder'
require 'forwardable'

Expand Down
9 changes: 9 additions & 0 deletions lib/openapi_first/header.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

module OpenapiFirst
Header = Data.define(:name, :required?, :schema, :node) do
def resolved_schema
node['schema']&.resolved
end
end
end
3 changes: 2 additions & 1 deletion lib/openapi_first/middlewares/response_validation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ def initialize(app, options = {})

def call(env)
status, headers, body = @app.call(env)
@definition.validate_response(Rack::Request.new(env), Rack::Response[status, headers, body], raise_error: @raise)
@definition.validate_response(Rack::Request.new(env), Rack::Response[status, headers, body],
raise_error: @raise)
[status, headers, body]
end
end
Expand Down
34 changes: 22 additions & 12 deletions lib/openapi_first/ref_resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ def initialize(value, context: value, filepath: nil)
@value = value
@context = context
@filepath = filepath
dir = File.dirname(File.expand_path(filepath)) if filepath
@dir = (dir && File.absolute_path(dir)) || Dir.pwd
@dir = if filepath
File.dirname(File.absolute_path(filepath))
else
Dir.pwd
end
end

# The value of this node
Expand All @@ -52,7 +55,11 @@ def initialize(value, context: value, filepath: nil)
# The object where this node was found in
attr_reader :context

private attr_reader :filepath
attr_reader :filepath

def ==(_other)
raise "Don't call == on an unresolved value. Use .value == other instead."
end

def resolve_ref(pointer)
if pointer.start_with?('#')
Expand Down Expand Up @@ -89,6 +96,10 @@ class Hash
include Diggable
include Enumerable

def ==(_other)
raise "Don't call == on an unresolved value. Use .value == other instead."
end

def resolved
return resolve_ref(value['$ref']).value if value.key?('$ref')

Expand All @@ -108,17 +119,16 @@ def fetch(key)
end

def each
resolved.each do |key, value|
yield key, RefResolver.for(value, filepath:, context:)
resolved.each_key do |key|
yield key, self[key]
end
end

def schema(options = {})
ref_resolver = JSONSchemer::CachedResolver.new do |uri|
FileLoader.load(uri.path)
end
# You have to pass configuration or ref_resolver
def schema(options)
base_uri = URI::File.build({ path: "#{dir}/" })
root = JSONSchemer::Schema.new(context, base_uri:, ref_resolver:, **options)
root = JSONSchemer::Schema.new(context, base_uri:, **options)
# binding.irb if value['maxItems'] == 4
JSONSchemer::Schema.new(value, nil, root, base_uri:, **options)
end
end
Expand All @@ -137,8 +147,8 @@ def [](index)
end

def each
resolved.each do |item|
yield RefResolver.for(item, filepath:, context:)
resolved.each_with_index do |_item, index|
yield self[index]
end
end

Expand Down
7 changes: 3 additions & 4 deletions lib/openapi_first/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,20 @@ module OpenapiFirst
# This is not a direct reflecton of the OpenAPI 3.X response definition, but a combination of
# status, content type and content schema.
class Response
def initialize(status:, headers:, headers_schema:, content_type:, content_schema:, key:)
def initialize(status:, headers:, content_type:, content_schema:, key:)
@status = status
@content_type = content_type
@content_schema = content_schema
@headers = headers
@headers_schema = headers_schema
@key = key
@parser = ResponseParser.new(headers:, content_type:)
@validator = ResponseValidator.new(self)
@validator = ResponseValidator.new(content_schema:, headers:)
end

# @attr_reader [Integer] status The HTTP status code of the response definition.
# @attr_reader [String, nil] content_type Content type of this response.
# @attr_reader [Schema, nil] content_schema the Schema of the response body.
attr_reader :status, :content_type, :content_schema, :headers, :headers_schema, :key
attr_reader :status, :content_type, :content_schema, :headers, :key

def validate(response)
parsed_values = nil
Expand Down
Loading