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.
@@ -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