rack-jet_router 1.2.0 → 1.3.1

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.
@@ -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.1 $
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.1 $'.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,288 @@ 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
+ #; [!93itq] returns nil if urlpath pattern includes optional parameters.
540
+ return nil if urlpath_pattern =~ /\(/
541
+ #; [!syrdh] returns Range object when urlpath_pattern contains just one param.
542
+ #; [!skh4z] returns nil when urlpath_pattern contains more than two params.
543
+ #; [!acj5b] returns nil when urlpath_pattern contains no params.
544
+ rexp = /:\w+/
545
+ arr = urlpath_pattern.split(rexp, -1) # ex: ['/books/', '/edit']
546
+ return nil unless arr.length == 2
547
+ return (arr[0].length .. -(arr[1].length+1)) # ex: 7..-6 (Range object)
548
+ end
549
+
347
550
  end
348
551
 
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
552
 
353
553
  end
354
554