openapi_first 4.2.1 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20ff22852f152edfbbf62ad27d2672d286be8e07d9ec0d3a6a4a6b8d2608ea23
4
- data.tar.gz: 4d956b76a491067d3c2a7d2d0716fbad834d62f6e7d643e9ac51c92938b6416b
3
+ metadata.gz: ef15e925e193150f3425c32bd94986f3c069f12af86a88ad043886de13c1ca8a
4
+ data.tar.gz: d4f11e849a469be720cbd5973ffb04e039664101ed44484d4b4f4b2c5ef542f8
5
5
  SHA512:
6
- metadata.gz: 974432feb3f2b69cf7f1d613980d54bb2cbf25ae39ff09c2afc3f9aa0a8d4246bbd17c555ef7814533dcaee9ea9eee6d9aa9856a23b5869a2dcc61f8b5c06c32
7
- data.tar.gz: 907638cdecc5ba7f0504a8a41555359db2a5dd924644495848836fc39e791624283050bdd7923afcd4eedf08a26fa5bb13b8ff06d91971033a4bb3a9507d3b53
6
+ metadata.gz: 8aa700d2587944d67f5e2372fe7793c1cd71a76e6c772bf56938564e4f4c4221cb29e54e0ec37bec66c1da370415aabbebb7f722f4cd4fbeba478919308df6cb
7
+ data.tar.gz: 6ef3870f3b011ac35065775da7265d6bc6dff0b121ec0ee42a7e5b47a9fd69d17dbc25ea85723615206d9704afcaee4f65ff3f04e6c85f430ea4781316260ff8
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 4.4.0
6
+
7
+ - Changed: Percent-encoded path parameters are now decoded. For example `/files/my%20file` yields `my file` for `/files/{name}`. A `+` stays a `+`. Values with a malformed encoding or that do not decode to valid UTF-8 are kept as they are. If your application decodes path parameters itself, remove that, or values get decoded twice. Known limitation: An encoded delimiter inside an array or object path parameter is decoded before the value is split, so `/files/a%2Cb,c` yields `["a", "b", "c"]`.
8
+ - Added: You can pass a block to `OpenapiFirst::Middlewares::RequestValidation` to configure hooks and other options specifically for that middleware. With a registered name or a Definition it creates a copy of that OAD, so the registered OAD stays unchanged.
9
+ - Fix: `OpenapiFirst.load` now accepts and applies a configuration block.
10
+
11
+ ## 4.3.0
12
+
13
+ - Fix: Passing a block to `config.register` not works when called inside `OpenapiFirst.configure` block. Before the block was ignored.
14
+ - Faster routing for OADs with many dynamic paths. The router now looks up path templates in a prefix tree of path segments instead of trying each template one by one.
15
+ - Changed: If more than one path template matches a request path, the router now compares the templates segment by segment from left to right. For example `/users/{id}/{action}` wins over `/{tenant}/settings/profile` for `/users/settings/profile`. Before, the template with more literal characters overall won.
16
+
5
17
  ## 4.2.1
6
18
 
7
19
  - Fix deprecation warnings when using multi_json >= 1.21. openapi_first now uses `MultiJSON.parse`/`MultiJSON.generate` if available ([#508](https://github.com/ahx/openapi_first/issues/508)).
data/README.md CHANGED
@@ -84,6 +84,19 @@ The request validation middleware returns a 4xx if the request is invalid or not
84
84
  use OpenapiFirst::Middlewares::RequestValidation
85
85
  ```
86
86
 
87
+ You can pass a block to configure [hooks](#hooks) and other options for this middleware only:
88
+
89
+ ```ruby
90
+ use OpenapiFirst::Middlewares::RequestValidation, :v1 do |config|
91
+ config.request_validation_error_response = :jsonapi
92
+ config.after_request_validation do |validated_request|
93
+ warn validated_request.error.message if validated_request.invalid?
94
+ end
95
+ end
96
+ ```
97
+
98
+ If you pass a registered name or a Definition together with a block, the middleware uses a copy of that OAD that keeps its configuration and adds yours. The registered OAD stays unchanged.
99
+
87
100
  #### Error responses
88
101
 
89
102
  openapi_first produces a useful machine readable error response that can be customized.
@@ -6,16 +6,20 @@ module OpenapiFirst
6
6
  def initialize(parent:)
7
7
  super()
8
8
  @parent = parent
9
- @request_validation_error_response = parent.request_validation_error_response
9
+ @request_validation_error_response = nil
10
10
  @path = parent.path
11
11
  @regexp_resolver = parent.regexp_resolver
12
12
  end
13
13
 
14
14
  private attr_reader :parent
15
15
 
16
+ def request_validation_error_response
17
+ @request_validation_error_response || parent.request_validation_error_response
18
+ end
19
+
16
20
  HOOKS.each do |hook|
17
21
  define_method(hook) do |&block|
18
- return hooks[hook].chain(parent.hooks[hook]) if block.nil?
22
+ return hooks[hook].chain(parent.public_send(hook)) if block.nil?
19
23
 
20
24
  hooks[hook] << block
21
25
  block
@@ -19,8 +19,8 @@ module OpenapiFirst
19
19
  @regexp_resolver = nil
20
20
  end
21
21
 
22
- def register(path_or_definition, as: :default)
23
- OpenapiFirst.register(path_or_definition, as:)
22
+ def register(path_or_definition, as: :default, &)
23
+ OpenapiFirst.register(path_or_definition, as:, &)
24
24
  end
25
25
 
26
26
  attr_reader :hooks, :request_validation_error_response, :regexp_resolver
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'path_template'
3
4
  require_relative 'builder'
4
5
  require 'forwardable'
5
6
 
@@ -23,10 +24,11 @@ module OpenapiFirst
23
24
  # @param contents [Hash] The OpenAPI document.
24
25
  # @param filepath [String] The file path of the OpenAPI document.
25
26
  # @param path_prefix [String,nil] An optional path prefix, that is not documented, that all requests begin with.
26
- def initialize(contents, filepath = nil, path_prefix = nil)
27
+ # @param config [Configuration] The configuration to use. Gets yielded and frozen.
28
+ def initialize(contents, filepath = nil, path_prefix = nil, config: OpenapiFirst.configuration.child)
27
29
  @filepath = filepath
28
30
  @path_prefix = path_prefix
29
- @config = OpenapiFirst.configuration.child
31
+ @config = config
30
32
  yield @config if block_given?
31
33
  @config.freeze
32
34
  @router = Builder.build_router(contents, filepath:, config:)
@@ -34,6 +36,14 @@ module OpenapiFirst
34
36
  @paths = @router.routes.map(&:path).to_a.uniq # TODO: Refactor
35
37
  end
36
38
 
39
+ # Returns a new Definition of the same document whose configuration is a child of this one's.
40
+ # @visibility private
41
+ # @yield [Configuration]
42
+ # @return [Definition]
43
+ def configure(&)
44
+ Definition.new(@resolved, filepath, path_prefix, config: @config.child, &)
45
+ end
46
+
37
47
  # Gives access to the raw resolved Hash. Like `mydefinition['components'].dig('schemas', 'Stations')`
38
48
  # @!method [](key)
39
49
  # @return [Hash]
@@ -80,8 +90,7 @@ module OpenapiFirst
80
90
  request_def = routes.lazy.flat_map(&:requests).find { |r| r.operation_id == operation_id.to_s }
81
91
  raise ArgumentError, "Operation #{operation_id.inspect} is not defined in #{key}." unless request_def
82
92
 
83
- request_def.path.gsub(/\{([^}]+)\}/) do
84
- name = Regexp.last_match(1)
93
+ PathTemplate.expand(request_def.path) do |name|
85
94
  params.fetch(name.to_sym) do
86
95
  params.fetch(name) do
87
96
  raise ArgumentError, "Missing path parameter #{name.inspect} for operation #{operation_id.inspect}."
@@ -29,20 +29,19 @@ module OpenapiFirst
29
29
  # :error_response The Class to use for error responses.
30
30
  # This can be a Symbol-name of an registered error response (:default, :jsonapi)
31
31
  # or it can be set to false to disable returning a response.
32
- # default: OpenapiFirst::Plugins::Default::ErrorResponse (Config.default_options.error_response)
33
- def initialize(app, spec = nil, options = {})
32
+ # default: the configured request_validation_error_response
33
+ # @yield [Configuration] Optional block to configure hooks and other options for this middleware.
34
+ # If spec is a registered name or a Definition, this uses a new Definition that inherits its configuration,
35
+ # so the registered Definition is not changed.
36
+ def initialize(app, spec = nil, options = {}, &)
34
37
  @app = app
35
38
  if spec.is_a?(Hash)
36
39
  options = spec
37
40
  spec = options[:spec]
38
41
  end
39
42
  @raise = options.fetch(:raise_error, false)
43
+ @definition = OpenapiFirst.load(spec || :default, &)
40
44
  @error_response_class = error_response_option(options[:error_response])
41
-
42
- spec ||= :default
43
- spec = OpenapiFirst[spec] if spec.is_a?(Symbol)
44
-
45
- @definition = spec.is_a?(Definition) ? spec : OpenapiFirst.load(spec)
46
45
  end
47
46
 
48
47
  # @attr_reader [Proc] app The upstream Rack application
@@ -70,7 +69,7 @@ module OpenapiFirst
70
69
  return if value == false
71
70
  return OpenapiFirst.find_error_response(value) if value.is_a?(Symbol)
72
71
 
73
- value || OpenapiFirst.configuration.request_validation_error_response
72
+ value || @definition.config.request_validation_error_response
74
73
  end
75
74
  end
76
75
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ # Path templating as described in https://spec.openapis.org/oas/v3.1.0#path-templating
5
+ # @visibility private
6
+ module PathTemplate
7
+ VARIABLE = /\{(?<name>[^{}]+)\}/
8
+ SEGMENT_VARIABLE = /\A#{VARIABLE}\z/
9
+
10
+ # @return [Boolean] whether the path contains at least one variable
11
+ def self.template?(path)
12
+ path.match?(VARIABLE)
13
+ end
14
+
15
+ # @return [Boolean] whether the path segment consists of exactly one variable
16
+ def self.variable?(segment)
17
+ segment.match?(SEGMENT_VARIABLE)
18
+ end
19
+
20
+ # @return [Array<String>] the variable names in order of appearance
21
+ def self.variable_names(template)
22
+ template.scan(VARIABLE).flatten
23
+ end
24
+
25
+ # Replaces each variable with the value the block returns for its name
26
+ def self.expand(template)
27
+ template.gsub(VARIABLE) { yield Regexp.last_match(:name) }
28
+ end
29
+ end
30
+ end
@@ -23,7 +23,6 @@ module OpenapiFirst
23
23
  "#{name}.register(#{path_or_definition.inspect}, as: :my_other_api)"
24
24
  )
25
25
  end
26
-
27
26
  definition = OpenapiFirst.load(path_or_definition, &)
28
27
  definitions[as] = definition
29
28
  definition
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack/utils'
4
+ require_relative '../path_template'
5
+ require_relative 'segment_pattern'
6
+
7
+ module OpenapiFirst
8
+ class Router
9
+ # @visibility private
10
+ class PathTrie
11
+ def initialize
12
+ @static = {}
13
+ @partial = {}
14
+ @variable = nil
15
+ @leaf = nil
16
+ end
17
+
18
+ def add(template, value)
19
+ names = PathTemplate.variable_names(template)
20
+ node = template.split('/', -1).reduce(self) { |parent, segment| parent.child(segment) }
21
+ node.leaf ||= [value, names]
22
+ end
23
+
24
+ def match(path)
25
+ values = []
26
+ value, names = find(path.split('/', -1), 0, values)
27
+ return unless value
28
+
29
+ values.map! { decode(_1) } if path.include?('%')
30
+ [value, names.zip(values).to_h]
31
+ end
32
+
33
+ def find(segments, index, values)
34
+ return @leaf if index == segments.size
35
+
36
+ @static[segments[index]]&.find(segments, index + 1, values) ||
37
+ find_partial(segments, index, values) ||
38
+ find_variable(segments, index, values)
39
+ end
40
+
41
+ protected
42
+
43
+ attr_accessor :leaf
44
+
45
+ def child(segment)
46
+ return @variable ||= PathTrie.new if PathTemplate.variable?(segment)
47
+ return partial_child(segment) if PathTemplate.template?(segment)
48
+
49
+ @static[segment] ||= PathTrie.new
50
+ end
51
+
52
+ private
53
+
54
+ def decode(value)
55
+ decoded = Rack::Utils.unescape_path(value).force_encoding(Encoding::UTF_8)
56
+ decoded.valid_encoding? ? decoded : value
57
+ end
58
+
59
+ def find_partial(segments, index, values)
60
+ @partial.each_value do |pattern, node|
61
+ captures = pattern.match(segments[index])
62
+ next unless captures
63
+
64
+ values.concat(captures)
65
+ found = node.find(segments, index + 1, values)
66
+ return found if found
67
+
68
+ values.pop(captures.size)
69
+ end
70
+ nil
71
+ end
72
+
73
+ def find_variable(segments, index, values)
74
+ segment = segments[index]
75
+ return if @variable.nil? || segment.empty?
76
+
77
+ values << segment
78
+ found = @variable.find(segments, index + 1, values)
79
+ values.pop unless found
80
+ found
81
+ end
82
+
83
+ def partial_child(segment)
84
+ @partial[segment] ||= [SegmentPattern.new(segment), PathTrie.new]
85
+ @partial = @partial.sort_by.with_index { |(key, _), i| [-literal_length(key), i] }.to_h
86
+ @partial[segment].last
87
+ end
88
+
89
+ def literal_length(segment)
90
+ segment.gsub(PathTemplate::VARIABLE, '').length
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../path_template'
4
+
5
+ module OpenapiFirst
6
+ class Router
7
+ # Matches one path segment against a segment of a path template that mixes literals and variables
8
+ # @visibility private
9
+ class SegmentPattern
10
+ def initialize(segment)
11
+ @pattern = build_pattern(segment)
12
+ end
13
+
14
+ # @return [Array<String>, nil] the variable values in order of appearance, or nil
15
+ def match(segment)
16
+ @pattern.match(segment)&.captures
17
+ end
18
+
19
+ private
20
+
21
+ def build_pattern(segment)
22
+ parts = segment.split(PathTemplate::VARIABLE).each_slice(2).map do |literal, name|
23
+ "#{Regexp.escape(literal)}#{'([^/?#]+)' if name}"
24
+ end
25
+ /\A#{parts.join}\z/
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'router/path_template'
3
+ require_relative 'path_template'
4
+ require_relative 'router/path_trie'
4
5
  require_relative 'router/find_content'
5
6
  require_relative 'router/find_response'
6
7
 
@@ -27,15 +28,14 @@ module OpenapiFirst
27
28
 
28
29
  def initialize
29
30
  @static = {}
30
- @dynamic = {} # TODO: use a trie or similar
31
+ @dynamic = {}
32
+ @trie = PathTrie.new
31
33
  end
32
34
 
33
35
  # Returns an enumerator of all routes
34
36
  def routes
35
37
  @static.chain(@dynamic).lazy.flat_map do |path, request_methods|
36
- request_methods.filter_map do |request_method, content|
37
- next if request_method == :template
38
-
38
+ request_methods.map do |request_method, content|
39
39
  Route.new(path:, request_method:, requests: content.requests.each_value.lazy.uniq,
40
40
  responses: content.responses.each_value.lazy.flat_map(&:values))
41
41
  end
@@ -95,7 +95,7 @@ module OpenapiFirst
95
95
  def route_at(path, request_method)
96
96
  request_method = request_method.upcase
97
97
  path_item = if PathTemplate.template?(path)
98
- @dynamic[path] ||= { template: PathTemplate.new(path) }
98
+ @dynamic[path] ||= {}.tap { @trie.add(path, _1) }
99
99
  else
100
100
  @static[path] ||= {}
101
101
  end
@@ -112,15 +112,7 @@ module OpenapiFirst
112
112
  found = @static[request_path]
113
113
  return [found, {}] if found
114
114
 
115
- @dynamic.each_value.reduce(nil) do |best, path_item|
116
- params = path_item[:template].match(request_path)
117
- next best unless params
118
-
119
- candidate = [path_item, params]
120
- next candidate unless best
121
-
122
- params.values.sum(&:length) < best[1].values.sum(&:length) ? candidate : best
123
- end
115
+ @trie.match(request_path)
124
116
  end
125
117
  end
126
118
  end
@@ -62,9 +62,6 @@ module OpenapiFirst
62
62
  # encoded characters, ...) - a request Sinatra matches is not re-checked against openapi_first's own
63
63
  # path matching. Avoid path shapes where the two routers disagree.
64
64
  module Sinatra
65
- PATH_PARAMETER = /\{[^}]+\}/
66
- private_constant :PATH_PARAMETER
67
-
68
65
  # The configuration lives in Sinatra settings (rather than plain instance variables) so a
69
66
  # subclass of a configured app inherits the loaded description and its operation index.
70
67
  def self.registered(app)
@@ -133,7 +130,7 @@ module OpenapiFirst
133
130
  end
134
131
 
135
132
  def operation_handler(path_template, block)
136
- param_names = path_template.scan(PATH_PARAMETER).map! { |placeholder| placeholder[1..-2] }
133
+ param_names = PathTemplate.variable_names(path_template)
137
134
  proc do |*captures|
138
135
  path_params = param_names.zip(captures).to_h
139
136
  validated = settings.openapi_definition.validate_request(request, path_template:, path_params:)
@@ -147,7 +144,7 @@ module OpenapiFirst
147
144
  end
148
145
 
149
146
  def sinatra_pattern(path)
150
- path.gsub(PATH_PARAMETER) { |placeholder| ":#{placeholder[1..-2].gsub(/[^A-Za-z0-9_]/, '_')}" }
147
+ PathTemplate.expand(path) { |name| ":#{name.gsub(/[^A-Za-z0-9_]/, '_')}" }
151
148
  end
152
149
 
153
150
  def build_operation_index(definition)
@@ -25,8 +25,8 @@ module OpenapiFirst
25
25
  # Register OADs, but don't load them just yet
26
26
  # @param [OpenapiFirst::OAD] oad The OAD to register
27
27
  # @param [Symbol] as The name to register the OAD under
28
- def register(oad, as: :default)
29
- Test.register(oad, as:)
28
+ def register(oad, as: :default, &)
29
+ Test.register(oad, as:, &)
30
30
  end
31
31
 
32
32
  # Observe a rack app
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiFirst
4
- VERSION = '4.2.1'
4
+ VERSION = '4.4.0'
5
5
  end
data/lib/openapi_first.rb CHANGED
@@ -62,15 +62,20 @@ module OpenapiFirst
62
62
  configuration.plugin(name, **)
63
63
  end
64
64
 
65
- # Load and dereference an OpenAPI spec file or return the Definition if it's already loaded
66
- # @param filepath_or_definition [String, Definition] The path to the file or a Definition object
65
+ # Load and dereference an OpenAPI spec file or return the Definition if it's already loaded.
66
+ # If a block is given together with a Definition or Symbol,
67
+ # this returns a new Definition with the configuration block applied.
68
+ # @param filepath_or_definition [String, Symbol, Definition] The path to the file, a registered name or a Definition
67
69
  # @param only [Proc, nil] An optional proc to filter paths. It is called with the path string and should return
68
70
  # true/false
69
71
  # @param path_prefix [String, nil] An optional path prefix, that is not documented, that all requests begin with.
72
+ # @yieldparam config [Configuration] Configuration of the returned Definition.
70
73
  # @return [Definition]
71
74
  def self.load(filepath_or_definition, only: nil, path_prefix: nil, &)
72
- return filepath_or_definition if filepath_or_definition.is_a?(Definition)
73
- return self[filepath_or_definition] if filepath_or_definition.is_a?(Symbol)
75
+ if filepath_or_definition.is_a?(Definition) || filepath_or_definition.is_a?(Symbol)
76
+ definition = self[filepath_or_definition]
77
+ return block_given? ? definition.configure(&) : definition
78
+ end
74
79
 
75
80
  filepath = filepath_or_definition
76
81
  raise FileNotFoundError, "File not found: #{filepath}" unless File.exist?(filepath)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openapi_first
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.2.1
4
+ version: 4.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andreas Haller
@@ -108,6 +108,7 @@ files:
108
108
  - lib/openapi_first/parameter_content_parsers.rb
109
109
  - lib/openapi_first/parameters_parser.rb
110
110
  - lib/openapi_first/parsed_request.rb
111
+ - lib/openapi_first/path_template.rb
111
112
  - lib/openapi_first/plugins.rb
112
113
  - lib/openapi_first/plugins/x_public.rb
113
114
  - lib/openapi_first/query_string_parser.rb
@@ -125,7 +126,8 @@ files:
125
126
  - lib/openapi_first/router.rb
126
127
  - lib/openapi_first/router/find_content.rb
127
128
  - lib/openapi_first/router/find_response.rb
128
- - lib/openapi_first/router/path_template.rb
129
+ - lib/openapi_first/router/path_trie.rb
130
+ - lib/openapi_first/router/segment_pattern.rb
129
131
  - lib/openapi_first/schema/hash.rb
130
132
  - lib/openapi_first/schema/validation_error.rb
131
133
  - lib/openapi_first/schema/validation_result.rb
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module OpenapiFirst
4
- class Router
5
- # @visibility private
6
- class PathTemplate
7
- # See also https://spec.openapis.org/oas/v3.1.0#path-templating
8
- TEMPLATE_EXPRESSION = /(\{[^{}]+\})/
9
-
10
- def self.template?(string)
11
- string.include?('{')
12
- end
13
-
14
- def initialize(template)
15
- @template = template
16
- @pattern = build_pattern(template)
17
- end
18
-
19
- def to_s
20
- @template
21
- end
22
-
23
- def match(path)
24
- return {} if path == @template
25
-
26
- matches = path.match(@pattern)
27
- return unless matches
28
-
29
- matches.named_captures
30
- end
31
-
32
- private
33
-
34
- def build_pattern(template)
35
- parts = template.split(TEMPLATE_EXPRESSION).map! do |part|
36
- if part.start_with?('{')
37
- "(?<#{part[1..-2]}>[^/?#]+)"
38
- else
39
- Regexp.escape(part)
40
- end
41
- end
42
-
43
- /^#{parts.join}$/
44
- end
45
- end
46
- end
47
- end