openapi_first 4.2.1 → 4.3.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: db682408d3927edb6c83c2778f16edecfa428c3e85bdc33ec96664259ddd3f0b
4
+ data.tar.gz: 4519ccef3e6c27da6372f52fea09a1e36aa09b5648534edce6caf4d7bdbca8b3
5
5
  SHA512:
6
- metadata.gz: 974432feb3f2b69cf7f1d613980d54bb2cbf25ae39ff09c2afc3f9aa0a8d4246bbd17c555ef7814533dcaee9ea9eee6d9aa9856a23b5869a2dcc61f8b5c06c32
7
- data.tar.gz: 907638cdecc5ba7f0504a8a41555359db2a5dd924644495848836fc39e791624283050bdd7923afcd4eedf08a26fa5bb13b8ff06d91971033a4bb3a9507d3b53
6
+ metadata.gz: d37cc2c5d0d99331907a5d565eba3a404e492c7cef71c154e5badd52fe05fb2c7761d17cbe2f94dfec14cda1df59e354f5848bcdc44b515069d325a1316d14aa
7
+ data.tar.gz: 20d09dc7debef84d08504202bd6b2acabc33d172486d79d82685bce22362dbf15f920bda84287619bc7517f42da5701c454bf1e23c74a7eeb0c870ab8ae7025c
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 4.3.0
6
+
7
+ - Fix: Passing a block to `config.register` not works when called inside `OpenapiFirst.configure` block. Before the block was ignored.
8
+ - 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.
9
+ - 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.
10
+
5
11
  ## 4.2.1
6
12
 
7
13
  - 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)).
@@ -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
 
@@ -80,8 +81,7 @@ module OpenapiFirst
80
81
  request_def = routes.lazy.flat_map(&:requests).find { |r| r.operation_id == operation_id.to_s }
81
82
  raise ArgumentError, "Operation #{operation_id.inspect} is not defined in #{key}." unless request_def
82
83
 
83
- request_def.path.gsub(/\{([^}]+)\}/) do
84
- name = Regexp.last_match(1)
84
+ PathTemplate.expand(request_def.path) do |name|
85
85
  params.fetch(name.to_sym) do
86
86
  params.fetch(name) do
87
87
  raise ArgumentError, "Missing path parameter #{name.inspect} for operation #{operation_id.inspect}."
@@ -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,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../path_template'
4
+ require_relative 'segment_pattern'
5
+
6
+ module OpenapiFirst
7
+ class Router
8
+ # @visibility private
9
+ class PathTrie
10
+ def initialize
11
+ @static = {}
12
+ @partial = {}
13
+ @variable = nil
14
+ @leaf = nil
15
+ end
16
+
17
+ def add(template, value)
18
+ names = PathTemplate.variable_names(template)
19
+ node = template.split('/', -1).reduce(self) { |parent, segment| parent.child(segment) }
20
+ node.leaf ||= [value, names]
21
+ end
22
+
23
+ def match(path)
24
+ values = []
25
+ value, names = find(path.split('/', -1), 0, values)
26
+ [value, names.zip(values).to_h] if value
27
+ end
28
+
29
+ def find(segments, index, values)
30
+ return @leaf if index == segments.size
31
+
32
+ @static[segments[index]]&.find(segments, index + 1, values) ||
33
+ find_partial(segments, index, values) ||
34
+ find_variable(segments, index, values)
35
+ end
36
+
37
+ protected
38
+
39
+ attr_accessor :leaf
40
+
41
+ def child(segment)
42
+ return @variable ||= PathTrie.new if PathTemplate.variable?(segment)
43
+ return partial_child(segment) if PathTemplate.template?(segment)
44
+
45
+ @static[segment] ||= PathTrie.new
46
+ end
47
+
48
+ private
49
+
50
+ def find_partial(segments, index, values)
51
+ @partial.each_value do |pattern, node|
52
+ captures = pattern.match(segments[index])
53
+ next unless captures
54
+
55
+ values.concat(captures)
56
+ found = node.find(segments, index + 1, values)
57
+ return found if found
58
+
59
+ values.pop(captures.size)
60
+ end
61
+ nil
62
+ end
63
+
64
+ def find_variable(segments, index, values)
65
+ segment = segments[index]
66
+ return if @variable.nil? || segment.empty?
67
+
68
+ values << segment
69
+ found = @variable.find(segments, index + 1, values)
70
+ values.pop unless found
71
+ found
72
+ end
73
+
74
+ def partial_child(segment)
75
+ @partial[segment] ||= [SegmentPattern.new(segment), PathTrie.new]
76
+ @partial = @partial.sort_by.with_index { |(key, _), i| [-literal_length(key), i] }.to_h
77
+ @partial[segment].last
78
+ end
79
+
80
+ def literal_length(segment)
81
+ segment.gsub(PathTemplate::VARIABLE, '').length
82
+ end
83
+ end
84
+ end
85
+ 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.3.0'
5
5
  end
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.3.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