rack-jet_router 1.1.1 → 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.1.1 $
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,88 +16,177 @@ 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.1.1 $'.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
- ) = compile_mapping(mapping)
68
- ## cache for variable urlpath (= containg urlpath parameters)
69
- @urlpath_cache_size = urlpath_cache_size
70
- @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
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
- app, urlpath_params = lookup(req_path)
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
- unless app || req_path == '/'
81
- location = req_path =~ /\/\z/ ? req_path[0..-2] : req_path + '/'
82
- app, urlpath_params = lookup(location)
83
- return redirect_to(location) 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
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 app
174
+ return error_not_found(env) unless obj
87
175
  #; [!gclbs] if mapped object is a Hash...
88
- if app.is_a?(Hash)
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 = app
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 parameters as env['rack.urlpath_params'].
99
- store_urlpath_params(env, urlpath_params)
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 = @fixed_urlpath_dict[req_path]
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 = @variable_urlpath_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.find_index('')
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 = @variable_urlpath_list[index]
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
- param_values = [str]
220
+ values = [str]
131
221
  else
132
222
  m = full_urlpath_rexp.match(req_path)
133
- param_values = m.captures
223
+ values = m.captures
134
224
  end
135
- vars = build_urlpath_parameter_vars(param_names, param_values)
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 >= @urlpath_cache_size
139
- cache[req_path] = [obj, vars]
228
+ cache.shift() if cache.length >= @cache_size
229
+ cache[req_path] = [obj, param_values]
140
230
  end
141
- return obj, vars
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
- ## Sets env['rack.urlpath_params'] = vars. Override in subclass if necessary.
167
- def store_urlpath_params(env, vars)
168
- 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
169
280
  end
170
281
 
171
- ## Returns Hash object representing urlpath parameters. Override if necessary.
172
- ##
173
- ## ex:
174
- ## class MyRouter < JetRouter
175
- ## def build_urlpath_parameter_vars(names, values)
176
- ## return names.zip(values).each_with_object({}) {|(k, v), d|
177
- ## ## converts urlpath pavam value into integer
178
- ## v = v.to_i if k == 'id' || k.end_with?('_id')
179
- ## d[k] = v
180
- ## }
181
- ## end
182
- ## end
183
- def build_urlpath_parameter_vars(names, values)
184
- 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
185
297
  end
186
298
 
187
- private
188
-
189
- ## Compiles urlpath mapping. Called from '#initialize()'.
190
- def compile_mapping(mapping)
191
- rexp_buf = ['\A']
192
- fixed_urlpaths = {} # ex: {'/api/books'=>BooksApp}
193
- variable_urlpaths = [] # ex: [[%r'\A/api/books/([^./]+)\z', ['id'], BookApp]]
194
- _compile_array(mapping, rexp_buf, '', '',
195
- fixed_urlpaths, variable_urlpaths)
196
- ## ex: %r'\A(?:/api(?:/books(?:/[^./]+(\z)|/[^./]+/edit(\z))))\z'
197
- rexp_buf << '\z'
198
- urlpath_rexp = Regexp.new(rexp_buf.join())
199
- #; [!xzo7k] returns regexp, hash, and array.
200
- return urlpath_rexp, fixed_urlpaths, variable_urlpaths
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
- def _compile_array(mapping, rexp_buf, base_urlpath_pat, urlpath_pat,
204
- fixed_dict, variable_list)
205
- rexp_str, _ = compile_urlpath_pattern(urlpath_pat, false)
206
- rexp_buf << rexp_str
207
- rexp_buf << '(?:'
208
- len = rexp_buf.length
209
- mapping.each do |child_urlpath_pat, obj|
210
- rexp_buf << '|' if rexp_buf.length != len
211
- curr_urlpath_pat = "#{base_urlpath_pat}#{urlpath_pat}"
212
- #; [!ospaf] accepts nested mapping.
213
- if obj.is_a?(Array)
214
- _compile_array(obj, rexp_buf, curr_urlpath_pat, child_urlpath_pat,
215
- fixed_dict, variable_list)
216
- #; [!2ktpf] handles end-point.
217
- else
218
- _compile_object(obj, rexp_buf, curr_urlpath_pat, child_urlpath_pat,
219
- fixed_dict, variable_list)
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
- #; [!gfxgr] deletes unnecessary grouping.
223
- if rexp_buf.length == len
224
- x = rexp_buf.pop() # delete '(?:'
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
- def _compile_object(obj, rexp_buf, base_urlpath_pat, urlpath_pat,
239
- fixed_dict, variable_list)
240
- #; [!guhdc] if mapping dict is specified...
241
- if obj.is_a?(Hash)
242
- obj = normalize_mapping_keys(obj)
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
- #; [!l63vu] handles urlpath pattern as fixed when no urlpath params.
245
- full_urlpath_pat = "#{base_urlpath_pat}#{urlpath_pat}"
246
- full_urlpath_rexp_str, param_names = compile_urlpath_pattern(full_urlpath_pat, true)
247
- fixed_pattern = param_names.nil?
248
- if fixed_pattern
249
- fixed_dict[full_urlpath_pat] = obj
250
- #; [!vfytw] handles urlpath pattern as variable when urlpath param exists.
251
- else
252
- rexp_str, _ = compile_urlpath_pattern(urlpath_pat, false)
253
- rexp_buf << (rexp_str << '(\z)')
254
- full_urlpath_rexp = Regexp.new("\\A#{full_urlpath_rexp_str}\\z")
255
- range = @enable_urlpath_param_range ? range_of_urlpath_param(full_urlpath_pat) : nil
256
- variable_list << [full_urlpath_rexp, param_names, obj, range]
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
- ## Compiles '/books/:id' into ['/books/([^./]+)', ["id"]].
261
- def compile_urlpath_pattern(urlpath_pat, enable_capture=true)
262
- s = "".dup()
263
- param_pat = enable_capture ? '([^./]+)' : '[^./]+'
264
- param_names = []
265
- pos = 0
266
- urlpath_pat.scan(/:(\w+)|\((.*?)\)/) do |name, optional|
267
- #; [!joozm] escapes metachars with backslash in text part.
268
- m = Regexp.last_match
269
- text = urlpath_pat[pos...m.begin(0)]
270
- pos = m.end(0)
271
- s << Regexp.escape(text)
272
- #; [!rpezs] converts '/books/:id' into '/books/([^./]+)'.
273
- if name
274
- param_names << name
275
- s << param_pat
276
- #; [!4dcsa] converts '/index(.:format)' into '/index(?:\.([^./]+))?'.
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
- s << '(?:'
279
- optional.scan(/(.*?)(?::(\w+))/) do |text2, name2|
280
- s << Regexp.escape(text2) << param_pat
281
- 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_
282
492
  end
283
- s << Regexp.escape($' || optional)
284
- s << ')?'
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 "unreachable: urlpath=#{urlpath.inspect}"
499
+ raise "** internal error"
288
500
  end
501
+ return pat1, pat2
289
502
  end
290
- #; [!1d5ya] rethrns compiled string and nil when no urlpath parameters nor parens.
291
- #; [!of1zq] returns compiled string and urlpath param names when urlpath param or parens exist.
292
- if pos == 0
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
- def range_of_urlpath_param(urlpath_pattern) # ex: '/books/:id/edit'
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
- def normalize_mapping_keys(dict)
311
- #; [!r7cmk] converts keys into string.
312
- #; [!z9kww] allows 'ANY' as request method.
313
- #; [!k7sme] raises error when unknown request method specified.
314
- request_methods = REQUEST_METHODS
315
- return dict.each_with_object({}) do |(meth, app), newdict|
316
- meth_str = meth.to_s
317
- request_methods[meth_str] || meth_str == 'ANY' or
318
- raise ArgumentError.new("#{meth.inspect}: unknown request method.")
319
- 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())
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