rack-jet_router 1.1.1 → 1.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 +5 -5
- data/CHANGES.md +52 -0
- data/MIT-LICENSE +1 -1
- data/README.md +207 -52
- data/bench/Gemfile +13 -0
- data/bench/Rakefile.rb +11 -0
- data/bench/bench.rb +367 -0
- data/lib/rack/jet_router.rb +432 -208
- data/rack-jet_router.gemspec +21 -18
- data/test/builder_test.rb +631 -0
- data/test/misc_test.rb +23 -0
- data/test/router_test.rb +597 -0
- data/test/run_all.rb +6 -0
- data/test/{test_helper.rb → shared.rb} +8 -3
- metadata +36 -42
- data/Rakefile +0 -58
- data/test/rack/jet_router_test.rb +0 -670
data/lib/rack/jet_router.rb
CHANGED
@@ -1,8 +1,9 @@
|
|
1
1
|
# -*- coding: utf-8 -*-
|
2
|
+
# frozen_string_literal: true
|
2
3
|
|
3
4
|
###
|
4
|
-
### $Release: 1.
|
5
|
-
### $Copyright: copyright(c) 2015
|
5
|
+
### $Release: 1.3.0 $
|
6
|
+
### $Copyright: copyright(c) 2015 kwatch@gmail.com $
|
6
7
|
### $License: MIT License $
|
7
8
|
###
|
8
9
|
|
@@ -15,88 +16,177 @@ module Rack
|
|
15
16
|
##
|
16
17
|
## Jet-speed router class, derived from Keight.rb.
|
17
18
|
##
|
18
|
-
##
|
19
|
-
##
|
20
|
-
##
|
21
|
-
##
|
22
|
-
##
|
23
|
-
##
|
24
|
-
##
|
25
|
-
##
|
26
|
-
##
|
27
|
-
##
|
28
|
-
##
|
29
|
-
##
|
30
|
-
##
|
31
|
-
##
|
32
|
-
##
|
33
|
-
##
|
34
|
-
##
|
35
|
-
##
|
19
|
+
## Example #1:
|
20
|
+
## ### (assume that 'xxxx_app' are certain Rack applications.)
|
21
|
+
## mapping = {
|
22
|
+
## "/" => home_app,
|
23
|
+
## "/api" => {
|
24
|
+
## "/books" => {
|
25
|
+
## "" => books_app,
|
26
|
+
## "/:id(.:format)" => book_app,
|
27
|
+
## "/:book_id/comments/:comment_id" => comment_app,
|
28
|
+
## },
|
29
|
+
## },
|
30
|
+
## "/admin" => {
|
31
|
+
## "/books" => admin_books_app,
|
32
|
+
## },
|
33
|
+
## }
|
34
|
+
## router = Rack::JetRouter.new(mapping)
|
35
|
+
## router.lookup("/api/books/123.html")
|
36
|
+
## #=> [book_app, {"id"=>"123", "format"=>"html"}]
|
37
|
+
## status, headers, body = router.call(env)
|
36
38
|
##
|
37
|
-
##
|
38
|
-
##
|
39
|
-
##
|
40
|
-
##
|
41
|
-
##
|
42
|
-
##
|
43
|
-
##
|
44
|
-
##
|
45
|
-
##
|
46
|
-
##
|
47
|
-
##
|
48
|
-
##
|
49
|
-
##
|
50
|
-
##
|
51
|
-
##
|
52
|
-
##
|
53
|
-
##
|
54
|
-
##
|
39
|
+
## Example #2:
|
40
|
+
## mapping = [
|
41
|
+
## ["/" , {GET: home_app}],
|
42
|
+
## ["/api", [
|
43
|
+
## ["/books", [
|
44
|
+
## ["" , {GET: book_list_app, POST: book_create_app}],
|
45
|
+
## ["/:id(.:format)" , {GET: book_show_app, PUT: book_update_app}],
|
46
|
+
## ["/:book_id/comments/:comment_id", {POST: comment_create_app}],
|
47
|
+
## ]],
|
48
|
+
## ]],
|
49
|
+
## ["/admin", [
|
50
|
+
## ["/books" , {ANY: admin_books_app}],
|
51
|
+
## ]],
|
52
|
+
## ]
|
53
|
+
## router = Rack::JetRouter.new(mapping)
|
54
|
+
## router.lookup("/api/books/123")
|
55
|
+
## #=> [{"GET"=>book_show_app, "PUT"=>book_update_app}, {"id"=>"123", "format"=>nil}]
|
56
|
+
## status, headers, body = router.call(env)
|
57
|
+
##
|
58
|
+
## Example #3:
|
59
|
+
## class Map < Hash # define subclass of Hash
|
60
|
+
## end
|
61
|
+
## def Map(**kwargs) # define helper method to create Map object easily
|
62
|
+
## return Map.new.update(kwargs)
|
63
|
+
## end
|
64
|
+
## mapping = {
|
65
|
+
## "/" => Map(GET: home_app),
|
66
|
+
## "/api" => {
|
67
|
+
## "/books" => {
|
68
|
+
## "" => Map(GET: book_list_app, POST: book_create_app),
|
69
|
+
## "/:id(.:format)" => Map(GET: book_show_app, PUT: book_update_app),
|
70
|
+
## "/:book_id/comments/:comment_id" => Map(POST: comment_create_app),
|
71
|
+
## },
|
72
|
+
## },
|
73
|
+
## "/admin" => {
|
74
|
+
## "/books" => Map(ANY: admin_books_app),
|
75
|
+
## },
|
76
|
+
## }
|
77
|
+
## router = Rack::JetRouter.new(mapping)
|
78
|
+
## router.lookup("/api/books/123")
|
79
|
+
## #=> [{"GET"=>book_show_app, "PUT"=>book_update_app}, {"id"=>"123", "format"=>nil}]
|
80
|
+
## status, headers, body = router.call(env)
|
55
81
|
##
|
56
82
|
class JetRouter
|
57
83
|
|
58
|
-
RELEASE = '$Release: 1.
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
@
|
70
|
-
|
84
|
+
RELEASE = '$Release: 1.3.0 $'.split()[1]
|
85
|
+
|
86
|
+
#; [!haggu] contains available request methods.
|
87
|
+
REQUEST_METHODS = %w[GET POST PUT DELETE PATCH HEAD OPTIONS TRACE LINK UNLINK] \
|
88
|
+
.each_with_object({}) {|s, d| d[s] = s.intern }
|
89
|
+
|
90
|
+
def initialize(mapping, cache_size: 0, env_key: 'rack.urlpath_params',
|
91
|
+
int_param: nil, # ex: /(?:\A|_)id\z/
|
92
|
+
urlpath_cache_size: 0, # for backward compatibility
|
93
|
+
_enable_range: true) # undocumentend keyword arg
|
94
|
+
@env_key = env_key
|
95
|
+
@int_param = int_param
|
96
|
+
#; [!21mf9] 'urlpath_cache_size:' kwarg is available for backward compatibility.
|
97
|
+
@cache_size = [cache_size, urlpath_cache_size].max()
|
98
|
+
#; [!5tw57] cache is disabled when 'cache_size:' is zero.
|
99
|
+
@cache_dict = @cache_size > 0 ? {} : nil
|
100
|
+
##
|
101
|
+
## Pair list of endpoint and Rack app.
|
102
|
+
## ex:
|
103
|
+
## [
|
104
|
+
## ["/api/books" , books_app ],
|
105
|
+
## ["/api/books/:id" , book_app ],
|
106
|
+
## ["/api/orders" , orders_app],
|
107
|
+
## ["/api/orders/:id" , order_app ],
|
108
|
+
## ]
|
109
|
+
##
|
110
|
+
@all_endpoints = []
|
111
|
+
##
|
112
|
+
## Endpoints without any path parameters.
|
113
|
+
## ex:
|
114
|
+
## {
|
115
|
+
## "/" => home_app,
|
116
|
+
## "/api/books" => books_app,
|
117
|
+
## "/api/orders" => orders_app,
|
118
|
+
## }
|
119
|
+
##
|
120
|
+
@fixed_endpoints = {}
|
121
|
+
##
|
122
|
+
## Endpoints with one or more path parameters.
|
123
|
+
## ex:
|
124
|
+
## [
|
125
|
+
## [%r!\A/api/books/([^./?]+)\z! , ["id"], book_app , (11..-1)],
|
126
|
+
## [%r!\A/api/orders/([^./?]+)\z!, ["id"], order_app, (12..-1)],
|
127
|
+
## ]
|
128
|
+
##
|
129
|
+
@variable_endpoints = []
|
130
|
+
##
|
131
|
+
## Combined regexp of variable endpoints.
|
132
|
+
## ex:
|
133
|
+
## %r!\A/api/(?:books/[^./?]+(\z)|orders/[^./?]+(\z))\z!
|
134
|
+
##
|
135
|
+
@urlpath_rexp = nil
|
136
|
+
#
|
137
|
+
#; [!x2l32] gathers all endpoints.
|
138
|
+
builder = Builder.new(self, _enable_range)
|
139
|
+
builder.traverse_mapping(mapping) do |path, item|
|
140
|
+
@all_endpoints << [path, item]
|
141
|
+
end
|
142
|
+
#; [!l63vu] handles urlpath pattern as fixed when no urlpath params.
|
143
|
+
param_rexp = /:\w+|\(.*?\)/
|
144
|
+
pairs1, pairs2 = @all_endpoints.partition {|path, _| path =~ param_rexp }
|
145
|
+
pairs2.each {|path, item| @fixed_endpoints[path] = item }
|
146
|
+
#; [!saa1a] compiles compound urlpath regexp.
|
147
|
+
tree = builder.build_tree(pairs1)
|
148
|
+
@urlpath_rexp = builder.build_rexp(tree) do |tuple|
|
149
|
+
#; [!f1d7s] builds variable endpoint list.
|
150
|
+
@variable_endpoints << tuple
|
151
|
+
end
|
71
152
|
end
|
72
153
|
|
154
|
+
attr_reader :urlpath_rexp
|
155
|
+
|
73
156
|
## Finds rack app according to PATH_INFO and REQUEST_METHOD and invokes it.
|
74
157
|
def call(env)
|
75
158
|
#; [!fpw8x] finds mapped app according to env['PATH_INFO'].
|
76
159
|
req_path = env['PATH_INFO']
|
77
|
-
|
160
|
+
obj, param_values = lookup(req_path)
|
78
161
|
#; [!wxt2g] guesses correct urlpath and redirects to it automaticaly when request path not found.
|
79
162
|
#; [!3vsua] doesn't redict automatically when request path is '/'.
|
80
|
-
|
81
|
-
location = req_path
|
82
|
-
|
83
|
-
|
163
|
+
if ! obj && should_redirect?(env)
|
164
|
+
location = req_path.end_with?("/") ? req_path[0..-2] : req_path + "/"
|
165
|
+
obj, param_values = lookup(location)
|
166
|
+
if obj
|
167
|
+
#; [!hyk62] adds QUERY_STRING to redirect location.
|
168
|
+
qs = env['QUERY_STRING']
|
169
|
+
location = "#{location}?#{qs}" if qs && ! qs.empty?
|
170
|
+
return redirect_to(location)
|
171
|
+
end
|
84
172
|
end
|
85
173
|
#; [!30x0k] returns 404 when request urlpath not found.
|
86
|
-
return error_not_found(env) unless
|
174
|
+
return error_not_found(env) unless obj
|
87
175
|
#; [!gclbs] if mapped object is a Hash...
|
88
|
-
if
|
176
|
+
if obj.is_a?(Hash)
|
89
177
|
#; [!p1fzn] invokes app mapped to request method.
|
90
178
|
#; [!5m64a] returns 405 when request method is not allowed.
|
91
179
|
#; [!ys1e2] uses GET method when HEAD is not mapped.
|
92
180
|
#; [!2hx6j] try ANY method when request method is not mapped.
|
93
|
-
dict =
|
181
|
+
dict = obj
|
94
182
|
req_meth = env['REQUEST_METHOD']
|
95
183
|
app = dict[req_meth] || (req_meth == 'HEAD' ? dict['GET'] : nil) || dict['ANY']
|
96
184
|
return error_not_allowed(env) unless app
|
185
|
+
else
|
186
|
+
app = obj
|
97
187
|
end
|
98
|
-
#; [!2c32f] stores urlpath
|
99
|
-
|
188
|
+
#; [!2c32f] stores urlpath parameter values into env['rack.urlpath_params'].
|
189
|
+
store_param_values(env, param_values)
|
100
190
|
#; [!hse47] invokes app mapped to request urlpath.
|
101
191
|
return app.call(env) # make body empty when HEAD?
|
102
192
|
end
|
@@ -108,11 +198,11 @@ module Rack
|
|
108
198
|
def lookup(req_path)
|
109
199
|
#; [!24khb] finds in fixed urlpaths at first.
|
110
200
|
#; [!iwyzd] urlpath param value is nil when found in fixed urlpaths.
|
111
|
-
obj = @
|
201
|
+
obj = @fixed_endpoints[req_path]
|
112
202
|
return obj, nil if obj
|
113
203
|
#; [!upacd] finds in variable urlpath cache if it is enabled.
|
114
204
|
#; [!1zx7t] variable urlpath cache is based on LRU.
|
115
|
-
cache = @
|
205
|
+
cache = @cache_dict
|
116
206
|
if cache && (pair = cache.delete(req_path))
|
117
207
|
cache[req_path] = pair
|
118
208
|
return pair
|
@@ -120,29 +210,35 @@ module Rack
|
|
120
210
|
#; [!vpdzn] returns nil when urlpath not found.
|
121
211
|
m = @urlpath_rexp.match(req_path)
|
122
212
|
return nil unless m
|
123
|
-
index = m.captures.
|
213
|
+
index = m.captures.index('')
|
124
214
|
return nil unless index
|
125
215
|
#; [!ijqws] returns mapped object and urlpath parameter values when urlpath found.
|
126
|
-
full_urlpath_rexp, param_names, obj, range = @
|
216
|
+
full_urlpath_rexp, param_names, obj, range = @variable_endpoints[index]
|
127
217
|
if range
|
128
|
-
## "/books/123"[7..-1] is faster than /\A\/books\/(\d+)\z/.match("/books/123")
|
218
|
+
## "/books/123"[7..-1] is faster than /\A\/books\/(\d+)\z/.match("/books/123")[1]
|
129
219
|
str = req_path[range]
|
130
|
-
|
220
|
+
values = [str]
|
131
221
|
else
|
132
222
|
m = full_urlpath_rexp.match(req_path)
|
133
|
-
|
223
|
+
values = m.captures
|
134
224
|
end
|
135
|
-
|
225
|
+
param_values = build_param_values(param_names, values)
|
136
226
|
#; [!84inr] caches result when variable urlpath cache enabled.
|
137
227
|
if cache
|
138
|
-
cache.shift() if cache.length >= @
|
139
|
-
cache[req_path] = [obj,
|
228
|
+
cache.shift() if cache.length >= @cache_size
|
229
|
+
cache[req_path] = [obj, param_values]
|
140
230
|
end
|
141
|
-
return obj,
|
231
|
+
return obj, param_values
|
142
232
|
end
|
143
233
|
|
144
234
|
alias find lookup # :nodoc: # for backward compatilibity
|
145
235
|
|
236
|
+
## Yields pair of urlpath pattern and app.
|
237
|
+
def each(&block)
|
238
|
+
#; [!ep0pw] yields pair of urlpath pattern and app.
|
239
|
+
@all_endpoints.each(&block)
|
240
|
+
end
|
241
|
+
|
146
242
|
protected
|
147
243
|
|
148
244
|
## Returns [404, {...}, [...]]. Override in subclass if necessary.
|
@@ -157,172 +253,300 @@ module Rack
|
|
157
253
|
return [405, {"Content-Type"=>"text/plain"}, ["405 Method Not Allowed"]]
|
158
254
|
end
|
159
255
|
|
256
|
+
## Returns false when request path is '/' or request method is not GET nor HEAD.
|
257
|
+
## (It is not recommended to redirect when request method is POST, PUT or DELETE,
|
258
|
+
## because browser doesn't handle redirect correctly on those methods.)
|
259
|
+
def should_redirect?(env)
|
260
|
+
#; [!dsu34] returns false when request path is '/'.
|
261
|
+
#; [!ycpqj] returns true when request method is GET or HEAD.
|
262
|
+
#; [!7q8xu] returns false when request method is POST, PUT or DELETE.
|
263
|
+
return false if env['PATH_INFO'] == '/'
|
264
|
+
req_method = env['REQUEST_METHOD']
|
265
|
+
return req_method == 'GET' || req_method == 'HEAD'
|
266
|
+
end
|
267
|
+
|
160
268
|
## Returns [301, {"Location"=>location, ...}, [...]]. Override in subclass if necessary.
|
161
269
|
def redirect_to(location)
|
270
|
+
#; [!9z57v] returns 301 and 'Location' header.
|
162
271
|
content = "Redirect to #{location}"
|
163
272
|
return [301, {"Content-Type"=>"text/plain", "Location"=>location}, [content]]
|
164
273
|
end
|
165
274
|
|
166
|
-
##
|
167
|
-
def
|
168
|
-
env['rack.urlpath_params']
|
275
|
+
## Stores urlpath parameter values into `env['rack.urlpath_params']`. Override if necessary.
|
276
|
+
def store_param_values(env, values)
|
277
|
+
#; [!94riv] stores urlpath param values into `env['rack.urlpath_params']`.
|
278
|
+
#; [!9he9h] env key can be changed by `env_key:` kwarg of 'JetRouter#initialize()'.
|
279
|
+
env[@env_key] = values if values
|
169
280
|
end
|
170
281
|
|
171
|
-
## Returns Hash object representing urlpath
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
282
|
+
## Returns Hash object representing urlpath parameter values. Override if necessary.
|
283
|
+
def build_param_values(names, values)
|
284
|
+
#; [!qxcis] when 'int_param:' kwarg is specified to constructor...
|
285
|
+
if (int_param_rexp = @int_param)
|
286
|
+
#; [!l6p84] converts urlpath pavam value into integer.
|
287
|
+
d = {}
|
288
|
+
for name, val in names.zip(values)
|
289
|
+
d[name] = int_param_rexp.match?(name) ? val.to_i : val
|
290
|
+
end
|
291
|
+
return d
|
292
|
+
#; [!vrbo5] else...
|
293
|
+
else
|
294
|
+
#; [!yc9n8] creates new Hash object from param names and values.
|
295
|
+
return Hash[names.zip(values)]
|
296
|
+
end
|
185
297
|
end
|
186
298
|
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
299
|
+
public
|
300
|
+
|
301
|
+
def normalize_method_mapping(dict) # called from Builder class
|
302
|
+
#; [!r7cmk] converts keys into string.
|
303
|
+
#; [!z9kww] allows 'ANY' as request method.
|
304
|
+
#; [!k7sme] raises error when unknown request method specified.
|
305
|
+
#; [!itfsd] returns new Hash object.
|
306
|
+
#; [!gd08f] if arg is an instance of Hash subclass, returns new instance of it.
|
307
|
+
request_methods = REQUEST_METHODS
|
308
|
+
#newdict = {}
|
309
|
+
newdict = dict.class.new
|
310
|
+
dict.each do |meth_sym, app|
|
311
|
+
meth_str = meth_sym.to_s
|
312
|
+
request_methods[meth_str] || meth_str == 'ANY' or
|
313
|
+
raise ArgumentError.new("#{meth_sym}: unknown request method.")
|
314
|
+
newdict[meth_str] = app
|
315
|
+
end
|
316
|
+
return newdict
|
201
317
|
end
|
202
318
|
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
319
|
+
## Returns regexp string of path parameter. Override if necessary.
|
320
|
+
def param2rexp(param) # called from Builder class
|
321
|
+
#; [!6sd9b] returns regexp string according to param name.
|
322
|
+
#; [!rfvk2] returns '\d+' if param name matched to int param regexp.
|
323
|
+
return (rexp = @int_param) && rexp.match?(param) ? '\d+' : '[^./?]+'
|
324
|
+
end
|
325
|
+
|
326
|
+
|
327
|
+
class Builder
|
328
|
+
|
329
|
+
def initialize(router, enable_range=true)
|
330
|
+
@router = router
|
331
|
+
@enable_range = enable_range
|
332
|
+
end
|
333
|
+
|
334
|
+
def traverse_mapping(mapping, &block)
|
335
|
+
_traverse_mapping(mapping, "", mapping.class, &block)
|
336
|
+
nil
|
337
|
+
end
|
338
|
+
|
339
|
+
private
|
340
|
+
|
341
|
+
def _traverse_mapping(mapping, base_path, mapping_class, &block)
|
342
|
+
#; [!9s3f0] supports both nested list mapping and nested dict mapping.
|
343
|
+
mapping.each do |sub_path, item|
|
344
|
+
full_path = base_path + sub_path
|
345
|
+
#; [!2ntnk] nested dict mapping can have subclass of Hash as handlers.
|
346
|
+
if item.class == mapping_class
|
347
|
+
#; [!dj0sh] traverses mapping recursively.
|
348
|
+
_traverse_mapping(item, full_path, mapping_class, &block)
|
349
|
+
else
|
350
|
+
#; [!j0pes] if item is a hash object, converts keys from symbol to string.
|
351
|
+
item = _normalize_method_mapping(item) if item.is_a?(Hash)
|
352
|
+
#; [!brhcs] yields block for each full path and handler.
|
353
|
+
yield full_path, item
|
354
|
+
end
|
220
355
|
end
|
221
356
|
end
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
x == '(?:' or raise "assertion failed"
|
226
|
-
#; [!pv2au] deletes unnecessary urlpath regexp.
|
227
|
-
x = rexp_buf.pop() # delete rexp_str
|
228
|
-
x == rexp_str or raise "assertion failed"
|
229
|
-
#; [!bh9lo] deletes unnecessary grouping which contains only an element.
|
230
|
-
elsif rexp_buf.length == len + 1
|
231
|
-
rexp_buf[-2] == '(?:' or raise "assertion failed: rexp_buf[-2]=#{rexp_buf[-2].inspect}"
|
232
|
-
rexp_buf[-2] = ''
|
233
|
-
else
|
234
|
-
rexp_buf << ')'
|
357
|
+
|
358
|
+
def _normalize_method_mapping(dict)
|
359
|
+
return @router.normalize_method_mapping(dict)
|
235
360
|
end
|
236
|
-
end
|
237
361
|
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
362
|
+
public
|
363
|
+
|
364
|
+
def build_tree(entrypoint_pairs)
|
365
|
+
#; [!6oa05] builds nested hash object from mapping data.
|
366
|
+
tree = {} # tree is a nested dict
|
367
|
+
param_d = {}
|
368
|
+
entrypoint_pairs.each do |path, item|
|
369
|
+
d = tree
|
370
|
+
sb = ['\A']
|
371
|
+
pos = 0
|
372
|
+
params = []
|
373
|
+
#; [!uyupj] handles urlpath parameter such as ':id'.
|
374
|
+
#; [!j9cdy] handles optional urlpath parameter such as '(.:format)'.
|
375
|
+
path.scan(/:(\w+)|\((.*?)\)/) do
|
376
|
+
param = $1; optional = $2 # ex: $1=='id' or $2=='.:format'
|
377
|
+
m = Regexp.last_match()
|
378
|
+
str = path[pos, m.begin(0) - pos]
|
379
|
+
pos = m.end(0)
|
380
|
+
#; [!akkkx] converts urlpath param into regexp.
|
381
|
+
pat1, pat2 = _param_patterns(param, optional) do |param_|
|
382
|
+
param_.freeze
|
383
|
+
params << (param_d[param_] ||= param_)
|
384
|
+
end
|
385
|
+
#; [!po6o6] param regexp should be stored into nested dict as a Symbol.
|
386
|
+
d = _next_dict(d, str) unless str.empty?
|
387
|
+
d = (d[pat1.intern] ||= {}) # ex: pat1=='[^./?]+'
|
388
|
+
#; [!zoym3] urlpath string should be escaped.
|
389
|
+
sb << Regexp.escape(str) << pat2 # ex: pat2=='([^./?]+)'
|
390
|
+
end
|
391
|
+
#; [!o642c] remained string after param should be handled correctly.
|
392
|
+
str = pos == 0 ? path : path[pos..-1]
|
393
|
+
unless str.empty?
|
394
|
+
d = _next_dict(d, str)
|
395
|
+
sb << Regexp.escape(str) # ex: str=='.html'
|
396
|
+
end
|
397
|
+
sb << '\z'
|
398
|
+
#; [!kz8m7] range object should be included into tuple if only one param exist.
|
399
|
+
range = @enable_range ? _range_of_urlpath_param(path) : nil
|
400
|
+
#; [!c6xmp] tuple should be stored into nested dict with key 'nil'.
|
401
|
+
d[nil] = [Regexp.compile(sb.join()), params, item, range]
|
402
|
+
end
|
403
|
+
return tree
|
243
404
|
end
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
405
|
+
|
406
|
+
private
|
407
|
+
|
408
|
+
def _next_dict(d, str)
|
409
|
+
#; [!s1rzs] if new key exists in dict...
|
410
|
+
if d.key?(str)
|
411
|
+
#; [!io47b] just returns corresponding value and not change dict.
|
412
|
+
return d[str]
|
413
|
+
end
|
414
|
+
#; [!3ndpz] returns next dict.
|
415
|
+
d2 = nil # next dict
|
416
|
+
c = str[0]
|
417
|
+
found = false
|
418
|
+
d.keys.each do |key|
|
419
|
+
if found
|
420
|
+
#; [!5fh08] keeps order of keys in dict.
|
421
|
+
d[key] = d.delete(key)
|
422
|
+
#; [!4wdi7] ignores Symbol key (which represents regexp).
|
423
|
+
#; [!66sdb] ignores nil key (which represents leaf node).
|
424
|
+
elsif key.is_a?(String) && key[0] == c
|
425
|
+
found = true
|
426
|
+
prefix, rest1, rest2 = _common_prefix(key, str)
|
427
|
+
#; [!46o9b] if existing key is same as common prefix...
|
428
|
+
if rest1.empty?
|
429
|
+
#; [!4ypls] not replace existing key.
|
430
|
+
val = d[key]
|
431
|
+
d2 = _next_dict(val, rest2)
|
432
|
+
break
|
433
|
+
#; [!veq0q] if new key is same as common prefix...
|
434
|
+
elsif rest2.empty?
|
435
|
+
#; [!0tboh] replaces existing key with ney key.
|
436
|
+
val = d.delete(key)
|
437
|
+
d2 = {rest1 => val}
|
438
|
+
d[prefix] = d2
|
439
|
+
#; [!esszs] if common prefix is a part of exsting key and new key...
|
440
|
+
else
|
441
|
+
#; [!pesq0] replaces existing key with common prefix.
|
442
|
+
val = d.delete(key)
|
443
|
+
d2 = {}
|
444
|
+
d[prefix] = {rest1 => val, rest2 => d2}
|
445
|
+
end
|
446
|
+
end
|
447
|
+
end
|
448
|
+
#; [!viovl] if new key has no common prefix with existing keys...
|
449
|
+
unless found
|
450
|
+
#; [!i6smv] adds empty dict with new key.
|
451
|
+
d2 = {}
|
452
|
+
d[str] = d2
|
453
|
+
end
|
454
|
+
return d2
|
257
455
|
end
|
258
|
-
end
|
259
456
|
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
457
|
+
def _common_prefix(str1, str2) # ex: "/api/books/" and "/api/blog/"
|
458
|
+
#; [!86tsd] calculates common prefix of two strings.
|
459
|
+
#n = [str1.length, str2.length].min()
|
460
|
+
n1 = str1.length; n2 = str2.length
|
461
|
+
n = n1 < n2 ? n1 : n2
|
462
|
+
i = 0
|
463
|
+
while i < n && str1[i] == str2[i]
|
464
|
+
i += 1
|
465
|
+
end
|
466
|
+
#; [!1z2ii] returns common prefix and rest of strings.
|
467
|
+
prefix = str1[0...i] # ex: "/api/b"
|
468
|
+
rest1 = str1[i..-1] # ex: "ooks/"
|
469
|
+
rest2 = str2[i..-1] # ex: "log/"
|
470
|
+
return prefix, rest1, rest2
|
471
|
+
end
|
472
|
+
|
473
|
+
def _param_patterns(param, optional, &callback)
|
474
|
+
#; [!j90mw] returns '[^./?]+' and '([^./?]+)' if param specified.
|
475
|
+
if param
|
476
|
+
optional == nil or raise "** internal error"
|
477
|
+
yield param
|
478
|
+
pat1 = _param2rexp(param) # ex: '[^./?]+'
|
479
|
+
pat2 = "(#{pat1})"
|
480
|
+
#; [!raic7] returns '(?:\.[^./?]+)?' and '(?:\.([^./?]+))?' if optional param is '(.:format)'.
|
481
|
+
elsif optional == ".:format"
|
482
|
+
yield "format"
|
483
|
+
pat1 = '(?:\.[^./?]+)?'
|
484
|
+
pat2 = '(?:\.([^./?]+))?'
|
485
|
+
#; [!69yj9] optional string can contains other params.
|
277
486
|
elsif optional
|
278
|
-
|
279
|
-
optional.scan(/(.*?)(?::(\w+))/) do |
|
280
|
-
|
281
|
-
|
487
|
+
sb = ['(?:']
|
488
|
+
optional.scan(/(.*?)(?::(\w+))/) do |str, param_|
|
489
|
+
pat = _param2rexp(param) # ex: pat == '[^./?]+'
|
490
|
+
sb << Regexp.escape(str) << "<<#{pat}>>" # ex: sb << '(?:\.<<[^./?]+>>)?'
|
491
|
+
yield param_
|
282
492
|
end
|
283
|
-
|
284
|
-
|
285
|
-
|
493
|
+
sb << Regexp.escape($' || optional)
|
494
|
+
sb << ')?'
|
495
|
+
s = sb.join()
|
496
|
+
pat1 = s.gsub('<<', '' ).gsub('>>', '' ) # ex: '(?:\.[^./?]+)?'
|
497
|
+
pat2 = s.gsub('<<', '(').gsub('>>', ')') # ex: '(?:\.([^./?]+))?'
|
286
498
|
else
|
287
|
-
raise "
|
499
|
+
raise "** internal error"
|
288
500
|
end
|
501
|
+
return pat1, pat2
|
289
502
|
end
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
return Regexp.escape(urlpath_pat), nil
|
294
|
-
else
|
295
|
-
s << Regexp.escape(urlpath_pat[pos..-1])
|
296
|
-
return s, param_names
|
503
|
+
|
504
|
+
def _param2rexp(param)
|
505
|
+
return @router.param2rexp(param) # ex: '[^./?]+'
|
297
506
|
end
|
298
|
-
end
|
299
507
|
|
300
|
-
|
301
|
-
#; [!syrdh] returns Range object when urlpath_pattern contains just one param.
|
302
|
-
#; [!skh4z] returns nil when urlpath_pattern contains more than two params.
|
303
|
-
#; [!acj5b] returns nil when urlpath_pattern contains no params.
|
304
|
-
rexp = /:\w+|\(.*?\)/
|
305
|
-
arr = urlpath_pattern.split(rexp, -1) # ex: ['/books/', '/edit']
|
306
|
-
return nil unless arr.length == 2
|
307
|
-
return (arr[0].length .. -(arr[1].length+1)) # ex: 7..-6 (Range object)
|
308
|
-
end
|
508
|
+
public
|
309
509
|
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
510
|
+
def build_rexp(tree, &callback)
|
511
|
+
#; [!65yw6] converts nested dict into regexp.
|
512
|
+
sb = ['\A']
|
513
|
+
_build_rexp(tree, sb, &callback)
|
514
|
+
sb << '\z'
|
515
|
+
return Regexp.compile(sb.join())
|
516
|
+
end
|
517
|
+
|
518
|
+
private
|
519
|
+
|
520
|
+
def _build_rexp(nested_dict, sb, &b)
|
521
|
+
#; [!hs7vl] '(?:)' and '|' are added only if necessary.
|
522
|
+
sb << '(?:' if nested_dict.length > 1
|
523
|
+
nested_dict.each_with_index do |(k, v), i|
|
524
|
+
sb << '|' if i > 0
|
525
|
+
#; [!7v7yo] nil key means leaf node and yields block argument.
|
526
|
+
#; [!hda6m] string key should be escaped.
|
527
|
+
#; [!b9hxc] symbol key means regexp string.
|
528
|
+
case k
|
529
|
+
when nil ; sb << '(\z)' ; yield v
|
530
|
+
when String ; sb << Regexp.escape(k) ; _build_rexp(v, sb, &b)
|
531
|
+
when Symbol ; sb << k.to_s ; _build_rexp(v, sb, &b)
|
532
|
+
else ; raise "** internal error"
|
533
|
+
end
|
534
|
+
end
|
535
|
+
sb << ')' if nested_dict.length > 1
|
536
|
+
end
|
537
|
+
|
538
|
+
def _range_of_urlpath_param(urlpath_pattern) # ex: '/books/:id/edit'
|
539
|
+
#; [!syrdh] returns Range object when urlpath_pattern contains just one param.
|
540
|
+
#; [!skh4z] returns nil when urlpath_pattern contains more than two params.
|
541
|
+
#; [!acj5b] returns nil when urlpath_pattern contains no params.
|
542
|
+
rexp = /:\w+|\(.*?\)/
|
543
|
+
arr = urlpath_pattern.split(rexp, -1) # ex: ['/books/', '/edit']
|
544
|
+
return nil unless arr.length == 2
|
545
|
+
return (arr[0].length .. -(arr[1].length+1)) # ex: 7..-6 (Range object)
|
320
546
|
end
|
547
|
+
|
321
548
|
end
|
322
549
|
|
323
|
-
#; [!haggu] contains available request methods.
|
324
|
-
REQUEST_METHODS = %w[GET POST PUT DELETE PATCH HEAD OPTIONS TRACE LINK UNLINK] \
|
325
|
-
.each_with_object({}) {|s, d| d[s] = s.intern }
|
326
550
|
|
327
551
|
end
|
328
552
|
|