Skip to content

Commit 8c02829

Browse files
authored
Merge branch 'main' into spec-first-arg
2 parents 3da4507 + b57d5cd commit 8c02829

33 files changed

Lines changed: 508 additions & 279 deletions

.rubocop_todo.yml

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

9-
# Offense count: 9
9+
# Offense count: 12
1010
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
1111
Metrics/AbcSize:
12-
Max: 28
12+
Max: 32
1313

14-
# Offense count: 1
14+
# Offense count: 4
1515
# Configuration parameters: AllowedMethods, AllowedPatterns.
1616
Metrics/CyclomaticComplexity:
17-
Max: 9
17+
Max: 14
18+
19+
# Offense count: 1
20+
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns.
21+
Metrics/MethodLength:
22+
Exclude:
23+
- 'lib/openapi_first/schema/validation_error.rb'
1824

19-
# Offense count: 3
25+
# Offense count: 2
2026
# Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
2127
Metrics/ParameterLists:
2228
Max: 8
@@ -25,10 +31,3 @@ Metrics/ParameterLists:
2531
# Configuration parameters: AllowedMethods, AllowedPatterns.
2632
Metrics/PerceivedComplexity:
2733
Max: 9
28-
29-
# Offense count: 1
30-
# This cop supports safe autocorrection (--autocorrect).
31-
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings.
32-
# URISchemes: http, https
33-
Layout/LineLength:
34-
Max: 121

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# Changelog
22

33
## Unreleased
4+
45
- Middlewares now accept the OAD as a first positional argument instead of `:spec` inside the options hash.
6+
- 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
57
- `OpenapiFirst::Test::Methods[MyApplication]` returns a Module which adds an `app` method to be used by rack-test alonside the `assert_api_conform` method.
68
- Make default coverage report less verbose
79
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

lib/openapi_first/builder.rb

Lines changed: 60 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
# frozen_string_literal: true
22

33
require 'json_schemer'
4+
5+
require_relative 'failure'
6+
require_relative 'router'
7+
require_relative 'header'
8+
require_relative 'request'
9+
require_relative 'response'
10+
require_relative 'schema/hash'
411
require_relative 'ref_resolver'
512

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

1926
def initialize(contents, filepath:, config:)
20-
@schemer_configuration = JSONSchemer.configuration.clone
21-
@schemer_configuration.meta_schema = detect_meta_schema(contents, filepath)
22-
@schemer_configuration.insert_property_defaults = true
23-
27+
meta_schema = detect_meta_schema(contents, filepath)
28+
@schemer_configuration = build_schemer_config(filepath:, meta_schema:)
2429
@config = config
2530
@contents = RefResolver.for(contents, filepath:)
2631
end
2732

2833
attr_reader :config
2934
private attr_reader :schemer_configuration
3035

36+
def build_schemer_config(filepath:, meta_schema:)
37+
result = JSONSchemer.configuration.clone
38+
dir = (filepath && File.absolute_path(File.dirname(filepath))) || Dir.pwd
39+
result.base_uri = URI::File.build({ path: "#{dir}/" })
40+
result.ref_resolver = JSONSchemer::CachedResolver.new do |uri|
41+
FileLoader.load(uri.path)
42+
end
43+
result.meta_schema = meta_schema
44+
result.insert_property_defaults = true
45+
result
46+
end
47+
3148
def detect_meta_schema(document, filepath)
3249
# Copied from JSONSchemer 🙇🏻‍♂️
3350
version = document['openapi']
@@ -46,10 +63,10 @@ def detect_meta_schema(document, filepath)
4663
def router # rubocop:disable Metrics/MethodLength
4764
router = OpenapiFirst::Router.new
4865
@contents.fetch('paths').each do |path, path_item_object|
49-
path_parameters = resolve_parameters(path_item_object['parameters'])
66+
path_parameters = path_item_object['parameters'] || []
5067
path_item_object.resolved.keys.intersection(REQUEST_METHODS).map do |request_method|
5168
operation_object = path_item_object[request_method]
52-
operation_parameters = resolve_parameters(operation_object['parameters'])
69+
operation_parameters = operation_object['parameters'] || []
5370
parameters = parse_parameters(operation_parameters.chain(path_parameters))
5471

5572
build_requests(path:, request_method:, operation_object:,
@@ -79,10 +96,10 @@ def router # rubocop:disable Metrics/MethodLength
7996
def parse_parameters(parameters)
8097
grouped_parameters = group_parameters(parameters)
8198
ParsedParameters.new(
82-
query: grouped_parameters[:query],
83-
path: grouped_parameters[:path],
84-
cookie: grouped_parameters[:cookie],
85-
header: grouped_parameters[:header],
99+
query: resolve_parameters(grouped_parameters[:query]),
100+
path: resolve_parameters(grouped_parameters[:path]),
101+
cookie: resolve_parameters(grouped_parameters[:cookie]),
102+
header: resolve_parameters(grouped_parameters[:header]),
86103
query_schema: build_parameter_schema(grouped_parameters[:query]),
87104
path_schema: build_parameter_schema(grouped_parameters[:path]),
88105
cookie_schema: build_parameter_schema(grouped_parameters[:cookie]),
@@ -99,11 +116,18 @@ def resolve_parameters(parameters)
99116
end
100117

101118
def build_parameter_schema(parameters)
102-
schema = build_parameters_schema(parameters)
119+
return unless parameters
103120

104-
JSONSchemer.schema(schema,
105-
configuration: schemer_configuration,
106-
after_property_validation: config.hooks[:after_request_parameter_property_validation])
121+
required = []
122+
schemas = parameters.each_with_object({}) do |parameter, result|
123+
schema = parameter['schema'].schema(configuration: schemer_configuration)
124+
name = parameter['name']&.value
125+
required << name if parameter['required']&.value
126+
result[name] = schema if schema
127+
end
128+
129+
Schema::Hash.new(schemas, required:, configuration: schemer_configuration,
130+
after_property_validation: config.hooks[:after_request_parameter_property_validation])
107131
end
108132

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

143167
responses.flat_map do |status, response_object|
144-
headers = response_object['headers']&.resolved
145-
headers_schema = JSONSchemer::Schema.new(
146-
build_headers_schema(headers),
147-
configuration: schemer_configuration
148-
)
168+
headers = build_response_headers(response_object['headers'])
149169
response_object['content']&.map do |content_type, content_object|
150170
content_schema = content_object['schema'].schema(configuration: schemer_configuration)
151171
Response.new(status:,
152172
headers:,
153-
headers_schema:,
154173
content_type:,
155174
content_schema:,
156175
key: [request.key, status, content_type].join(':'))
157-
end || Response.new(status:, headers:, headers_schema:, content_type: nil,
176+
end || Response.new(status:, headers:, content_type: nil,
158177
content_schema: nil, key: [request.key, status, nil].join(':'))
159178
end
160179
end
161180

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

165-
def group_parameters(parameter_definitions)
166-
result = {}
167-
parameter_definitions&.each do |parameter|
168-
(result[parameter['in'].to_sym] ||= []) << parameter
169-
end
170-
result[:header]&.reject! { IGNORED_HEADER_PARAMETERS.include?(_1['name']) }
171-
result
172-
end
173-
174-
def build_headers_schema(headers_object)
175-
return unless headers_object&.any?
184+
def build_response_headers(headers_object)
185+
return if headers_object.nil?
176186

177-
properties = {}
178-
required = []
187+
result = []
179188
headers_object.each do |name, header|
180-
schema = header['schema']
181-
next if name.casecmp('content-type').zero?
182-
183-
properties[name] = schema if schema
184-
required << name if header['required']
189+
next if header['schema'].nil?
190+
next if IGNORED_HEADER_PARAMETERS.include?(name)
191+
192+
header = Header.new(
193+
name:,
194+
schema: header['schema'].schema(configuration: schemer_configuration),
195+
required?: header['required']&.value == true,
196+
node: header
197+
)
198+
result << header
185199
end
186-
{
187-
'properties' => properties,
188-
'required' => required
189-
}
200+
result
190201
end
191202

192-
def build_parameters_schema(parameters)
193-
return unless parameters
194-
195-
properties = {}
196-
required = []
197-
parameters.each do |parameter|
198-
schema = parameter['schema']
199-
name = parameter['name']
200-
properties[name] = schema if schema
201-
required << name if parameter['required']
203+
def group_parameters(parameter_definitions)
204+
result = {}
205+
parameter_definitions&.each do |parameter|
206+
(result[parameter['in']&.value&.to_sym] ||= []) << parameter
202207
end
203-
204-
{
205-
'properties' => properties,
206-
'required' => required
207-
}
208+
result[:header]&.reject! { IGNORED_HEADER_PARAMETERS.include?(_1['name']&.value) }
209+
result
208210
end
209211

210212
ParsedParameters = Data.define(:path, :query, :header, :cookie, :path_schema, :query_schema, :header_schema,

lib/openapi_first/definition.rb

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
# frozen_string_literal: true
22

3-
require_relative 'failure'
4-
require_relative 'router'
5-
require_relative 'request'
6-
require_relative 'response'
73
require_relative 'builder'
84
require 'forwardable'
95

lib/openapi_first/header.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
module OpenapiFirst
4+
Header = Data.define(:name, :required?, :schema, :node) do
5+
def resolved_schema
6+
node['schema']&.resolved
7+
end
8+
end
9+
end

lib/openapi_first/middlewares/response_validation.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ def initialize(app, spec = nil, options = {})
2929

3030
def call(env)
3131
status, headers, body = @app.call(env)
32-
@definition.validate_response(Rack::Request.new(env), Rack::Response[status, headers, body], raise_error: @raise)
32+
@definition.validate_response(Rack::Request.new(env), Rack::Response[status, headers, body],
33+
raise_error: @raise)
3334
[status, headers, body]
3435
end
3536
end

lib/openapi_first/ref_resolver.rb

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,11 @@ def initialize(value, context: value, filepath: nil)
4141
@value = value
4242
@context = context
4343
@filepath = filepath
44-
dir = File.dirname(File.expand_path(filepath)) if filepath
45-
@dir = (dir && File.absolute_path(dir)) || Dir.pwd
44+
@dir = if filepath
45+
File.dirname(File.absolute_path(filepath))
46+
else
47+
Dir.pwd
48+
end
4649
end
4750

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

55-
private attr_reader :filepath
58+
attr_reader :filepath
59+
60+
def ==(_other)
61+
raise "Don't call == on an unresolved value. Use .value == other instead."
62+
end
5663

5764
def resolve_ref(pointer)
5865
if pointer.start_with?('#')
@@ -89,6 +96,10 @@ class Hash
8996
include Diggable
9097
include Enumerable
9198

99+
def ==(_other)
100+
raise "Don't call == on an unresolved value. Use .value == other instead."
101+
end
102+
92103
def resolved
93104
return resolve_ref(value['$ref']).value if value.key?('$ref')
94105

@@ -108,17 +119,16 @@ def fetch(key)
108119
end
109120

110121
def each
111-
resolved.each do |key, value|
112-
yield key, RefResolver.for(value, filepath:, context:)
122+
resolved.each_key do |key|
123+
yield key, self[key]
113124
end
114125
end
115126

116-
def schema(options = {})
117-
ref_resolver = JSONSchemer::CachedResolver.new do |uri|
118-
FileLoader.load(uri.path)
119-
end
127+
# You have to pass configuration or ref_resolver
128+
def schema(options)
120129
base_uri = URI::File.build({ path: "#{dir}/" })
121-
root = JSONSchemer::Schema.new(context, base_uri:, ref_resolver:, **options)
130+
root = JSONSchemer::Schema.new(context, base_uri:, **options)
131+
# binding.irb if value['maxItems'] == 4
122132
JSONSchemer::Schema.new(value, nil, root, base_uri:, **options)
123133
end
124134
end
@@ -137,8 +147,8 @@ def [](index)
137147
end
138148

139149
def each
140-
resolved.each do |item|
141-
yield RefResolver.for(item, filepath:, context:)
150+
resolved.each_with_index do |_item, index|
151+
yield self[index]
142152
end
143153
end
144154

lib/openapi_first/response.rb

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,20 @@ module OpenapiFirst
99
# This is not a direct reflecton of the OpenAPI 3.X response definition, but a combination of
1010
# status, content type and content schema.
1111
class Response
12-
def initialize(status:, headers:, headers_schema:, content_type:, content_schema:, key:)
12+
def initialize(status:, headers:, content_type:, content_schema:, key:)
1313
@status = status
1414
@content_type = content_type
1515
@content_schema = content_schema
1616
@headers = headers
17-
@headers_schema = headers_schema
1817
@key = key
1918
@parser = ResponseParser.new(headers:, content_type:)
20-
@validator = ResponseValidator.new(self)
19+
@validator = ResponseValidator.new(content_schema:, headers:)
2120
end
2221

2322
# @attr_reader [Integer] status The HTTP status code of the response definition.
2423
# @attr_reader [String, nil] content_type Content type of this response.
2524
# @attr_reader [Schema, nil] content_schema the Schema of the response body.
26-
attr_reader :status, :content_type, :content_schema, :headers, :headers_schema, :key
25+
attr_reader :status, :content_type, :content_schema, :headers, :key
2726

2827
def validate(response)
2928
parsed_values = nil

0 commit comments

Comments
 (0)