rage-rb 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +15 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +96 -0
- data/Rakefile +8 -0
- data/exe/rage +4 -0
- data/lib/rage/application.rb +34 -0
- data/lib/rage/cli.rb +49 -0
- data/lib/rage/controller/api.rb +123 -0
- data/lib/rage/fiber.rb +9 -0
- data/lib/rage/fiber_scheduler.rb +98 -0
- data/lib/rage/router/README.md +19 -0
- data/lib/rage/router/backend.rb +249 -0
- data/lib/rage/router/constrainer.rb +91 -0
- data/lib/rage/router/dsl.rb +135 -0
- data/lib/rage/router/handler_storage.rb +153 -0
- data/lib/rage/router/node.rb +202 -0
- data/lib/rage/router/strategies/host.rb +50 -0
- data/lib/rage/setup.rb +6 -0
- data/lib/rage/templates/Gemfile +6 -0
- data/lib/rage/templates/app-controllers-application_controller.rb +2 -0
- data/lib/rage/templates/config-application.rb +4 -0
- data/lib/rage/templates/config-routes.rb +3 -0
- data/lib/rage/templates/config.ru +3 -0
- data/lib/rage/templates/lib-.keep +0 -0
- data/lib/rage/templates/log-.keep +0 -0
- data/lib/rage/templates/public-.keep +0 -0
- data/lib/rage/version.rb +5 -0
- data/lib/rage.rb +40 -0
- data/rage.gemspec +33 -0
- metadata +120 -0
@@ -0,0 +1,249 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "uri"
|
4
|
+
|
5
|
+
class Rage::Router::Backend
|
6
|
+
OPTIONAL_PARAM_REGEXP = /\/?\(\/?(:\w+)\/?\)/
|
7
|
+
STRING_HANDLER_REGEXP = /^([a-z0-9_\/]+)#([a-z_]+)$/
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@routes = []
|
11
|
+
@trees = {}
|
12
|
+
@constrainer = Rage::Router::Constrainer.new({})
|
13
|
+
end
|
14
|
+
|
15
|
+
def on(method, path, handler, constraints: {})
|
16
|
+
raise "Path could not be empty" if path&.empty?
|
17
|
+
|
18
|
+
if match_index = (path =~ OPTIONAL_PARAM_REGEXP)
|
19
|
+
raise "Optional Parameter has to be the last parameter of the path" if path.length != match_index + $&.length
|
20
|
+
|
21
|
+
path_full = path.sub(OPTIONAL_PARAM_REGEXP, "/#{$1}")
|
22
|
+
path_optional = path.sub(OPTIONAL_PARAM_REGEXP, "")
|
23
|
+
|
24
|
+
on(method, path_full, handler)
|
25
|
+
on(method, path_optional, handler)
|
26
|
+
return
|
27
|
+
end
|
28
|
+
|
29
|
+
if handler.is_a?(String)
|
30
|
+
raise "Invalid route handler format, expected to match the 'controller#action' pattern" unless handler =~ STRING_HANDLER_REGEXP
|
31
|
+
|
32
|
+
controller, action = to_controller_class($1), $2
|
33
|
+
run_action_method_name = controller.__register_action(action.to_sym)
|
34
|
+
|
35
|
+
handler = eval("->(env, params) { #{controller}.new(env, params).#{run_action_method_name} }")
|
36
|
+
else
|
37
|
+
raise "Non-string route handler should respond to `call`" unless handler.respond_to?(:call)
|
38
|
+
# while regular handlers are expected to be called with the `env` and `params` objects,
|
39
|
+
# lambda handlers expect just `env` as an argument;
|
40
|
+
# TODO: come up with something nicer?
|
41
|
+
orig_handler = handler
|
42
|
+
handler = ->(env, _params) { orig_handler.call(env) }
|
43
|
+
end
|
44
|
+
|
45
|
+
__on(method, path, handler, constraints)
|
46
|
+
end
|
47
|
+
|
48
|
+
def lookup(env)
|
49
|
+
constraints = @constrainer.derive_constraints(env)
|
50
|
+
find(env, constraints)
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def __on(method, path, handler, constraints)
|
56
|
+
@constrainer.validate_constraints(constraints)
|
57
|
+
# Let the constrainer know if any constraints are being used now
|
58
|
+
@constrainer.note_usage(constraints)
|
59
|
+
|
60
|
+
# Boot the tree for this method if it doesn't exist yet
|
61
|
+
@trees[method] ||= Rage::Router::StaticNode.new("/")
|
62
|
+
|
63
|
+
pattern = path
|
64
|
+
if pattern == "*" && !@trees[method].prefix.empty?
|
65
|
+
current_root = @trees[method]
|
66
|
+
@trees[method] = Rage::Router::StaticNode.new("")
|
67
|
+
@trees[method].static_children["/"] = current_root
|
68
|
+
end
|
69
|
+
|
70
|
+
current_node = @trees[method]
|
71
|
+
parent_node_path_index = current_node.prefix.length
|
72
|
+
|
73
|
+
i, params = 0, []
|
74
|
+
while i <= pattern.length
|
75
|
+
if pattern[i] == ":" && pattern[i + 1] == ":"
|
76
|
+
# It's a double colon
|
77
|
+
i += 2
|
78
|
+
next
|
79
|
+
end
|
80
|
+
|
81
|
+
is_parametric_node = pattern[i] == ":" && pattern[i + 1] != ":"
|
82
|
+
is_wildcard_node = pattern[i] == "*"
|
83
|
+
|
84
|
+
if is_parametric_node || is_wildcard_node || (i == pattern.length && i != parent_node_path_index)
|
85
|
+
static_node_path = pattern[parent_node_path_index, i - parent_node_path_index]
|
86
|
+
static_node_path = static_node_path.split("::").join(":")
|
87
|
+
static_node_path = static_node_path.split("%").join("%25")
|
88
|
+
# add the static part of the route to the tree
|
89
|
+
current_node = current_node.create_static_child(static_node_path)
|
90
|
+
end
|
91
|
+
|
92
|
+
if is_parametric_node
|
93
|
+
last_param_start_index = i + 1
|
94
|
+
|
95
|
+
j = last_param_start_index
|
96
|
+
while true
|
97
|
+
char = pattern[j]
|
98
|
+
is_end_of_node = (char == "/" || j == pattern.length)
|
99
|
+
|
100
|
+
if is_end_of_node
|
101
|
+
param_name = pattern[last_param_start_index, j - last_param_start_index]
|
102
|
+
params << param_name
|
103
|
+
|
104
|
+
static_part_start_index = j
|
105
|
+
while j < pattern.length
|
106
|
+
j_char = pattern[j]
|
107
|
+
break if j_char == "/"
|
108
|
+
if j_char == ":"
|
109
|
+
next_char = pattern[j + 1]
|
110
|
+
next_char == ":" ? j += 1 : break
|
111
|
+
end
|
112
|
+
j += 1
|
113
|
+
end
|
114
|
+
|
115
|
+
static_part = pattern[static_part_start_index, j - static_part_start_index]
|
116
|
+
unless static_part.empty?
|
117
|
+
static_part = static_part.split("::").join(":")
|
118
|
+
static_part = static_part.split("%").join("%25")
|
119
|
+
end
|
120
|
+
|
121
|
+
last_param_start_index = j + 1
|
122
|
+
|
123
|
+
if is_end_of_node || pattern[j] == "/" || j == pattern.length
|
124
|
+
node_path = pattern[i, j - i]
|
125
|
+
|
126
|
+
pattern = "#{pattern[0, i + 1]}#{static_part}#{pattern[j, pattern.length - j]}"
|
127
|
+
i += static_part.length
|
128
|
+
|
129
|
+
current_node = current_node.create_parametric_child(static_part == "" ? nil : static_part, node_path)
|
130
|
+
parent_node_path_index = i + 1
|
131
|
+
break
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
j += 1
|
136
|
+
end
|
137
|
+
elsif is_wildcard_node
|
138
|
+
# add the wildcard parameter
|
139
|
+
params << "*"
|
140
|
+
current_node = current_node.create_wildcard_child
|
141
|
+
parent_node_path_index = i + 1
|
142
|
+
raise "Wildcard must be the last character in the route" if i != pattern.length - 1
|
143
|
+
end
|
144
|
+
|
145
|
+
i += 1
|
146
|
+
end
|
147
|
+
|
148
|
+
if pattern == "*"
|
149
|
+
pattern = "/*"
|
150
|
+
end
|
151
|
+
|
152
|
+
@routes.each do |existing_route|
|
153
|
+
if (
|
154
|
+
existing_route[:method] == method &&
|
155
|
+
existing_route[:pattern] == pattern &&
|
156
|
+
existing_route[:constraints] == constraints
|
157
|
+
)
|
158
|
+
raise "Method '#{method}' already declared for route '#{pattern}' with constraints '#{constraints.inspect}'"
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
route = { method: method, path: path, pattern: pattern, params: params, constraints: constraints, handler: handler }
|
163
|
+
@routes << route
|
164
|
+
current_node.add_route(route, @constrainer)
|
165
|
+
end
|
166
|
+
|
167
|
+
def find(env, derived_constraints)
|
168
|
+
method, path = env["REQUEST_METHOD"], env["PATH_INFO"]
|
169
|
+
|
170
|
+
current_node = @trees[method]
|
171
|
+
return nil unless current_node
|
172
|
+
|
173
|
+
origin_path = path
|
174
|
+
|
175
|
+
path_index = current_node.prefix.length
|
176
|
+
url_params = []
|
177
|
+
path_len = path.length
|
178
|
+
|
179
|
+
brothers_nodes_stack = []
|
180
|
+
|
181
|
+
while true
|
182
|
+
if path_index == path_len && current_node.is_leaf_node
|
183
|
+
handle = current_node.handler_storage.get_matching_handler(derived_constraints)
|
184
|
+
if handle
|
185
|
+
return {
|
186
|
+
handler: handle[:handler],
|
187
|
+
params: handle[:create_params_object].call(url_params)
|
188
|
+
}
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
node = current_node.get_next_node(path, path_index, brothers_nodes_stack, url_params.length)
|
193
|
+
|
194
|
+
unless node
|
195
|
+
return if brothers_nodes_stack.length == 0
|
196
|
+
|
197
|
+
brother_node_state = brothers_nodes_stack.pop
|
198
|
+
path_index = brother_node_state[:brother_path_index]
|
199
|
+
url_params.slice!(brother_node_state[:params_count], url_params.length)
|
200
|
+
node = brother_node_state[:brother_node]
|
201
|
+
end
|
202
|
+
|
203
|
+
current_node = node
|
204
|
+
|
205
|
+
if current_node.kind == Rage::Router::Node::STATIC
|
206
|
+
path_index += current_node.prefix.length
|
207
|
+
next
|
208
|
+
end
|
209
|
+
|
210
|
+
if current_node.kind == Rage::Router::Node::WILDCARD
|
211
|
+
param = origin_path[path_index, origin_path.length - path_index]
|
212
|
+
param = Rack::Utils.unescape(param) if param.include?("%")
|
213
|
+
|
214
|
+
url_params << param
|
215
|
+
path_index = path_len
|
216
|
+
next
|
217
|
+
end
|
218
|
+
|
219
|
+
if current_node.kind == Rage::Router::Node::PARAMETRIC
|
220
|
+
param_end_index = origin_path.index("/", path_index)
|
221
|
+
param_end_index = path_len unless param_end_index
|
222
|
+
|
223
|
+
param = origin_path.slice(path_index, param_end_index - path_index)
|
224
|
+
param = Rack::Utils.unescape(param) if param.include?("%")
|
225
|
+
|
226
|
+
url_params << param
|
227
|
+
path_index = param_end_index
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
def to_controller_class(str)
|
233
|
+
str.capitalize!
|
234
|
+
str.gsub!(/([\/_])([a-zA-Z0-9]+)/) do
|
235
|
+
if $1 == "/"
|
236
|
+
"::#{$2.capitalize}"
|
237
|
+
else
|
238
|
+
$2.capitalize
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
klass = "#{str}Controller"
|
243
|
+
if Object.const_defined?(klass)
|
244
|
+
Object.const_get(klass)
|
245
|
+
else
|
246
|
+
raise "Routing error: could not find the #{klass} class"
|
247
|
+
end
|
248
|
+
end
|
249
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "set"
|
4
|
+
|
5
|
+
class Rage::Router::Constrainer
|
6
|
+
attr_reader :strategies
|
7
|
+
|
8
|
+
def initialize(custom_strategies)
|
9
|
+
@strategies = {
|
10
|
+
host: Rage::Router::Strategies::Host.new
|
11
|
+
}
|
12
|
+
|
13
|
+
@strategies_in_use = Set.new
|
14
|
+
end
|
15
|
+
|
16
|
+
def strategy_used?(strategy_name)
|
17
|
+
@strategies_in_use.include?(strategy_name)
|
18
|
+
end
|
19
|
+
|
20
|
+
def has_constraint_strategy(strategy_name)
|
21
|
+
custom_constraint_strategy = @strategies[strategy_name]
|
22
|
+
if custom_constraint_strategy
|
23
|
+
return custom_constraint_strategy.custom? || strategy_used?(strategy_name)
|
24
|
+
end
|
25
|
+
|
26
|
+
false
|
27
|
+
end
|
28
|
+
|
29
|
+
def derive_constraints(env)
|
30
|
+
end
|
31
|
+
|
32
|
+
# When new constraints start getting used, we need to rebuild the deriver to derive them. Do so if we see novel constraints used.
|
33
|
+
def note_usage(constraints)
|
34
|
+
if constraints
|
35
|
+
before_size = @strategies_in_use.size
|
36
|
+
|
37
|
+
constraints.each_key do |key|
|
38
|
+
@strategies_in_use.add(key)
|
39
|
+
end
|
40
|
+
|
41
|
+
if before_size != @strategies_in_use.size
|
42
|
+
__build_derive_constraints
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def new_store_for_constraint(constraint)
|
48
|
+
raise "No strategy registered for constraint key '#{constraint}'" unless @strategies[constraint]
|
49
|
+
@strategies[constraint].storage
|
50
|
+
end
|
51
|
+
|
52
|
+
def validate_constraints(constraints)
|
53
|
+
constraints.each do |key, value|
|
54
|
+
strategy = @strategies[key]
|
55
|
+
raise "No strategy registered for constraint key '#{key}'" unless strategy
|
56
|
+
|
57
|
+
strategy.validate(value)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Optimization: build a fast function for deriving the constraints for all the strategies at once. We inline the definitions of the version constraint and the host constraint for performance.
|
62
|
+
# If no constraining strategies are in use (no routes constrain on host, or version, or any custom strategies) then we don't need to derive constraints for each route match, so don't do anything special, and just return undefined
|
63
|
+
# This allows us to not allocate an object to hold constraint values if no constraints are defined.
|
64
|
+
def __build_derive_constraints
|
65
|
+
return if @strategies_in_use.empty?
|
66
|
+
|
67
|
+
lines = ["{"]
|
68
|
+
|
69
|
+
@strategies_in_use.each do |key|
|
70
|
+
strategy = @strategies[key]
|
71
|
+
# Optimization: inline the derivation for the common built in constraints
|
72
|
+
if !strategy.custom?
|
73
|
+
if key == :host
|
74
|
+
lines << " host: env['HTTP_HOST'.freeze],"
|
75
|
+
else
|
76
|
+
raise 'unknown non-custom strategy for compiling constraint derivation function'
|
77
|
+
end
|
78
|
+
else
|
79
|
+
lines << " #{strategy.name}: @strategies[#{key}].derive_constraint(env),"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
lines << "}"
|
84
|
+
|
85
|
+
instance_eval <<-RUBY
|
86
|
+
def derive_constraints(env)
|
87
|
+
#{lines.join("\n")}
|
88
|
+
end
|
89
|
+
RUBY
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Rage::Router::DSL
|
4
|
+
def initialize(router)
|
5
|
+
@router = router
|
6
|
+
end
|
7
|
+
|
8
|
+
def draw(&block)
|
9
|
+
Handler.new(@router).instance_eval(&block)
|
10
|
+
end
|
11
|
+
|
12
|
+
class Handler
|
13
|
+
def initialize(router)
|
14
|
+
@router = router
|
15
|
+
|
16
|
+
@path_prefixes = []
|
17
|
+
@module_prefixes = []
|
18
|
+
end
|
19
|
+
|
20
|
+
# Register a new GET route.
|
21
|
+
#
|
22
|
+
# @param path [String] the path for the route handler
|
23
|
+
# @param to [String] the route handler in the format of "controller#action"
|
24
|
+
# @param constraints [Hash] a hash of constraints for the route
|
25
|
+
# @example
|
26
|
+
# get "/photos/:id", to: "photos#show", constraints: { host: /myhost/ }
|
27
|
+
def get(path, to:, constraints: nil)
|
28
|
+
__on("GET", path, to, constraints)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Register a new POST route.
|
32
|
+
#
|
33
|
+
# @param path [String] the path for the route handler
|
34
|
+
# @param to [String] the route handler in the format of "controller#action"
|
35
|
+
# @param constraints [Hash] a hash of constraints for the route
|
36
|
+
# @example
|
37
|
+
# post "/photos", to: "photos#create", constraints: { host: /myhost/ }
|
38
|
+
def post(path, to:, constraints: nil)
|
39
|
+
__on("POST", path, to, constraints)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Register a new PUT route.
|
43
|
+
#
|
44
|
+
# @param path [String] the path for the route handler
|
45
|
+
# @param to [String] the route handler in the format of "controller#action"
|
46
|
+
# @param constraints [Hash] a hash of constraints for the route
|
47
|
+
# @example
|
48
|
+
# put "/photos/:id", to: "photos#update", constraints: { host: /myhost/ }
|
49
|
+
def put(path, to:, constraints: nil)
|
50
|
+
__on("PUT", path, to, constraints)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Register a new PATCH route.
|
54
|
+
#
|
55
|
+
# @param path [String] the path for the route handler
|
56
|
+
# @param to [String] the route handler in the format of "controller#action"
|
57
|
+
# @param constraints [Hash] a hash of constraints for the route
|
58
|
+
# @example
|
59
|
+
# patch "/photos/:id", to: "photos#update", constraints: { host: /myhost/ }
|
60
|
+
def patch(path, to:, constraints: nil)
|
61
|
+
__on("PATCH", path, to, constraints)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Register a new DELETE route.
|
65
|
+
#
|
66
|
+
# @param path [String] the path for the route handler
|
67
|
+
# @param to [String] the route handler in the format of "controller#action"
|
68
|
+
# @param constraints [Hash] a hash of constraints for the route
|
69
|
+
# @example
|
70
|
+
# delete "/photos/:id", to: "photos#destroy", constraints: { host: /myhost/ }
|
71
|
+
def delete(path, to:, constraints: nil)
|
72
|
+
__on("DELETE", path, to, constraints)
|
73
|
+
end
|
74
|
+
|
75
|
+
# Register a new route pointing to '/'.
|
76
|
+
#
|
77
|
+
# @param to [String] the route handler in the format of "controller#action"
|
78
|
+
# @example
|
79
|
+
# root to: "photos#index"
|
80
|
+
def root(to:)
|
81
|
+
__on("GET", "/", to, nil)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Scopes a set of routes to the given default options.
|
85
|
+
#
|
86
|
+
# @param [Hash] opts scope options.
|
87
|
+
# @option opts [String] :module module option
|
88
|
+
# @option opts [String] :path path option
|
89
|
+
# @example Route `/photos` to `Api::PhotosController`
|
90
|
+
# scope module: "api" do
|
91
|
+
# get "photos", to: "photos#index"
|
92
|
+
# end
|
93
|
+
# @example Route `admin/photos` to `PhotosController`
|
94
|
+
# scope path: "admin" do
|
95
|
+
# get "photos", to: "photos#index"
|
96
|
+
# end
|
97
|
+
# @example Nested calls
|
98
|
+
# scope module: "admin" do
|
99
|
+
# get "photos", to: "photos#index"
|
100
|
+
#
|
101
|
+
# scope path: "api", module: "api" do
|
102
|
+
# get "photos/:id", to: "photos#show"
|
103
|
+
# end
|
104
|
+
# end
|
105
|
+
def scope(opts, &block)
|
106
|
+
raise ArgumentError, "only 'module' and 'path' options are accepted" if (opts.keys - %i(module path)).any?
|
107
|
+
|
108
|
+
@path_prefixes << opts[:path].delete_prefix("/").delete_suffix("/") if opts[:path]
|
109
|
+
@module_prefixes << opts[:module] if opts[:module]
|
110
|
+
|
111
|
+
instance_eval &block
|
112
|
+
|
113
|
+
@path_prefixes.pop if opts[:path]
|
114
|
+
@module_prefixes.pop if opts[:module]
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def __on(method, path, to, constraints)
|
120
|
+
if path != "/"
|
121
|
+
path = "/#{path}" unless path.start_with?("/")
|
122
|
+
path = path.delete_suffix("/") if path.end_with?("/")
|
123
|
+
end
|
124
|
+
|
125
|
+
path_prefix = @path_prefixes.any? ? "/#{@path_prefixes.join("/")}" : nil
|
126
|
+
module_prefix = @module_prefixes.any? ? "#{@module_prefixes.join("/")}/" : nil
|
127
|
+
|
128
|
+
if to.is_a?(String)
|
129
|
+
@router.on(method, "#{path_prefix}#{path}", "#{module_prefix}#{to}", constraints: constraints || {})
|
130
|
+
else
|
131
|
+
@router.on(method, "#{path_prefix}#{path}", to, constraints: constraints || {})
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Rage::Router::HandlerStorage
|
4
|
+
def initialize
|
5
|
+
@unconstrained_handler = nil # optimized reference to the handler that will match most of the time
|
6
|
+
@constraints = []
|
7
|
+
@handlers = [] # unoptimized list of handler objects for which the fast matcher function will be compiled
|
8
|
+
@constrained_handler_stores = nil
|
9
|
+
end
|
10
|
+
|
11
|
+
# This is the hot path for node handler finding -- change with care!
|
12
|
+
def get_matching_handler(derived_constraints)
|
13
|
+
return @unconstrained_handler unless derived_constraints
|
14
|
+
get_handler_matching_constraints(derived_constraints)
|
15
|
+
end
|
16
|
+
|
17
|
+
def add_handler(constrainer, route)
|
18
|
+
params = route[:params]
|
19
|
+
constraints = route[:constraints]
|
20
|
+
|
21
|
+
handler_object = {
|
22
|
+
params: params,
|
23
|
+
constraints: constraints,
|
24
|
+
handler: route[:handler],
|
25
|
+
create_params_object: compile_create_params_object(params)
|
26
|
+
}
|
27
|
+
|
28
|
+
constraints_keys = constraints.keys
|
29
|
+
if constraints_keys.empty?
|
30
|
+
@unconstrained_handler = handler_object
|
31
|
+
end
|
32
|
+
|
33
|
+
constraints_keys.each do |constraint_key|
|
34
|
+
@constraints << constraint_key unless @constraints.include?(constraint_key)
|
35
|
+
end
|
36
|
+
|
37
|
+
if @handlers.length >= 32
|
38
|
+
raise "Limit reached: a maximum of 32 route handlers per node allowed when there are constraints"
|
39
|
+
end
|
40
|
+
|
41
|
+
@handlers << handler_object
|
42
|
+
# Sort the most constrained handlers to the front of the list of handlers so they are tested first.
|
43
|
+
@handlers.sort_by! { |a| a[:constraints].length }
|
44
|
+
|
45
|
+
compile_get_handler_matching_constraints(constrainer)
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def compile_create_params_object(param_keys)
|
51
|
+
lines = []
|
52
|
+
|
53
|
+
param_keys.each_with_index do |key, i|
|
54
|
+
lines << "'#{key}' => param_values[#{i}]"
|
55
|
+
end
|
56
|
+
|
57
|
+
eval "->(param_values) { { #{lines.join(',')} } }"
|
58
|
+
end
|
59
|
+
|
60
|
+
def get_handler_matching_constraints(_derived_constraints)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Builds a store object that maps from constraint values to a bitmap of handler indexes which pass the constraint for a value
|
64
|
+
# So for a host constraint, this might look like { "fastify.io": 0b0010, "google.ca": 0b0101 }, meaning the 3rd handler is constrainted to fastify.io, and the 2nd and 4th handlers are constrained to google.ca.
|
65
|
+
# The store's implementation comes from the strategies provided to the Router.
|
66
|
+
def build_constraint_store(store, constraint)
|
67
|
+
@handlers.each_with_index do |handler, i|
|
68
|
+
constraint_value = handler[:constraints][constraint]
|
69
|
+
if constraint_value
|
70
|
+
indexes = store.get(constraint_value) || 0
|
71
|
+
indexes |= 1 << i # set the i-th bit for the mask because this handler is constrained by this value https://stackoverflow.com/questions/1436438/how-do-you-set-clear-and-toggle-a-single-bit-in-javascrip
|
72
|
+
store.set(constraint_value, indexes)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Builds a bitmask for a given constraint that has a bit for each handler index that is 0 when that handler *is* constrained and 1 when the handler *isnt* constrainted. This is opposite to what might be obvious, but is just for convienience when doing the bitwise operations.
|
78
|
+
def constrained_index_bitmask(constraint)
|
79
|
+
mask = 0
|
80
|
+
|
81
|
+
@handlers.each_with_index do |handler, i|
|
82
|
+
constraint_value = handler[:constraints][constraint]
|
83
|
+
mask |= 1 << i if constraint_value
|
84
|
+
end
|
85
|
+
|
86
|
+
~mask
|
87
|
+
end
|
88
|
+
|
89
|
+
# Compile a fast function to match the handlers for this node
|
90
|
+
# The function implements a general case multi-constraint matching algorithm.
|
91
|
+
# The general idea is this: we have a bunch of handlers, each with a potentially different set of constraints, and sometimes none at all. We're given a list of constraint values and we have to use the constraint-value-comparison strategies to see which handlers match the constraint values passed in.
|
92
|
+
# We do this by asking each constraint store which handler indexes match the given constraint value for each store. Trickily, the handlers that a store says match are the handlers constrained by that store, but handlers that aren't constrained at all by that store could still match just fine. So, each constraint store can only describe matches for it, and it won't have any bearing on the handlers it doesn't care about. For this reason, we have to ask each stores which handlers match and track which have been matched (or not cared about) by all of them.
|
93
|
+
# We use bitmaps to represent these lists of matches so we can use bitwise operations to implement this efficiently. Bitmaps are cheap to allocate, let us implement this masking behaviour in one CPU instruction, and are quite compact in memory. We start with a bitmap set to all 1s representing every handler that is a match candidate, and then for each constraint, see which handlers match using the store, and then mask the result by the mask of handlers that that store applies to, and bitwise AND with the candidate list. Phew.
|
94
|
+
# We consider all this compiling function complexity to be worth it, because the naive implementation that just loops over the handlers asking which stores match is quite a bit slower.
|
95
|
+
def compile_get_handler_matching_constraints(constrainer)
|
96
|
+
@constrained_handler_stores = {}
|
97
|
+
|
98
|
+
@constraints.each do |constraint|
|
99
|
+
store = constrainer.new_store_for_constraint(constraint)
|
100
|
+
@constrained_handler_stores[constraint] = store
|
101
|
+
|
102
|
+
build_constraint_store(store, constraint)
|
103
|
+
end
|
104
|
+
|
105
|
+
lines = []
|
106
|
+
lines << <<-RUBY
|
107
|
+
candidates = #{(1 << @handlers.length) - 1}
|
108
|
+
mask, matches = nil
|
109
|
+
RUBY
|
110
|
+
|
111
|
+
@constraints.each do |constraint|
|
112
|
+
# Setup the mask for indexes this constraint applies to. The mask bits are set to 1 for each position if the constraint applies.
|
113
|
+
lines << <<-RUBY
|
114
|
+
mask = #{constrained_index_bitmask(constraint)}
|
115
|
+
value = derived_constraints[:#{constraint}]
|
116
|
+
RUBY
|
117
|
+
|
118
|
+
# If there's no constraint value, none of the handlers constrained by this constraint can match. Remove them from the candidates.
|
119
|
+
# If there is a constraint value, get the matching indexes bitmap from the store, and mask it down to only the indexes this constraint applies to, and then bitwise and with the candidates list to leave only matching candidates left.
|
120
|
+
strategy = constrainer.strategies[constraint]
|
121
|
+
match_mask = strategy.must_match_when_derived ? "matches" : "(matches | mask)"
|
122
|
+
|
123
|
+
lines.push << <<-RUBY
|
124
|
+
if !value
|
125
|
+
candidates &= mask
|
126
|
+
else
|
127
|
+
matches = @constrained_handler_stores[:#{constraint}].get(value) || 0
|
128
|
+
candidates &= #{match_mask}
|
129
|
+
end
|
130
|
+
|
131
|
+
return nil if candidates == 0
|
132
|
+
RUBY
|
133
|
+
end
|
134
|
+
|
135
|
+
# There are some constraints that can be derived and marked as "must match", where if they are derived, they only match routes that actually have a constraint on the value, like the SemVer version constraint.
|
136
|
+
# An example: a request comes in for version 1.x, and this node has a handler that matches the path, but there's no version constraint. For SemVer, the find-my-way semantics do not match this handler to that request.
|
137
|
+
# This function is used by Nodes with handlers to match when they don't have any constrained routes to exclude request that do have must match derived constraints present.
|
138
|
+
constrainer.strategies.each do |constraint, strategy|
|
139
|
+
if strategy.must_match_when_derived && !@constraints.include?(constraint)
|
140
|
+
lines << "return nil if derived_constraints[:#{constraint}]"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
# Return the first handler whose bit is set in the candidates https://stackoverflow.com/questions/18134985/how-to-find-index-of-first-set-bit
|
145
|
+
lines << "return @handlers[Math.log2(candidates).floor]"
|
146
|
+
|
147
|
+
instance_eval <<-RUBY
|
148
|
+
def get_handler_matching_constraints(derived_constraints)
|
149
|
+
#{lines.join("\n")}
|
150
|
+
end
|
151
|
+
RUBY
|
152
|
+
end
|
153
|
+
end
|