rack-jet_router 1.2.0 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,8 +1,9 @@
1
1
  # -*- coding: utf-8 -*-
2
+ # frozen_string_literal: true
2
3
 
3
4
  ###
4
- ### $Release: 1.2.0 $
5
- ### $Copyright: copyright(c) 2015 kuwata-lab.com all rights reserved $
5
+ ### $Release: 1.3.0 $
6
+ ### $Copyright: copyright(c) 2015 kwatch@gmail.com $
6
7
  ### $License: MIT License $
7
8
  ###
8
9
 
@@ -15,60 +16,139 @@ module Rack
15
16
  ##
16
17
  ## Jet-speed router class, derived from Keight.rb.
17
18
  ##
18
- ## ex:
19
- ## urlpath_mapping = [
20
- ## ['/' , welcome_app],
21
- ## ['/api', [
22
- ## ['/books', [
23
- ## ['' , books_api],
24
- ## ['/:id(.:format)' , book_api],
25
- ## ['/:book_id/comments/:comment_id', comment_api],
26
- ## ]],
27
- ## ]],
28
- ## ['/admin', [
29
- ## ['/books' , admin_books_app],
30
- ## ]],
31
- ## ]
32
- ## router = Rack::JetRouter.new(urlpath_mapping)
33
- ## router.lookup('/api/books/123.html')
34
- ## #=> [book_api, {"id"=>"123", "format"=>"html"}]
35
- ## status, headers, body = router.call(env)
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
- ## ### or:
38
- ## urlpath_mapping = [
39
- ## ['/' , {GET: welcome_app}],
40
- ## ['/api', [
41
- ## ['/books', [
42
- ## ['' , {GET: book_list_api, POST: book_create_api}],
43
- ## ['/:id(.:format)' , {GET: book_show_api, PUT: book_update_api}],
44
- ## ['/:book_id/comments/:comment_id', {POST: comment_create_api}],
45
- ## ]],
46
- ## ]],
47
- ## ['/admin', [
48
- ## ['/books' , {ANY: admin_books_app}],
49
- ## ]],
50
- ## ]
51
- ## router = Rack::JetRouter.new(urlpath_mapping)
52
- ## router.lookup('/api/books/123')
53
- ## #=> [{"GET"=>book_show_api, "PUT"=>book_update_api}, {"id"=>"123", "format"=>nil}]
54
- ## status, headers, body = router.call(env)
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.2.0 $'.split()[1]
59
-
60
- def initialize(mapping, urlpath_cache_size: 0,
61
- enable_urlpath_param_range: true)
62
- @enable_urlpath_param_range = enable_urlpath_param_range
63
- #; [!u2ff4] compiles urlpath mapping.
64
- (@urlpath_rexp, # ex: {'/api/books'=>BooksApp}
65
- @fixed_urlpath_dict, # ex: [[%r'\A/api/books/([^./]+)\z', ['id'], BookApp]]
66
- @variable_urlpath_list, # ex: %r'\A(?:/api(?:/books(?:/[^./]+(\z))))\z'
67
- @all_entrypoints, # ex: [['/api/books', BooksAPI'], ['/api/orders', OrdersAPI]]
68
- ) = compile_mapping(mapping)
69
- ## cache for variable urlpath (= containg urlpath parameters)
70
- @urlpath_cache_size = urlpath_cache_size
71
- @variable_urlpath_cache = urlpath_cache_size > 0 ? {} : nil
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
72
152
  end
73
153
 
74
154
  attr_reader :urlpath_rexp
@@ -77,13 +157,13 @@ module Rack
77
157
  def call(env)
78
158
  #; [!fpw8x] finds mapped app according to env['PATH_INFO'].
79
159
  req_path = env['PATH_INFO']
80
- app, urlpath_params = lookup(req_path)
160
+ obj, param_values = lookup(req_path)
81
161
  #; [!wxt2g] guesses correct urlpath and redirects to it automaticaly when request path not found.
82
162
  #; [!3vsua] doesn't redict automatically when request path is '/'.
83
- if ! app && should_redirect?(env)
84
- location = req_path =~ /\/\z/ ? req_path[0..-2] : req_path + '/'
85
- app, urlpath_params = lookup(location)
86
- if app
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
87
167
  #; [!hyk62] adds QUERY_STRING to redirect location.
88
168
  qs = env['QUERY_STRING']
89
169
  location = "#{location}?#{qs}" if qs && ! qs.empty?
@@ -91,20 +171,22 @@ module Rack
91
171
  end
92
172
  end
93
173
  #; [!30x0k] returns 404 when request urlpath not found.
94
- return error_not_found(env) unless app
174
+ return error_not_found(env) unless obj
95
175
  #; [!gclbs] if mapped object is a Hash...
96
- if app.is_a?(Hash)
176
+ if obj.is_a?(Hash)
97
177
  #; [!p1fzn] invokes app mapped to request method.
98
178
  #; [!5m64a] returns 405 when request method is not allowed.
99
179
  #; [!ys1e2] uses GET method when HEAD is not mapped.
100
180
  #; [!2hx6j] try ANY method when request method is not mapped.
101
- dict = app
181
+ dict = obj
102
182
  req_meth = env['REQUEST_METHOD']
103
183
  app = dict[req_meth] || (req_meth == 'HEAD' ? dict['GET'] : nil) || dict['ANY']
104
184
  return error_not_allowed(env) unless app
185
+ else
186
+ app = obj
105
187
  end
106
- #; [!2c32f] stores urlpath parameters as env['rack.urlpath_params'].
107
- store_urlpath_params(env, urlpath_params)
188
+ #; [!2c32f] stores urlpath parameter values into env['rack.urlpath_params'].
189
+ store_param_values(env, param_values)
108
190
  #; [!hse47] invokes app mapped to request urlpath.
109
191
  return app.call(env) # make body empty when HEAD?
110
192
  end
@@ -116,11 +198,11 @@ module Rack
116
198
  def lookup(req_path)
117
199
  #; [!24khb] finds in fixed urlpaths at first.
118
200
  #; [!iwyzd] urlpath param value is nil when found in fixed urlpaths.
119
- obj = @fixed_urlpath_dict[req_path]
201
+ obj = @fixed_endpoints[req_path]
120
202
  return obj, nil if obj
121
203
  #; [!upacd] finds in variable urlpath cache if it is enabled.
122
204
  #; [!1zx7t] variable urlpath cache is based on LRU.
123
- cache = @variable_urlpath_cache
205
+ cache = @cache_dict
124
206
  if cache && (pair = cache.delete(req_path))
125
207
  cache[req_path] = pair
126
208
  return pair
@@ -128,25 +210,25 @@ module Rack
128
210
  #; [!vpdzn] returns nil when urlpath not found.
129
211
  m = @urlpath_rexp.match(req_path)
130
212
  return nil unless m
131
- index = m.captures.find_index('')
213
+ index = m.captures.index('')
132
214
  return nil unless index
133
215
  #; [!ijqws] returns mapped object and urlpath parameter values when urlpath found.
134
- full_urlpath_rexp, param_names, obj, range = @variable_urlpath_list[index]
216
+ full_urlpath_rexp, param_names, obj, range = @variable_endpoints[index]
135
217
  if range
136
- ## "/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]
137
219
  str = req_path[range]
138
- param_values = [str]
220
+ values = [str]
139
221
  else
140
222
  m = full_urlpath_rexp.match(req_path)
141
- param_values = m.captures
223
+ values = m.captures
142
224
  end
143
- vars = build_urlpath_parameter_vars(param_names, param_values)
225
+ param_values = build_param_values(param_names, values)
144
226
  #; [!84inr] caches result when variable urlpath cache enabled.
145
227
  if cache
146
- cache.shift() if cache.length >= @urlpath_cache_size
147
- cache[req_path] = [obj, vars]
228
+ cache.shift() if cache.length >= @cache_size
229
+ cache[req_path] = [obj, param_values]
148
230
  end
149
- return obj, vars
231
+ return obj, param_values
150
232
  end
151
233
 
152
234
  alias find lookup # :nodoc: # for backward compatilibity
@@ -154,7 +236,7 @@ module Rack
154
236
  ## Yields pair of urlpath pattern and app.
155
237
  def each(&block)
156
238
  #; [!ep0pw] yields pair of urlpath pattern and app.
157
- @all_entrypoints.each(&block)
239
+ @all_endpoints.each(&block)
158
240
  end
159
241
 
160
242
  protected
@@ -185,170 +267,286 @@ module Rack
185
267
 
186
268
  ## Returns [301, {"Location"=>location, ...}, [...]]. Override in subclass if necessary.
187
269
  def redirect_to(location)
270
+ #; [!9z57v] returns 301 and 'Location' header.
188
271
  content = "Redirect to #{location}"
189
272
  return [301, {"Content-Type"=>"text/plain", "Location"=>location}, [content]]
190
273
  end
191
274
 
192
- ## Sets env['rack.urlpath_params'] = vars. Override in subclass if necessary.
193
- def store_urlpath_params(env, vars)
194
- env['rack.urlpath_params'] = vars if vars
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
195
280
  end
196
281
 
197
- ## Returns Hash object representing urlpath parameters. Override if necessary.
198
- ##
199
- ## ex:
200
- ## class MyRouter < JetRouter
201
- ## def build_urlpath_parameter_vars(names, values)
202
- ## return names.zip(values).each_with_object({}) {|(k, v), d|
203
- ## ## converts urlpath pavam value into integer
204
- ## v = v.to_i if k == 'id' || k.end_with?('_id')
205
- ## d[k] = v
206
- ## }
207
- ## end
208
- ## end
209
- def build_urlpath_parameter_vars(names, values)
210
- return Hash[names.zip(values)]
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
211
297
  end
212
298
 
213
- private
299
+ public
214
300
 
215
- ## Compiles urlpath mapping. Called from '#initialize()'.
216
- def compile_mapping(mapping)
217
- ## entry points which has no urlpath parameters
218
- ## ex:
219
- ## { '/' => HomeApp,
220
- ## '/api/books' => BooksApp,
221
- ## '/api/authors => AuthorsApp,
222
- ## }
223
- dict = {}
224
- ## entry points which has one or more urlpath parameters
225
- ## ex:
226
- ## [
227
- ## [%r!\A/api/books/([^./]+)\z!, ["id"], BookApp, (11..-1)],
228
- ## [%r!\A/api/authors/([^./]+)\z!, ["id"], AuthorApp, (12..-1)],
229
- ## ]
230
- list = []
231
- #
232
- all = []
233
- rexp_str = _compile_mapping(mapping, "", "") do |entry_point|
234
- obj, urlpath_pat, urlpath_rexp, param_names = entry_point
235
- all << [urlpath_pat, obj]
236
- if urlpath_rexp
237
- range = @enable_urlpath_param_range ? range_of_urlpath_param(urlpath_pat) : nil
238
- list << [urlpath_rexp, param_names, obj, range]
239
- else
240
- dict[urlpath_pat] = obj
241
- end
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
242
315
  end
243
- ## ex: %r!^A(?:api(?:/books/[^./]+(\z)|/authors/[^./]+(\z)))\z!
244
- urlpath_rexp = Regexp.new("\\A#{rexp_str}\\z")
245
- #; [!xzo7k] returns regexp, hash, and array.
246
- return urlpath_rexp, dict, list, all
316
+ return newdict
247
317
  end
248
318
 
249
- def _compile_mapping(mapping, base_urlpath, parent_urlpath, &block)
250
- arr = []
251
- mapping.each do |urlpath, obj|
252
- full_urlpath = "#{base_urlpath}#{urlpath}"
253
- #; [!ospaf] accepts nested mapping.
254
- if obj.is_a?(Array)
255
- rexp_str = _compile_mapping(obj, full_urlpath, urlpath, &block)
256
- #; [!2ktpf] handles end-point.
257
- else
258
- #; [!guhdc] if mapping dict is specified...
259
- if obj.is_a?(Hash)
260
- obj = normalize_mapping_keys(obj)
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
355
+ end
356
+ end
357
+
358
+ def _normalize_method_mapping(dict)
359
+ return @router.normalize_method_mapping(dict)
360
+ end
361
+
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=='([^./?]+)'
261
390
  end
262
- #; [!vfytw] handles urlpath pattern as variable when urlpath param exists.
263
- full_urlpath_rexp_str, param_names = compile_urlpath_pattern(full_urlpath, true)
264
- if param_names # has urlpath params
265
- full_urlpath_rexp = Regexp.new("\\A#{full_urlpath_rexp_str}\\z")
266
- rexp_str, _ = compile_urlpath_pattern(urlpath, false)
267
- rexp_str << '(\z)'
268
- entry_point = [obj, full_urlpath, full_urlpath_rexp, param_names]
269
- #; [!l63vu] handles urlpath pattern as fixed when no urlpath params.
270
- else # has no urlpath params
271
- entry_point = [obj, full_urlpath, nil, nil]
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'
272
396
  end
273
- yield entry_point
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]
274
402
  end
275
- arr << rexp_str if rexp_str
403
+ return tree
404
+ end
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
455
+ end
456
+
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
276
471
  end
277
- #; [!pv2au] deletes unnecessary urlpath regexp.
278
- return nil if arr.empty?
279
- #; [!bh9lo] deletes unnecessary grouping.
280
- parent_urlpath_rexp_str, _ = compile_urlpath_pattern(parent_urlpath, false)
281
- return "#{parent_urlpath_rexp_str}#{arr[0]}" if arr.length == 1
282
- #; [!iza1g] adds grouping if necessary.
283
- return "#{parent_urlpath_rexp_str}(?:#{arr.join('|')})"
284
- end
285
472
 
286
- ## Compiles '/books/:id' into ['/books/([^./]+)', ["id"]].
287
- def compile_urlpath_pattern(urlpath_pat, enable_capture=true)
288
- s = "".dup()
289
- param_pat = enable_capture ? '([^./]+)' : '[^./]+'
290
- param_names = []
291
- pos = 0
292
- urlpath_pat.scan(/:(\w+)|\((.*?)\)/) do |name, optional|
293
- #; [!joozm] escapes metachars with backslash in text part.
294
- m = Regexp.last_match
295
- text = urlpath_pat[pos...m.begin(0)]
296
- pos = m.end(0)
297
- s << Regexp.escape(text)
298
- #; [!rpezs] converts '/books/:id' into '/books/([^./]+)'.
299
- if name
300
- param_names << name
301
- s << param_pat
302
- #; [!4dcsa] converts '/index(.:format)' into '/index(?:\.([^./]+))?'.
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.
303
486
  elsif optional
304
- s << '(?:'
305
- optional.scan(/(.*?)(?::(\w+))/) do |text2, name2|
306
- s << Regexp.escape(text2) << param_pat
307
- param_names << name2
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_
308
492
  end
309
- s << Regexp.escape($' || optional)
310
- s << ')?'
311
- #
493
+ sb << Regexp.escape($' || optional)
494
+ sb << ')?'
495
+ s = sb.join()
496
+ pat1 = s.gsub('<<', '' ).gsub('>>', '' ) # ex: '(?:\.[^./?]+)?'
497
+ pat2 = s.gsub('<<', '(').gsub('>>', ')') # ex: '(?:\.([^./?]+))?'
312
498
  else
313
- raise "unreachable: urlpath=#{urlpath.inspect}"
499
+ raise "** internal error"
314
500
  end
501
+ return pat1, pat2
315
502
  end
316
- #; [!1d5ya] rethrns compiled string and nil when no urlpath parameters nor parens.
317
- #; [!of1zq] returns compiled string and urlpath param names when urlpath param or parens exist.
318
- if pos == 0
319
- return Regexp.escape(urlpath_pat), nil
320
- else
321
- s << Regexp.escape(urlpath_pat[pos..-1])
322
- return s, param_names
503
+
504
+ def _param2rexp(param)
505
+ return @router.param2rexp(param) # ex: '[^./?]+'
323
506
  end
324
- end
325
507
 
326
- def range_of_urlpath_param(urlpath_pattern) # ex: '/books/:id/edit'
327
- #; [!syrdh] returns Range object when urlpath_pattern contains just one param.
328
- #; [!skh4z] returns nil when urlpath_pattern contains more than two params.
329
- #; [!acj5b] returns nil when urlpath_pattern contains no params.
330
- rexp = /:\w+|\(.*?\)/
331
- arr = urlpath_pattern.split(rexp, -1) # ex: ['/books/', '/edit']
332
- return nil unless arr.length == 2
333
- return (arr[0].length .. -(arr[1].length+1)) # ex: 7..-6 (Range object)
334
- end
508
+ public
335
509
 
336
- def normalize_mapping_keys(dict)
337
- #; [!r7cmk] converts keys into string.
338
- #; [!z9kww] allows 'ANY' as request method.
339
- #; [!k7sme] raises error when unknown request method specified.
340
- request_methods = REQUEST_METHODS
341
- return dict.each_with_object({}) do |(meth, app), newdict|
342
- meth_str = meth.to_s
343
- request_methods[meth_str] || meth_str == 'ANY' or
344
- raise ArgumentError.new("#{meth.inspect}: unknown request method.")
345
- newdict[meth_str] = app
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())
346
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)
546
+ end
547
+
347
548
  end
348
549
 
349
- #; [!haggu] contains available request methods.
350
- REQUEST_METHODS = %w[GET POST PUT DELETE PATCH HEAD OPTIONS TRACE LINK UNLINK] \
351
- .each_with_object({}) {|s, d| d[s] = s.intern }
352
550
 
353
551
  end
354
552