keight 0.1.0 → 0.2.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,13 +1,13 @@
1
1
  ###
2
- ### $Release: 0.1.0 $
3
- ### $Copyright: copyright(c) 2014-2015 kuwata-lab.com all rights reserved $
2
+ ### $Release: 0.2.0 $
3
+ ### $Copyright: copyright(c) 2014-2016 kuwata-lab.com all rights reserved $
4
4
  ### $License: MIT License $
5
5
  ###
6
6
 
7
7
 
8
8
  module Benchmarker
9
9
 
10
- VERSION = "$Release: 0.1.0 $".split(/ /)[1]
10
+ VERSION = "$Release: 0.2.0 $".split(/ /)[1]
11
11
 
12
12
  def self.new(opts={}, &block)
13
13
  #: creates runner object and returns it.
@@ -240,6 +240,8 @@ END
240
240
  attr_accessor :label, :loop, :user, :sys, :total, :real
241
241
 
242
242
  def run
243
+ #: starts GC before running benchmark.
244
+ GC.start
243
245
  #: yields block for @loop times.
244
246
  ntimes = @loop || 1
245
247
  pt1 = Process.times
data/bin/k8rb CHANGED
@@ -2,14 +2,12 @@
2
2
  # -*- coding: utf-8 -*-
3
3
 
4
4
  ###
5
- ### $Release: 0.1.0 $
6
- ### $Copyright: copyright(c) 2014-2015 kuwata-lab.com all rights reserved $
5
+ ### $Release: 0.2.0 $
6
+ ### $Copyright: copyright(c) 2014-2016 kuwata-lab.com all rights reserved $
7
7
  ### $License: MIT License $
8
8
  ###
9
9
 
10
10
 
11
- require 'digest/sha1'
12
-
13
11
  require 'keight'
14
12
  require 'baby_erubis'
15
13
 
@@ -22,15 +20,16 @@ module K8
22
20
  @__current = nil
23
21
  @__actions = []
24
22
 
25
- @action = proc do |action_name, desc|
23
+ @action = proc do |action_name, desc, dict|
26
24
  options = []
27
25
  method_name = nil
28
- tuple = [action_name, desc, options, method_name]
26
+ (dict ||= {})[:desc] = desc
27
+ tuple = [action_name, dict, options, method_name]
29
28
  @__current = tuple
30
29
  @__actions << tuple
31
30
  end
32
31
 
33
- @option = proc do |optdef_str, desc|
32
+ @option = proc do |optdef_str, desc, opts|
34
33
  tuple = @__current or
35
34
  raise "@option.() is called without @action.()"
36
35
  tuple[2] << [optdef_str, desc]
@@ -72,7 +71,8 @@ module K8
72
71
  sb << " #{script} ACTION [..OPTIONS..]"
73
72
  sb << ""
74
73
  sb << "Actions:"
75
- self.class.each_action do |action_name, desc, options, _|
74
+ self.class.each_action do |action_name, dict, options, _|
75
+ desc = dict[:desc]
76
76
  next unless desc
77
77
  help = desc.lines.first.chomp()
78
78
  sb << " #{script} %-15s # #{help}" % [action_name, help]
@@ -82,7 +82,7 @@ module K8
82
82
  else
83
83
  tuple = self.class.find_action(action_name) or
84
84
  raise K8::OptionError.new("help #{action_name}: no such action.")
85
- action_name, desc, options, method_name = tuple
85
+ action_name, dict, options, method_name = tuple
86
86
  pnames = self.method(method_name).parameters.collect {|kind, pname|
87
87
  case kind
88
88
  when :req ; pname
@@ -95,7 +95,7 @@ module K8
95
95
  }.compact()
96
96
  pnames.unshift('') unless pnames.empty?
97
97
  argstr = pnames.join(' ')
98
- sb << "#{script} #{action_name} - #{desc}"
98
+ sb << "#{script} #{action_name} - #{dict[:desc]}"
99
99
  sb << ""
100
100
  sb << "Usage:"
101
101
  if options.empty?
@@ -109,15 +109,21 @@ module K8
109
109
  sb << " %-20s # %s" % [optstr, optdesc] if optdesc
110
110
  end
111
111
  end
112
+ if dict[:example]
113
+ sb << ""
114
+ sb << "Examples:"
115
+ sb << dict[:example].gsub(/%SCRIPT%/, script)
116
+ end
112
117
  end
113
118
  sb << ""
114
119
  sb << ""
115
120
  return sb.join("\n")
116
121
  end
117
122
 
118
- @action.(:init, "create project files")
123
+ @action.(:project, "create project files")
119
124
  @option.("-s, --skeleton=DIR", "directory of skeleton files")
120
- def do_init(project_name, dir: nil)
125
+ def do_project(name, dir: nil)
126
+ project_name = name
121
127
  dir ||= K8::FILEPATH.sub(/\.rb\z/, '') + "/skeleton"
122
128
  if dir
123
129
  File.directory?(dir) or
@@ -137,30 +143,139 @@ module K8
137
143
  o.mkdir("#{project_name}/#{base}", descs[base+"/"])
138
144
  elsif File.file?(path)
139
145
  content = path =~ /\Astatic\/lib|\.gz\z/ \
140
- ? File.read(path, encoding: 'ascii-8bit') \
141
- : K8::SkeletonTemplate.new.from_file(path, 'ascii-8bit').render()
146
+ ? File.read(path, encoding: 'ascii-8bit') \
147
+ : K8::SkeletonTemplate.new.from_file(path, 'ascii-8bit').render()
142
148
  o.mkfile("#{project_name}/#{base}", content, descs[base])
143
149
  else
144
150
  next
145
151
  end
146
152
  end
147
- return
153
+ #
154
+ msg = <<-END
155
+
156
+ ##
157
+ ## File copied.
158
+ ## Please run 'rake setup' in '#{project_name}' directory, for example:
159
+ ##
160
+ ## $ cd #{project_name}
161
+ ## $ rake setup
162
+ ## $ export APP_ENV='dev' # or 'prod', 'stg', 'test'
163
+ ## $ rake server port=8000
164
+ ## $ open http://localhost:8000/
165
+ ##
166
+ END
167
+ return msg.gsub(/^ /, '')
148
168
  end
149
169
  0
150
170
  end
151
171
 
152
172
  @action.(:mapping, "show action mappings")
153
- def do_mapping()
173
+ @option.("--format={text|json|yaml|javascript|jquery|angular}", "output format")
174
+ def do_mapping(format: nil)
154
175
  ENV['APP_MODE'] ||= 'dev'
155
176
  load_config()
156
177
  require './config/urlpath_mapping'
157
- app = K8::RackApplication.new
158
- $urlpath_mapping.each do |urlpath, children|
159
- app.mount(urlpath, children)
178
+ return \
179
+ case format
180
+ when nil ; _do_mapping_in_text_format()
181
+ when 'text' ; _do_mapping_in_text_format()
182
+ when 'json' ; _do_mapping_in_json_format()
183
+ when 'yaml' ; _do_mapping_in_yaml_format()
184
+ when 'javascript'; _do_mapping_in_javascript_format(nil)
185
+ when 'jquery' ; _do_mapping_in_javascript_format('type')
186
+ when 'angular' ; _do_mapping_in_javascript_format('method')
187
+ else
188
+ raise OptionError.new("#{format}: expected one of text/json/yaml/javascript/jquery/angular.")
189
+ end
190
+ end
191
+
192
+ private
193
+
194
+ def _do_mapping(&block)
195
+ app = K8::RackApplication.new($urlpath_mapping)
196
+ app.each_mapping do |urlpath_pat, action_class, action_methods, |
197
+ yield urlpath_pat, action_class, action_methods
198
+ end
199
+ end
200
+ private :_do_mapping
201
+
202
+ def _do_mapping_in_json_format()
203
+ s = %Q`{ "mappings": [\n`.dup()
204
+ i = 0
205
+ _do_mapping do |urlpath_pat, action_class, action_methods|
206
+ i += 1
207
+ arr = action_methods.map {|k, v| %Q`"#{k}": "#{v}"` }
208
+ s << (i == 1 ? ' ' : ', ')
209
+ s << %Q`{"urlpath": "#{urlpath_pat}"`
210
+ s << %Q`, "class": \"#{action_class.name}"`
211
+ s << %Q`, "method": {#{arr.join(', ')}}}\n`
212
+ end
213
+ s << "] }\n"
214
+ return s
215
+ end
216
+
217
+ def _do_mapping_in_yaml_format()
218
+ s = ""
219
+ _do_mapping do |urlpath_pat, action_class, action_methods|
220
+ arr = action_methods.map {|k, v| "#{k}: #{v}" }
221
+ s << "- urlpath: '#{urlpath_pat}'\n"
222
+ s << " class: #{action_class.name}\n"
223
+ s << " method: {#{arr.join(', ')}}\n"
224
+ s << "\n"
225
+ end
226
+ return s
227
+ end
228
+
229
+ def _do_mapping_in_javascript_format(attr)
230
+ rexp = K8::ActionMapping::URLPATH_PARAM_REXP
231
+ defs = {}
232
+ _do_mapping do |urlpath_pat, action_class, action_methods|
233
+ (defs[action_class.name] ||= []) << [urlpath_pat, action_methods]
234
+ end
235
+ buf = []
236
+ defs.each do |action_class_name, pairs|
237
+ if action_class_name =~ /\A\w+\z/
238
+ buf << "#{action_class_name}:{\n"
239
+ else
240
+ buf << "'#{action_class_name}':{\n"
241
+ end
242
+ sep = ' '
243
+ pairs.each do |urlpath_pat, action_methods|
244
+ args = []; i = 0
245
+ urlpath_pat.scan(rexp) {|x, _| args << (x.empty? ? "_#{i+=1}" : x) }
246
+ a = args.dup()
247
+ js_urlpath = "'" + urlpath_pat.gsub(rexp) {|x, _| "'+#{a.shift}+'" } + "'"
248
+ js_urlpath.gsub!(/\+''/, '')
249
+ #
250
+ action_methods.each do |req_meth, action_method|
251
+ buf << sep; sep = ','
252
+ if attr
253
+ js_code = "return{#{attr}:'#{req_meth}',url:#{js_urlpath}}"
254
+ buf << "#{action_method}:function(#{args.join(',')}){#{js_code}}\n"
255
+ else
256
+ js_code = "function(#{args.join(',')}){return#{js_urlpath}}"
257
+ buf << "#{action_method}:{method:'#{req_meth}',urlpath:#{js_code}}\n"
258
+ end
259
+ end
260
+ end
261
+ buf << "},\n"
262
+ end
263
+ buf[-1] = "}\n" if buf[-1] == "},\n"
264
+ return buf.join()
265
+ end
266
+
267
+ def _do_mapping_in_text_format()
268
+ s = ""
269
+ _do_mapping do |urlpath_pat, action_class, action_methods|
270
+ action_methods.each do |req_meth, action_method|
271
+ s << "%-6s %-30s # %s\#%s\n" % [req_meth, urlpath_pat, action_class.name, action_method]
272
+ end
160
273
  end
161
- return app.show_mappings()
274
+ return s
162
275
  end
163
276
 
277
+ public
278
+
164
279
  @action.(:configs, "list config keys and values")
165
280
  @option.("--getenv", "show actual ENV value")
166
281
  def do_configs(getenv: false)
@@ -245,6 +360,108 @@ module K8
245
360
  end
246
361
  private :__var_dump
247
362
 
363
+ @action.(:cdnjs, "search or download libraries from cdnjs.com")
364
+ @option.("-d, --basedir=DIR", "base directory (default: 'static/lib')")
365
+ def do_cdnjs(library=nil, version=nil, basedir: nil)
366
+ require 'open-uri'
367
+ require 'fileutils'
368
+ library.nil? || library =~ /\A\*?[-.\w]+\*?\z/ or
369
+ raise OptionError.new("#{library}: invalid library name.")
370
+ version.nil? || version =~ /\A\d+(\.\d+)+[-.\w]+\z/ or
371
+ raise OptionError.new("#{version}: version number expected.")
372
+ ## list or search libraries
373
+ if library.nil? || library.include?('*')
374
+ return _cdnjs_list(library)
375
+ ## list versions
376
+ elsif version.nil?
377
+ return _cdnjs_find(library)
378
+ ## download files
379
+ else
380
+ return _cdnjs_download(library, version, basedir)
381
+ end
382
+ end
383
+
384
+ def _cdnjs_list(pattern)
385
+ url = "https://cdnjs.com/libraries"
386
+ rexp = %r'href="/libraries/([-.\w]+)"'
387
+ libs = open(url) {|f|
388
+ #f.grep(rexp) { $1 } # not work
389
+ f.collect {|s| $1 if s =~ rexp }.compact.sort.uniq
390
+ }
391
+ if pattern
392
+ s = Regexp.escape(pattern) # ex: '*foo.js*' -> '\*foo\.js\*'
393
+ s = s.gsub(/\\\*/, '.*') # ex: '\*foo\.js\*' -> '.*foo\.js.*'
394
+ s = "\\A#{s}\\z" # ex: '.*foo\.js.*' -> '\A.*foo\.js.*\z'
395
+ rexp = Regexp.compile(s)
396
+ libs = libs.grep(rexp)
397
+ end
398
+ return (libs << "").join("\n")
399
+ end
400
+
401
+ def _cdnjs_find(library)
402
+ url = "https://cdnjs.com/libraries/#{library}"
403
+ rexp = %r'<option value="(.*?)".*?>\1</option>'
404
+ versions = open(url) {|f|
405
+ f.collect {|s| $1 if s =~ rexp }.compact()
406
+ }.sort_by {|verstr| _normalized_version(verstr) }
407
+ if versions.empty?
408
+ $stderr.puts "#{library}: no such library (GET #{url})."
409
+ return 1
410
+ end
411
+ return (versions << "").join("\n")
412
+ end
413
+
414
+ def _cdnjs_download(library, version, basedir=nil)
415
+ basedir ||= "static/lib"
416
+ File.exist?(basedir) or
417
+ raise OptionError.new("#{basedir}: directory not exist.")
418
+ File.directory?(basedir) or
419
+ raise OptionError.new("#{basedir}: not a directory.")
420
+ url = "https://cdnjs.com/libraries/#{library}/#{version}"
421
+ rexp = %r">https://cdnjs\.cloudflare\.com/ajax/libs/#{library}/#{Regexp.escape(version)}/([-.\w]+(?:\&\#x2F;[-.\w]+)*)<"
422
+ filenames = open(url) {|f|
423
+ f.collect {|s| $1.gsub(/\&\#x2F;/, '/') if s =~ rexp }.compact()
424
+ }
425
+ if filenames.empty?
426
+ $stderr.puts "#{library} #{version}: no files (GET #{url})."
427
+ return 1
428
+ end
429
+ baseurl = "https://cdnjs.cloudflare.com/ajax/libs"
430
+ filenames.each do |filename|
431
+ fileurl = "#{baseurl}/#{library}/#{version}/#{filename}"
432
+ filepath = "#{basedir}/#{library}/#{version}/#{filename}"
433
+ FileUtils.mkdir_p(File.dirname(filepath))
434
+ print filepath, ' '
435
+ content = open(fileurl) {|f| f.read }
436
+ if _file_changed(filepath, content)
437
+ File.open(filepath, 'w') {|f| f.write(content) }
438
+ s = ""
439
+ else
440
+ s = " (not changed)"
441
+ end
442
+ puts "(#{_bytesize(content)} byte)#{s}"
443
+ end
444
+ return 0
445
+ end
446
+
447
+ def _normalized_version(version_str)
448
+ if version_str =~ /\A(\d+(?:\.\d+)*)(.*)/
449
+ return $1.split('.').map {|n| "%05d" % n.to_i }.join('.') + $2
450
+ else
451
+ return version_str
452
+ end
453
+ end
454
+
455
+ def _file_changed(filepath, expected)
456
+ return true unless File.exist?(filepath)
457
+ actual = File.read(filepath, encoding: expected.encoding)
458
+ return actual != expected
459
+ end
460
+
461
+ def _bytesize(string)
462
+ return string.bytesize.to_s.reverse.gsub(/(...)/, '\1,').sub(/,\z/, '').reverse
463
+ end
464
+
248
465
 
249
466
  if ENV['DEBUG']
250
467
  @action.(:debug, "debug")
@@ -3,7 +3,7 @@
3
3
 
4
4
  Gem::Specification.new do |o|
5
5
  o.name = "keight"
6
- o.version = "$Release: 0.1.0 $".split[1]
6
+ o.version = "$Release: 0.2.0 $".split[1]
7
7
  o.author = "makoto kuwata"
8
8
  o.email = "kwa(at)kuwata-lab.com"
9
9
  o.platform = Gem::Platform::RUBY
@@ -18,13 +18,12 @@ See https://github.com/kwatch/keight/tree/ruby for details.
18
18
  END
19
19
 
20
20
  o.files = Dir[*%w[
21
- README.md MIT-LICENSE keight.gemspec setup.rb Rakefile
21
+ README.md CHANGES.txt MIT-LICENSE keight.gemspec setup.rb Rakefile
22
22
  bin/k8rb
23
23
  lib/keight.rb lib/keight/**/*.{[a-z]*} lib/keight/**/.{[a-z]*}
24
24
  test/*_test.rb test/data/* test/oktest.rb
25
25
  bench/bench.rb bench/benchmarker.rb
26
26
  ]]
27
- #lib/keight.rb lib/keight/**/*
28
27
  o.executables = ["k8rb"]
29
28
  o.bindir = ["bin"]
30
29
  #o.test_files = o.files.grep(/^test\//)
@@ -34,4 +33,5 @@ END
34
33
  o.add_runtime_dependency 'rack', '~> 1.6'
35
34
  o.add_runtime_dependency 'baby_erubis', '~> 2.1', '>= 2.1.1'
36
35
  #o.add_development_dependency "oktest", "~> 0"
36
+ o.add_development_dependency 'rack-test_app', '~> 1.0', '>= 1.0.0'
37
37
  end
@@ -1,8 +1,8 @@
1
1
  # -*- coding: utf-8 -*-
2
2
 
3
3
  ###
4
- ### $Release: 0.1.0 $
5
- ### $Copyright: copyright(c) 2014-2015 kuwata-lab.com all rights reserved $
4
+ ### $Release: 0.2.0 $
5
+ ### $Copyright: copyright(c) 2014-2016 kuwata-lab.com all rights reserved $
6
6
  ### $License: MIT License $
7
7
  ###
8
8
 
@@ -15,6 +15,8 @@ require 'digest/sha1'
15
15
 
16
16
  module K8
17
17
 
18
+ RELEASE = '$Release: 0.2.0 $'.split()[1]
19
+
18
20
  FILEPATH = __FILE__
19
21
 
20
22
  HTTP_REQUEST_METHODS = {
@@ -26,7 +28,7 @@ module K8
26
28
  "PATCH" => :PATCH,
27
29
  "OPTIONS" => :OPTIONS,
28
30
  "TRACE" => :TRACE,
29
- }.each {|k, _| k.freeze }
31
+ }
30
32
 
31
33
  HTTP_RESPONSE_STATUS = {
32
34
  100 => "Continue",
@@ -169,7 +171,7 @@ module K8
169
171
  '.docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
170
172
  '.xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
171
173
  '.pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
172
- }.each {|k, v| k.freeze; v.freeze }
174
+ }.each {|_, v| v.freeze } # hash keys are always frozen
173
175
 
174
176
 
175
177
  module Util
@@ -456,17 +458,17 @@ module K8
456
458
 
457
459
  attr_reader :env, :method, :path
458
460
 
461
+ def method(name=nil)
462
+ #; [!084jo] returns current request method when argument is not specified.
463
+ #; [!gwskf] calls Object#method() when argument specified.
464
+ return name ? super : @method
465
+ end
466
+
459
467
  def header(name)
460
468
  #; [!1z7wj] returns http header value from environment.
461
469
  return @env["HTTP_#{name.upcase.sub('-', '_')}"]
462
470
  end
463
471
 
464
- def method(name=nil)
465
- #; [!tp595] returns :GET, :POST, :PUT, ... when argument is not passed.
466
- #; [!49f51] returns Method object when argument is passed.
467
- return name.nil? ? @method : super(name)
468
- end
469
-
470
472
  def request_method
471
473
  #; [!y8eos] returns env['REQUEST_METHOD'] as string.
472
474
  return @env['REQUEST_METHOD']
@@ -783,6 +785,25 @@ module K8
783
785
  return @action_method_mapping ||= ActionMethodMapping.new
784
786
  end
785
787
 
788
+ def self._build_action_info(full_urlpath_pattern) # :nodoc:
789
+ #; [!ordhc] build ActionInfo objects for each action methods.
790
+ parent = full_urlpath_pattern
791
+ @action_infos = {}
792
+ _action_method_mapping().each do |urlpath_pat, methods|
793
+ methods.each do |req_meth, action_method_name|
794
+ info = ActionInfo.create(req_meth, "#{parent}#{urlpath_pat}")
795
+ @action_infos[action_method_name] = info
796
+ end
797
+ end
798
+ @action_infos
799
+ end
800
+
801
+ def self.[](action_method_name)
802
+ #; [!1tq8z] returns ActionInfo object corresponding to action method.
803
+ #; [!6g2iw] returns nil when not mounted yet.
804
+ return (@action_infos || {})[action_method_name]
805
+ end
806
+
786
807
  end
787
808
 
788
809
 
@@ -1001,6 +1022,85 @@ module K8
1001
1022
  end
1002
1023
 
1003
1024
 
1025
+ ##
1026
+ ## ex:
1027
+ ## info = ActionInfo.new('PUT', '/api/books/{id}')
1028
+ ## p info.method #=> "PUT"
1029
+ ## p info.urlpath(123) #=> "/api/books/123"
1030
+ ## p info.form_action_attr(123) #=> "/api/books/123?_method=PUT"
1031
+ ##
1032
+ class ActionInfo
1033
+
1034
+ def initialize(method, urlpath_format)
1035
+ @method = method
1036
+ @urlpath_format = urlpath_format # ex: '/books/%s/comments/%s'
1037
+ end
1038
+
1039
+ attr_reader :method
1040
+
1041
+ def method(name=nil)
1042
+ return name ? super : @method
1043
+ end
1044
+
1045
+ def urlpath(*args)
1046
+ return @urlpath_format % args
1047
+ end
1048
+
1049
+ def form_action_attr(*args)
1050
+ #; [!qyhkm] returns '/api/books/123' when method is POST.
1051
+ #; [!kogyx] returns '/api/books/123?_method=PUT' when method is not POST.
1052
+ if @method == 'POST'
1053
+ return urlpath(*args)
1054
+ else
1055
+ return "#{urlpath(*args)}?_method=#{@method}"
1056
+ end
1057
+ end
1058
+
1059
+ def self.create(meth, urlpath_pattern)
1060
+ ## ex: '/books/{id}' -> '/books/%s'
1061
+ #; [!1nk0i] replaces urlpath parameters with '%s'.
1062
+ #; [!a7fqv] replaces '%' with'%%'.
1063
+ rexp = /(.*?)\{(\w*?)(?::[^{}]*(?:\{[^{}]*?\}[^{}]*?)*)?\}/
1064
+ urlpath_format = ''; n = 0
1065
+ urlpath_pattern.scan(rexp) do |text, pname|
1066
+ next if pname == 'ext' # ignore '.html' or '.json'
1067
+ urlpath_format << text.gsub(/%/, '%%') << '%s'
1068
+ n += 1
1069
+ end
1070
+ rest = n > 0 ? Regexp.last_match.post_match : urlpath_pattern
1071
+ urlpath_format << rest.gsub(/%/, '%%')
1072
+ #; [!btt2g] returns ActionInfoN object when number of urlpath parameter <= 4.
1073
+ #; [!x5yx2] returns ActionInfo object when number of urlpath parameter > 4.
1074
+ return (SUBCLASSES[n] || ActionInfo).new(meth, urlpath_format)
1075
+ end
1076
+
1077
+ SUBCLASSES = [] # :nodoc:
1078
+
1079
+ end
1080
+
1081
+ class ActionInfo0 < ActionInfo # :nodoc:
1082
+ def urlpath(); @urlpath_format; end
1083
+ end
1084
+
1085
+ class ActionInfo1 < ActionInfo # :nodoc:
1086
+ def urlpath(a); @urlpath_format % [a]; end
1087
+ end
1088
+
1089
+ class ActionInfo2 < ActionInfo # :nodoc:
1090
+ def urlpath(a, b); @urlpath_format % [a, b]; end
1091
+ end
1092
+
1093
+ class ActionInfo3 < ActionInfo # :nodoc:
1094
+ def urlpath(a, b, c); @urlpath_format % [a, b, c]; end
1095
+ end
1096
+
1097
+ class ActionInfo4 < ActionInfo # :nodoc:
1098
+ def urlpath(a, b, c, d); @urlpath_format % [a, b, c, d]; end
1099
+ end
1100
+
1101
+ ActionInfo::SUBCLASSES << ActionInfo0 << ActionInfo1 << ActionInfo2 << ActionInfo3 << ActionInfo4
1102
+
1103
+
1004
1104
  class DefaultPatterns
1005
1105
 
1006
1106
  def initialize
@@ -1031,6 +1131,26 @@ module K8
1031
1131
  end
1032
1132
 
1033
1133
 
1134
+ DEFAULT_PATTERNS = proc {
1135
+ x = DefaultPatterns.new
1136
+ #; [!i51id] registers '\d+' as default pattern of param 'id' or /_id\z/.
1137
+ x.register('id', '\d+') {|val| val.to_i }
1138
+ x.register(/_id\z/, '\d+') {|val| val.to_i }
1139
+ #; [!2g08b] registers '(?:\.\w+)?' as default pattern of param 'ext'.
1140
+ x.register('ext', '(?:\.\w+)?')
1141
+ #; [!8x5mp] registers '\d\d\d\d-\d\d-\d\d' as default pattern of param 'date' or /_date\z/.
1142
+ to_date = proc {|val|
1143
+ #; [!wg9vl] raises 404 error when invalid date (such as 2012-02-30).
1144
+ yr, mo, dy = val.split(/-/).map(&:to_i)
1145
+ Date.new(yr, mo, dy) rescue
1146
+ raise HttpException.new(404, "#{val}: invalid date.")
1147
+ }
1148
+ x.register('date', '\d\d\d\d-\d\d-\d\d', &to_date)
1149
+ x.register(/_date\z/, '\d\d\d\d-\d\d-\d\d', &to_date)
1150
+ x
1151
+ }.call()
1152
+
1153
+
1034
1154
  class ActionMethodMapping
1035
1155
 
1036
1156
  def initialize
@@ -1073,370 +1193,248 @@ module K8
1073
1193
  end
1074
1194
 
1075
1195
 
1076
- class ActionClassMapping
1077
-
1078
- def initialize
1079
- @mappings = []
1080
- end
1196
+ class ActionMapping
1081
1197
 
1082
- ##
1083
- ## ex:
1084
- ## mount '/', WelcomeAction
1085
- ## mount '/books', BooksAction
1086
- ## mount '/admin', [
1087
- ## ['/session', AdminSessionAction],
1088
- ## ['/books', AdminBooksAction],
1089
- ## ]
1090
- ##
1091
- def mount(urlpath_pattern, action_class)
1092
- _mount(@mappings, urlpath_pattern, action_class)
1093
- #; [!w8mee] returns self.
1198
+ def initialize(urlpath_mapping, default_patterns: DEFAULT_PATTERNS, urlpath_cache_size: 0,
1199
+ enable_urlpath_param_range: true)
1200
+ @default_patterns = default_patterns || DefaultPatterns.new
1201
+ #; [!34o67] keyword arg 'enable_urlpath_param_range' controls to generate range object or not.
1202
+ @enable_urlpath_param_range = enable_urlpath_param_range
1203
+ #; [!buj0d] prepares LRU cache if cache size specified.
1204
+ @urlpath_cache_size = urlpath_cache_size
1205
+ @urlpath_lru_cache = urlpath_cache_size > 0 ? {} : nil
1206
+ #; [!wsz8g] compiles urlpath mapping passed.
1207
+ compile(urlpath_mapping)
1208
+ end
1209
+
1210
+ def compile(urlpath_mapping)
1211
+ #; [!6f3vl] compiles urlpath mapping.
1212
+ @fixed_endpoints = {} # urlpath patterns which have no urlpath params
1213
+ @variable_endpoints = [] # urlpath patterns which have any ulrpath param
1214
+ @all_endpoints = [] # all urlpath patterns (fixed + variable)
1215
+ rexp_str = _compile_array(urlpath_mapping, '', '')
1216
+ @urlpath_rexp = Regexp.compile("\\A#{rexp_str}\\z")
1094
1217
  return self
1095
1218
  end
1096
1219
 
1097
- def _mount(mappings, urlpath_pattern, action_class)
1098
- child_mappings = nil
1099
- #; [!4l8xl] can accept array of pairs of urlpath and action class.
1100
- if action_class.is_a?(Array)
1101
- array = action_class
1102
- child_mappings = []
1103
- array.each {|upath, klass| _mount(child_mappings, upath, klass) }
1104
- action_class = nil
1105
- #; [!ne804] when target class name is string...
1106
- elsif action_class.is_a?(String)
1107
- str = action_class
1108
- action_class = _load_action_class(str, "mount('#{str}')")
1109
- #; [!lvxyx] raises error when not an action class.
1110
- else
1111
- action_class.is_a?(Class) && action_class < BaseAction or
1112
- raise ArgumentError.new("mount('#{urlpath_pattern}'): Action class expected but got: #{action_class.inspect}")
1113
- end
1114
- #; [!30cib] raises error when action method is not defined in action class.
1115
- _validate_action_method_existence(action_class) if action_class
1116
- #; [!flb11] mounts action class to urlpath.
1117
- mappings << [urlpath_pattern, action_class || child_mappings]
1118
- end
1119
- private :_mount
1120
-
1121
- def _validate_action_method_existence(action_class)
1122
- action_class._action_method_mapping.each do |_, action_methods|
1123
- action_methods.each do |req_meth, action_method_name|
1124
- action_class.method_defined?(action_method_name) or
1125
- raise UnknownActionMethodError.new("#{req_meth.inspect}=>#{action_method_name.inspect}: unknown action method in #{action_class}.")
1126
- end
1127
- end
1128
- end
1220
+ EMPTY_ARRAY = [].freeze # :nodoc:
1129
1221
 
1130
- def _load_action_class(str, error)
1131
- #; [!9brqr] raises error when string format is invalid.
1132
- filepath, classname = str.split(/:/, 2)
1133
- classname or
1134
- raise ArgumentError.new("#{error}: expected 'file/path:ClassName'.")
1135
- #; [!jpg56] loads file.
1136
- #; [!vaazw] raises error when failed to load file.
1137
- #; [!eiovd] raises original LoadError when it raises in loading file.
1138
- begin
1139
- require filepath
1140
- rescue LoadError => ex
1141
- raise unless ex.path == filepath
1142
- raise ArgumentError.new("#{error}: failed to require file.")
1222
+ def lookup(req_urlpath)
1223
+ #; [!j34yh] finds from fixed urlpaths at first.
1224
+ if (tuple = @fixed_endpoints[req_urlpath])
1225
+ _, action_class, action_methods = tuple
1226
+ pnames = pvalues = EMPTY_ARRAY
1227
+ return action_class, action_methods, pnames, pvalues
1143
1228
  end
1144
- #; [!au27n] finds target class.
1145
- #; [!k9bpm] raises error when target class not found.
1146
- begin
1147
- action_class = classname.split(/::/).inject(Object) {|c, x| c.const_get(x) }
1148
- rescue NameError
1149
- raise ArgumentError.new("#{error}: no such action class.")
1229
+ #; [!uqwr7] uses LRU as cache algorithm.
1230
+ cache = @urlpath_lru_cache
1231
+ if cache && (result = cache.delete(req_urlpath))
1232
+ cache[req_urlpath] = result # delete & append to simulate LRU
1233
+ return result
1150
1234
  end
1151
- #; [!t6key] raises error when target class is not an action class.
1152
- action_class.is_a?(Class) && action_class < BaseAction or
1153
- raise ArgumentError.new("#{error}: not an action class.")
1154
- return action_class
1155
- end
1156
- private :_load_action_class
1157
-
1158
- def traverse(&block)
1159
- _traverse(@mappings, "", &block)
1160
- self
1161
- end
1162
-
1163
- def _traverse(mappings, base_urlpath_pat, &block)
1164
- #; [!ds0fp] yields with event (:enter, :map or :exit).
1165
- mappings.each do |urlpath_pattern, action_class|
1166
- yield :enter, base_urlpath_pat, urlpath_pattern, action_class, nil
1167
- curr_urlpath_pat = "#{base_urlpath_pat}#{urlpath_pattern}"
1168
- if action_class.is_a?(Array)
1169
- child_mappings = action_class
1170
- _traverse(child_mappings, curr_urlpath_pat, &block)
1171
- else
1172
- action_method_mapping = action_class._action_method_mapping
1173
- action_method_mapping.each do |upath_pat, action_methods|
1174
- yield :map, curr_urlpath_pat, upath_pat, action_class, action_methods
1175
- end
1176
- end
1177
- yield :exit, base_urlpath_pat, urlpath_pattern, action_class, nil
1235
+ #; [!sos5i] returns nil when request path not matched to urlpath patterns.
1236
+ m = @urlpath_rexp.match(req_urlpath)
1237
+ return nil unless m
1238
+ #; [!95q61] finds from variable urlpath patterns when not found in fixed ones.
1239
+ index = m.captures.find_index('')
1240
+ tuple = @variable_endpoints[index]
1241
+ _, action_class, action_methods, urlpath_rexp, pnames, procs, range = tuple
1242
+ #; [!1k1k5] converts urlpath param values by converter procs.
1243
+ if range
1244
+ str = req_urlpath[range]
1245
+ pvalues = [procs[0] ? procs[0].call(str) : str]
1246
+ else
1247
+ strs = urlpath_rexp.match(req_urlpath).captures
1248
+ pvalues = \
1249
+ case procs.length
1250
+ when 1; [procs[0] ? procs[0].call(strs[0]) : strs[0]]
1251
+ when 2; [procs[0] ? procs[0].call(strs[0]) : strs[0],
1252
+ procs[1] ? procs[1].call(strs[1]) : strs[1]]
1253
+ when 3; [procs[0] ? procs[0].call(strs[0]) : strs[0],
1254
+ procs[1] ? procs[1].call(strs[1]) : strs[1],
1255
+ procs[2] ? procs[2].call(strs[2]) : strs[2]]
1256
+ else ; procs.zip(strs).map {|pr, v| pr ? pr.call(v) : v }
1257
+ end # ex: ["123"] -> [123]
1178
1258
  end
1259
+ #; [!jyxlm] returns action class and methods, parameter names and values.
1260
+ result = [action_class, action_methods, pnames, pvalues]
1261
+ #; [!uqwr7] stores result into cache if cache is enabled.
1262
+ if cache
1263
+ cache[req_urlpath] = result
1264
+ #; [!3ps5g] deletes item from cache when cache size over limit.
1265
+ cache.shift() if cache.length > @urlpath_cache_size
1266
+ end
1267
+ #
1268
+ return result
1179
1269
  end
1180
- private :_traverse
1181
1270
 
1182
- def each_mapping
1183
- traverse() do
1184
- |event, base_urlpath_pat, urlpath_pat, action_class, action_methods|
1185
- next unless event == :map
1186
- full_urlpath_pat = "#{base_urlpath_pat}#{urlpath_pat}"
1187
- #; [!driqt] yields full urlpath pattern, action class and action methods.
1188
- yield full_urlpath_pat, action_class, action_methods
1271
+ def each
1272
+ #; [!2gwru] returns Enumerator if block is not provided.
1273
+ return to_enum(:each) unless block_given?
1274
+ #; [!7ynne] yields each urlpath pattern, action class and action methods.
1275
+ @all_endpoints.each do |tuple|
1276
+ urlpath_pat, action_class, action_methods, _ = tuple
1277
+ yield urlpath_pat, action_class, action_methods
1189
1278
  end
1190
1279
  self
1191
1280
  end
1192
1281
 
1193
- end
1194
-
1195
-
1196
- class ActionFinder
1197
-
1198
- def initialize(action_class_mapping, default_patterns=nil, urlpath_cache_size: 0)
1199
- @default_patterns = default_patterns || K8::DefaultPatterns.new
1200
- @urlpath_cache_size = urlpath_cache_size
1201
- #; [!wb9l8] enables urlpath cache when urlpath_cache_size > 0.
1202
- @urlpath_cache = urlpath_cache_size > 0 ? {} : nil # LRU cache of variable urlpath
1203
- #; [!dnu4q] calls '#_construct()'.
1204
- _construct(action_class_mapping)
1205
- end
1206
-
1207
1282
  private
1208
1283
 
1209
- def _construct(action_class_mapping)
1210
- ##
1211
- ## Example of @rexp:
1212
- ## \A # ...(0)
1213
- ## (:? # ...(1)
1214
- ## /api # ...(2)
1215
- ## (?: # ...(3)
1216
- ## /books # ...(2)
1217
- ## (?: # ...(3)
1218
- ## /\d+(\z) # ...(4)
1219
- ## | # ...(5)
1220
- ## /\d+/edit(\z) # ...(4)
1221
- ## ) # ...(6)
1222
- ## | # ...(7)
1223
- ## /authors # ...(2)
1224
- ## (:? # ...(4)
1225
- ## /\d+(\z) # ...(4)
1226
- ## | # ...(5)
1227
- ## /\d+/edit(\z) # ...(4)
1228
- ## ) # ...(6)
1229
- ## ) # ...(6)
1230
- ## | # ...(7)
1231
- ## /admin # ...(2)
1232
- ## (:? # ...(3)
1233
- ## ....
1234
- ## ) # ...(6)
1235
- ## ) # ...(8)
1236
- ##
1237
- ## Example of @dict (fixed urlpaths):
1238
- ## {
1239
- ## "/api/books" # ...(9)
1240
- ## => [BooksAction, {:GET=>:do_index, :POST=>:do_create}],
1241
- ## "/api/books/new"
1242
- ## => [BooksAction, {:GET=>:do_new}],
1243
- ## "/api/authors"
1244
- ## => [AuthorsAction, {:GET=>:do_index, :POST=>:do_create}],
1245
- ## "/api/authors/new"
1246
- ## => [AuthorsAction, {:GET=>:do_new}],
1247
- ## "/admin/books"
1248
- ## => ...
1249
- ## ...
1250
- ## }
1251
- ##
1252
- ## Example of @list (variable urlpaths):
1253
- ## [
1254
- ## [ # ...(10)
1255
- ## %r'\A/api/books/(\d+)\z',
1256
- ## ["id"], [proc {|x| x.to_i }],
1257
- ## BooksAction,
1258
- ## {:GET=>:do_show, :PUT=>:do_update, :DELETE=>:do_delete},
1259
- ## ],
1260
- ## [
1261
- ## %r'\A/api/books/(\d+)/edit\z',
1262
- ## ["id"], [proc {|x| x.to_i }],
1263
- ## BooksAction,
1264
- ## {:GET=>:do_edit},
1265
- ## ],
1266
- ## ...
1267
- ## ]
1268
- ##
1269
- @dict = {}
1270
- @list = []
1271
- #; [!956fi] builds regexp object for variable urlpaths (= containing urlpath params).
1272
- buf = ['\A'] # ...(0)
1273
- buf << '(?:' # ...(1)
1274
- action_class_mapping.traverse do
1275
- |event, base_urlpath_pat, urlpath_pat, action_class, action_methods|
1276
- first_p = buf[-1] == '(?:'
1277
- case event
1278
- when :map
1279
- full_urlpath_pat = "#{base_urlpath_pat}#{urlpath_pat}"
1280
- if full_urlpath_pat =~ /\{.*?\}/
1281
- buf << '|' unless first_p # ...(5)
1282
- buf << _compile(urlpath_pat, '', '(\z)').first # ...(4)
1283
- full_urlpath_rexp_str, param_names, param_converters = \
1284
- _compile(full_urlpath_pat, '\A', '\z', true)
1285
- #; [!sl9em] builds list of variable urlpaths (= containing urlpath params).
1286
- @list << [Regexp.compile(full_urlpath_rexp_str),
1287
- param_names, param_converters,
1288
- action_class, action_methods] # ...(9)
1289
- else
1290
- #; [!6tgj5] builds dict of fixed urlpaths (= no urlpath params).
1291
- @dict[full_urlpath_pat] = [action_class, action_methods] # ...(10)
1292
- end
1293
- when :enter
1294
- buf << '|' unless first_p # ...(7)
1295
- buf << _compile(urlpath_pat, '', '').first # ...(2)
1296
- buf << '(?:' # ...(3)
1297
- when :exit
1298
- if first_p
1299
- buf.pop() # '(?:'
1300
- buf.pop() # urlpath
1301
- buf.pop() if buf[-1] == '|'
1302
- else
1303
- buf << ')' # ...(6)
1304
- end
1284
+ def _compile_array(mapping, base_urlpath_pat, urlpath_pat)
1285
+ buf = []
1286
+ curr_urlpath_pat = "#{base_urlpath_pat}#{urlpath_pat}"
1287
+ mapping.each do |child_urlpath_pat, target|
1288
+ child = child_urlpath_pat
1289
+ #; [!w45ad] can compile nested array.
1290
+ if target.is_a?(Array)
1291
+ buf << _compile_array(target, curr_urlpath_pat, child)
1292
+ #; [!wd2eb] accepts subclass of Action class.
1293
+ elsif target.is_a?(Class) && target < Action
1294
+ buf << _compile_class(target, curr_urlpath_pat, child)
1295
+ #; [!l2kz5] requires library when filepath and classname specified.
1296
+ elsif target.is_a?(String)
1297
+ klass = _require_action_class(target)
1298
+ buf << _compile_class(klass, curr_urlpath_pat, child)
1299
+ #; [!irt5g] raises TypeError when unknown object specified.
1305
1300
  else
1306
- raise "** internal error: event=#{event.inspect}"
1301
+ raise TypeError.new("Action class or nested array expected, but got #{target.inspect}")
1307
1302
  end
1308
1303
  end
1309
- buf << ')' # ...(8)
1310
- @rexp = Regexp.compile(buf.join())
1311
- buf.clear()
1312
- end
1313
-
1314
- def _compile(urlpath_pattern, start_pat='', end_pat='', grouping=false)
1315
- #; [!izsbp] compiles urlpath pattern into regexp string and param names.
1316
- #; [!olps9] allows '{}' in regular expression.
1317
- #parse_rexp = /(.*?)<(\w*)(?::(.*?))?>/
1318
- #parse_rexp = /(.*?)\{(\w*)(?::(.*?))?\}/
1319
- #parse_rexp = /(.*?)\{(\w*)(?::(.*?(?:\{.*?\}.*?)*))?\}/
1320
- parse_rexp = /(.*?)\{(\w*)(?::([^{}]*?(?:\{[^{}]*?\}[^{}]*?)*))?\}/
1321
- param_names = []
1322
- converters = []
1323
- s = ""
1324
- s << start_pat
1325
- urlpath_pattern.scan(parse_rexp) do |text, name, pat|
1326
- proc_ = nil
1327
- pat, proc_ = @default_patterns.lookup(name) if pat.nil? || pat.empty?
1328
- named = !name.empty?
1329
- param_names << name if named
1330
- converters << proc_ if named
1331
- #; [!vey08] uses grouping when 4th argument is true.
1332
- #; [!2zil2] don't use grouping when 4th argument is false.
1333
- #; [!rda92] ex: '/{id:\d+}' -> '/(\d+)'
1334
- #; [!jyz2g] ex: '/{:\d+}' -> '/\d+'
1335
- #; [!hy3y5] ex: '/{:xx|yy}' -> '/(?:xx|yy)'
1336
- #; [!gunsm] ex: '/{id:xx|yy}' -> '/(xx|yy)'
1337
- if named && grouping
1338
- pat = "(#{pat})"
1339
- elsif pat =~ /\|/
1340
- pat = "(?:#{pat})"
1304
+ #; [!bcgc9] skips classes which have only fixed urlpaths.
1305
+ buf.compact!
1306
+ rexp_str = _build_rexp_str(urlpath_pat, buf)
1307
+ return rexp_str # ex: '/api(?:/books/\d+(\z)|/authors/\d+(\z))'
1308
+ end
1309
+
1310
+ def _compile_class(action_class, base_urlpath_pat, urlpath_pat)
1311
+ buf = []
1312
+ curr_urlpath_pat = "#{base_urlpath_pat}#{urlpath_pat}"
1313
+ action_class._action_method_mapping.each do |child_urlpath_pat, methods|
1314
+ #; [!ue766] raises error when action method is not defined in action class.
1315
+ _validate_action_method_existence(action_class, methods)
1316
+ #; ex: '/api/books/{id}' -> '\A/api/books/(\d+)\z', ['id'], [proc{|x| x.to_i}]
1317
+ fullpath_pat = "#{curr_urlpath_pat}#{child_urlpath_pat}"
1318
+ rexp_str, pnames, procs = _compile_urlpath_pat(fullpath_pat, true)
1319
+ #; [!z2iax] classifies urlpath contains any parameter as variable one.
1320
+ if pnames
1321
+ fullpath_rexp = Regexp.compile("\\A#{rexp_str}\\z")
1322
+ range = @enable_urlpath_param_range ? _range_of_urlpath_param(fullpath_pat) : nil
1323
+ tuple = [fullpath_pat, action_class, methods, fullpath_rexp, pnames.freeze, procs, range]
1324
+ @variable_endpoints << tuple
1325
+ buf << (_compile_urlpath_pat(child_urlpath_pat).first << '(\z)')
1326
+ #; [!rvdes] classifies urlpath contains no parameters as fixed one.
1327
+ else
1328
+ tuple = [fullpath_pat, action_class, methods]
1329
+ @fixed_endpoints[fullpath_pat] = tuple
1341
1330
  end
1342
- s << Regexp.escape(text) << pat
1331
+ #
1332
+ @all_endpoints << tuple
1343
1333
  end
1344
- m = Regexp.last_match
1345
- rest = m ? m.post_match : urlpath_pattern
1346
- s << Regexp.escape(rest) << end_pat
1347
- ## ex: ['/api/books/(\d+)', ["id"], [proc {|x| x.to_i }]]
1348
- return s, param_names, converters
1349
- end
1350
-
1351
- public
1352
-
1353
- def find(req_path)
1354
- action_class, action_methods = @dict[req_path]
1355
- if action_class
1356
- #; [!p18w0] urlpath params are empty when matched to fixed urlpath pattern.
1357
- param_names = []
1358
- param_values = []
1359
- #; [!gzy2w] fetches variable urlpath from LRU cache if LRU cache is enabled.
1360
- elsif (cache = @urlpath_cache) && (tuple = cache.delete(req_path))
1361
- cache[req_path] = tuple # Hash in Ruby >=1.9 keeps keys' order!
1362
- action_class, action_methods, param_names, param_values = tuple
1363
- else
1364
- #; [!ps5jm] returns nil when not matched to any urlpath patterns.
1365
- m = @rexp.match(req_path) or return nil
1366
- i = m.captures.find_index('') or return nil
1367
- #; [!t6yk0] urlpath params are not empty when matched to variable urlpath apttern.
1368
- (full_urlpath_rexp, # ex: /\A\/api\/books\/(\d+)\z/
1369
- param_names, # ex: ["id"]
1370
- param_converters, # ex: [proc {|x| x.to_i }]
1371
- action_class, # ex: BooksAction
1372
- action_methods, # ex: {:GET=>:do_show, :PUT=>:do_edit, ...}
1373
- ) = @list[i]
1374
- #; [!0o3fe] converts urlpath param values according to default patterns.
1375
- values = full_urlpath_rexp.match(req_path).captures
1376
- procs = param_converters
1377
- #param_values = procs.zip(values).map {|pr, v| pr ? pr.call(v) : v }
1378
- param_values = \
1379
- case procs.length
1380
- when 1; pr0 = procs[0]
1381
- [pr0 ? pr0.call(values[0]) : values[0]]
1382
- when 2; pr0, pr1 = procs
1383
- [pr0 ? pr0.call(values[0]) : values[0],
1384
- pr1 ? pr1.call(values[1]) : values[1]]
1385
- else ; procs.zip(values).map {|pr, v| pr ? pr.call(v) : v }
1386
- end # ex: ["123"] -> [123]
1387
- #; [!v2zbx] caches variable urlpath into LRU cache if cache is enabled.
1388
- #; [!nczw6] LRU cache size doesn't growth over max cache size.
1389
- if cache
1390
- cache.shift() if cache.length > @urlpath_cache_size - 1
1391
- cache[req_path] = [action_class, action_methods, param_names, param_values]
1334
+ #; [!6xwhq] builds action infos for each action methods.
1335
+ action_class._build_action_info(curr_urlpath_pat) if action_class
1336
+ #
1337
+ rexp_str = _build_rexp_str(urlpath_pat, buf)
1338
+ return rexp_str # ex: '/books(?:/\d+(\z)|/\d+/edit(\z))'
1339
+ end
1340
+
1341
+ ## ex: '/books', ['/\d+', '/\d+/edit'] -> '/books(?:/\d+|/\d+/edit)'
1342
+ def _build_rexp_str(urlpath_pat, buf)
1343
+ return nil if buf.empty?
1344
+ prefix = _compile_urlpath_pat(urlpath_pat).first
1345
+ #; [!169ad] removes unnecessary grouping.
1346
+ return "#{prefix}#{buf.first}" if buf.length == 1
1347
+ return "#{prefix}(?:#{buf.join('|')})"
1348
+ ensure
1349
+ buf.clear() # for GC
1350
+ end
1351
+
1352
+ #; [!92jcn] '{' and '}' are available in urlpath param pattern.
1353
+ #URLPATH_PARAM_REXP = /\{(\w*)(?::(.*?))?\}/
1354
+ #URLPATH_PARAM_REXP = /\{(\w*)(?::(.*?(?:\{.*?\}.*?)*))?\}/
1355
+ URLPATH_PARAM_REXP = /\{(\w*)(?::([^{}]*?(?:\{[^{}]*?\}[^{}]*?)*))?\}/
1356
+ URLPATH_PARAM_REXP_NOGROUP = /\{(?:\w*)(?::(?:[^{}]*?(?:\{[^{}]*?\}[^{}]*?)*))?\}/
1357
+ proc {|rx1, rx2|
1358
+ rx1.source.gsub(/\(([^?])/, '(?:\1') == rx2.source or
1359
+ raise "*** assertion failed: #{rx1.source.inspect} != #{rx2.source.inspect}"
1360
+ }.call(URLPATH_PARAM_REXP, URLPATH_PARAM_REXP_NOGROUP)
1361
+
1362
+ ## ex: '/books/{id}', true -> ['/books/(\d+)', ['id'], [proc{|x| x.to_i}]]
1363
+ def _compile_urlpath_pat(urlpath_pat, enable_capture=false)
1364
+ #; [!iln54] param names and conveter procs are nil when no urlpath params.
1365
+ pnames = nil # urlpath parameter names (ex: ['id'])
1366
+ procs = nil # proc objects to convert parameter value (ex: [proc{|x| x.to_i}])
1367
+ #
1368
+ rexp_str = urlpath_pat.gsub(URLPATH_PARAM_REXP) {
1369
+ pname, s, proc_ = $1, $2, nil
1370
+ s, proc_ = @default_patterns.lookup(pname) if s.nil?
1371
+ #; [!lhtiz] skips empty param name.
1372
+ #; [!66zas] skips param name starting with '_'.
1373
+ skip = pname.empty? || pname.start_with?('_')
1374
+ pnames ||= []; pnames << pname unless skip
1375
+ procs ||= []; procs << proc_ unless skip
1376
+ #; [!bi7gr] captures urlpath params when 2nd argument is truthy.
1377
+ #; [!mprbx] ex: '/{id:x|y}' -> '/(x|y)', '/{:x|y}' -> '/(?:x|y)'
1378
+ if enable_capture && ! skip
1379
+ s = "(#{s})"
1380
+ elsif s =~ /\|/
1381
+ s = "(?:#{s})"
1392
1382
  end
1393
- end
1394
- #; [!ndktw] returns action class, action methods, urlpath names and values.
1395
- ## ex: [BooksAction, {:GET=>:do_show}, ["id"], [123]]
1396
- return action_class, action_methods, param_names, param_values
1397
- end
1398
-
1399
- end
1400
-
1401
-
1402
- ## Router consists of urlpath mapping and finder.
1403
- class ActionRouter
1404
-
1405
- def initialize(urlpath_cache_size: 0)
1406
- @mapping = ActionClassMapping.new
1407
- @default_patterns = DefaultPatterns.new
1408
- @finder = nil
1409
- #; [!l1elt] saves finder options.
1410
- @finder_opts = {:urlpath_cache_size=>urlpath_cache_size}
1411
- end
1412
-
1413
- attr_reader :default_patterns
1414
-
1415
- def register(urlpath_param_name, default_pattern='[^/]*?', &converter)
1416
- #; [!boq80] registers urlpath param pattern and converter.
1417
- @default_patterns.register(urlpath_param_name, default_pattern, &converter)
1418
- self
1419
- end
1420
-
1421
- def mount(urlpath_pattern, action_class)
1422
- #; [!uc996] mouts action class to urlpath.
1423
- @mapping.mount(urlpath_pattern, action_class)
1424
- #; [!trs6w] removes finder object.
1425
- @finder = nil
1426
- self
1383
+ s
1384
+ }
1385
+ #; [!awfgs] returns regexp string, param names, and converter procs.
1386
+ return rexp_str, pnames, procs # ex: '/books/(\d+)', ['id'], [proc{|x| x.to_i}]}]
1427
1387
  end
1428
1388
 
1429
- def each_mapping(&block)
1430
- #; [!2kq9h] yields with full urlpath pattern, action class and action methods.
1431
- @mapping.each_mapping(&block)
1389
+ ## raises error when action method is not defined in action class
1390
+ def _validate_action_method_existence(action_class, action_methods)
1391
+ action_methods.each do |req_meth, action_method_name|
1392
+ action_class.method_defined?(action_method_name) or
1393
+ raise UnknownActionMethodError.new("#{req_meth.inspect}=>#{action_method_name.inspect}: unknown action method in #{action_class}.")
1394
+ end
1432
1395
  end
1433
1396
 
1434
- def find(req_path)
1435
- #; [!zsuzg] creates finder object automatically if necessary.
1436
- #; [!9u978] urlpath_cache_size keyword argument will be passed to router oubject.
1437
- @finder ||= ActionFinder.new(@mapping, @default_patterns, @finder_opts)
1438
- #; [!m9klu] returns action class, action methods, urlpath param names and values.
1439
- return @finder.find(req_path)
1397
+ ## range object to retrieve urlpath parameter value faster than Regexp matching
1398
+ ## ex:
1399
+ ## urlpath_pat == '/books/{id}/edit'
1400
+ ## arr = urlpath_pat.split(/\{.*?\}/, -1) #=> ['/books/', '/edit']
1401
+ ## range = (arr[0].length .. - (arr[01].length+1)) #=> 7..-6 (Range object)
1402
+ ## p "/books/123/edit"[range] #=> '123'
1403
+ def _range_of_urlpath_param(urlpath_pattern)
1404
+ rexp = URLPATH_PARAM_REXP_NOGROUP
1405
+ arr = urlpath_pattern.split(rexp, -1) # ex: ['/books/', '/edit']
1406
+ return nil unless arr.length == 2
1407
+ return (arr[0].length .. - (arr[1].length+1)) # ex: 7..-6 (Range object)
1408
+ end
1409
+
1410
+ ## ex: './api/admin/books:Admin::BookAPI' -> Admin::BookAPI
1411
+ def _require_action_class(filepath_and_classname)
1412
+ #; [!px9jy] requires file and finds class object.
1413
+ str = filepath_and_classname # ex: './admin/api/book:Admin::BookAPI'
1414
+ filepath, classname = filepath_and_classname.split(':', 2)
1415
+ begin
1416
+ require filepath
1417
+ rescue LoadError => ex
1418
+ #; [!dlcks] don't rescue LoadError when it is not related to argument.
1419
+ raise unless ex.path == filepath
1420
+ #; [!mngjz] raises error when failed to load file.
1421
+ raise LoadError.new("'#{str}': cannot load '#{filepath}'.")
1422
+ end
1423
+ #; [!8n6pf] class name may have module prefix name.
1424
+ #; [!6lv7l] raises error when action class not found.
1425
+ begin
1426
+ klass = classname.split('::').inject(Object) {|cls, x| cls.const_get(x) }
1427
+ rescue NameError
1428
+ raise NameError.new("'#{str}': class not found (#{classname}).")
1429
+ end
1430
+ #; [!thf7t] raises TypeError when not a class.
1431
+ klass.is_a?(Class) or
1432
+ raise TypeError.new("'#{str}': class name expected but got #{klass.inspect}.")
1433
+ #; [!yqcgx] raises TypeError when not a subclass of K8::Action.
1434
+ klass < Action or
1435
+ raise TypeError.new("'#{str}': expected subclass of K8::Action but not.")
1436
+ #
1437
+ return klass
1440
1438
  end
1441
1439
 
1442
1440
  end
@@ -1444,52 +1442,18 @@ module K8
1444
1442
 
1445
1443
  class RackApplication
1446
1444
 
1447
- def initialize(urlpath_mapping=[], urlpath_cache_size: 0)
1448
- @router = ActionRouter.new(urlpath_cache_size: urlpath_cache_size)
1449
- init_default_param_patterns(@router.default_patterns)
1450
- #; [!vkp65] mounts urlpath mappings if provided.
1451
- urlpath_mapping.each do |urlpath, klass|
1452
- @router.mount(urlpath, klass)
1453
- end if urlpath_mapping
1454
- end
1455
-
1456
- def init_default_param_patterns(default_patterns)
1457
- #; [!i51id] registers '\d+' as default pattern of param 'id' or /_id\z/.
1458
- x = default_patterns
1459
- x.register('id', '\d+') {|val| val.to_i }
1460
- x.register(/_id\z/, '\d+') {|val| val.to_i }
1461
- #; [!2g08b] registers '(?:\.\w+)?' as default pattern of param 'ext'.
1462
- x.register('ext', '(?:\.\w+)?')
1463
- #; [!8x5mp] registers '\d\d\d\d-\d\d-\d\d' as default pattern of param 'date' or /_date\z/.
1464
- to_date = proc {|val|
1465
- #; [!wg9vl] raises 404 error when invalid date (such as 2012-02-30).
1466
- yr, mo, dy = val.split(/-/).map(&:to_i)
1467
- Date.new(yr, mo, dy) rescue
1468
- raise HttpException.new(404, "#{val}: invalid date.")
1469
- }
1470
- x.register('date', '\d\d\d\d-\d\d-\d\d', &to_date)
1471
- x.register(/_date\z/, '\d\d\d\d-\d\d-\d\d', &to_date)
1445
+ def initialize(urlpath_mapping=[], default_patterns: DEFAULT_PATTERNS, urlpath_cache_size: 0,
1446
+ enable_urlpath_param_range: true)
1447
+ #; [!vkp65] mounts urlpath mappings.
1448
+ @mapping = ActionMapping.new(urlpath_mapping,
1449
+ default_patterns: default_patterns,
1450
+ urlpath_cache_size: urlpath_cache_size,
1451
+ enable_urlpath_param_range: enable_urlpath_param_range)
1472
1452
  end
1473
- protected :init_default_param_patterns
1474
1453
 
1475
- ##
1476
- ## ex:
1477
- ## mount '/', WelcomeAction
1478
- ## mount '/books', BooksAction
1479
- ## mount '/admin', [
1480
- ## ['/session', AdminSessionAction],
1481
- ## ['/books', AdminBooksAction],
1482
- ## ]
1483
- ##
1484
- def mount(urlpath_pattern, action_class_or_array)
1485
- #; [!zwva6] mounts action class to urlpath pattern.
1486
- @router.mount(urlpath_pattern, action_class_or_array)
1487
- return self
1488
- end
1489
-
1490
- def find(req_path)
1454
+ def lookup(req_path)
1491
1455
  #; [!o0rnr] returns action class, action methods, urlpath names and values.
1492
- return @router.find(req_path)
1456
+ return @mapping.lookup(req_path)
1493
1457
  end
1494
1458
 
1495
1459
  def call(env)
@@ -1512,8 +1476,16 @@ module K8
1512
1476
  req_meth_ = req_meth
1513
1477
  end
1514
1478
  begin
1479
+ tuple4 = lookup(req.path)
1480
+ #; [!vz07j] redirects only when request method is GET or HEAD.
1481
+ if tuple4.nil? && req_meth_ == :GET
1482
+ #; [!eb2ms] returns 301 when urlpath not found but found with tailing '/'.
1483
+ #; [!02dow] returns 301 when urlpath not found but found without tailing '/'.
1484
+ location = lookup_autoredirect_location(req)
1485
+ return [301, {'Location'=>location}, []] if location
1486
+ end
1515
1487
  #; [!rz13i] returns HTTP 404 when urlpath not found.
1516
- tuple4 = find(req.path) or
1488
+ tuple4 or
1517
1489
  raise HttpException.new(404)
1518
1490
  action_class, action_methods, urlpath_param_names, urlpath_param_values = tuple4
1519
1491
  #; [!rv3cf] returns HTTP 405 when urlpath found but request method not allowed.
@@ -1579,11 +1551,19 @@ END
1579
1551
  return false
1580
1552
  end
1581
1553
 
1554
+ def lookup_autoredirect_location(req)
1555
+ location = req.path.end_with?('/') ? req.path[0..-2] : "#{req.path}/"
1556
+ return nil unless lookup(location)
1557
+ #; [!2a9c9] adds query string to 'Location' header.
1558
+ qs = req.env['QUERY_STRING']
1559
+ return qs && ! qs.empty? ? "#{location}?#{qs}" : location
1560
+ end
1561
+
1582
1562
  public
1583
1563
 
1584
1564
  def each_mapping(&block)
1585
1565
  #; [!cgjyv] yields full urlpath pattern, action class and action methods.
1586
- @router.each_mapping(&block)
1566
+ @mapping.each(&block)
1587
1567
  self
1588
1568
  end
1589
1569
 
@@ -1789,245 +1769,4 @@ END
1789
1769
  end
1790
1770
 
1791
1771
 
1792
- module Mock
1793
-
1794
-
1795
- module_function
1796
-
1797
- def new_env(meth=:GET, path="/", query: nil, form: nil, multipart: nil, json: nil, input: nil, headers: nil, cookie: nil, env: nil)
1798
- #uri = "http://localhost:80#{path}"
1799
- #opts["REQUEST_METHOD"] = meth
1800
- #env = Rack::MockRequest.env_for(uri, opts)
1801
- require 'stringio' unless defined?(StringIO)
1802
- https = env && (env['rack.url_scheme'] == 'https' || env['HTTPS'] == 'on')
1803
- #
1804
- err = proc {|a, b|
1805
- ArgumentError.new("new_env(): not allowed both '#{a}' and '#{b}' at a time.")
1806
- }
1807
- ctype = nil
1808
- if form
1809
- #; [!c779l] raises ArgumentError when both form and json are specified.
1810
- ! json or raise err.call('form', 'json')
1811
- input = Util.build_query_string(form)
1812
- ctype = "application/x-www-form-urlencoded"
1813
- end
1814
- if json
1815
- ! multipart or raise err.call('json', 'multipart')
1816
- input = json.is_a?(String) ? json : JSON.dump(json)
1817
- ctype = "application/json"
1818
- end
1819
- if multipart
1820
- ! form or raise err.call('multipart', 'form')
1821
- #; [!gko8g] 'multipart:' kwarg accepts Hash object (which is converted into multipart data).
1822
- if multipart.is_a?(Hash)
1823
- dict = multipart
1824
- multipart = dict.each_with_object(MultipartBuilder.new) do |(k, v), mp|
1825
- v.is_a?(File) ? mp.add_file(k, v) : mp.add(k, v.to_s)
1826
- end
1827
- end
1828
- input = multipart.to_s
1829
- m = /\A--(\S+)\r\n/.match(input) or
1830
- raise ArgumentError.new("invalid multipart format.")
1831
- boundary = $1
1832
- ctype = "multipart/form-data; boundary=#{boundary}"
1833
- end
1834
- environ = {
1835
- "rack.version" => [1, 3],
1836
- "rack.input" => StringIO.new(input || ""),
1837
- "rack.errors" => StringIO.new,
1838
- "rack.multithread" => true,
1839
- "rack.multiprocess" => true,
1840
- "rack.run_once" => false,
1841
- "rack.url_scheme" => https ? "https" : "http",
1842
- "REQUEST_METHOD" => meth.to_s,
1843
- "SERVER_NAME" => "localhost",
1844
- "SERVER_PORT" => https ? "443" : "80",
1845
- "QUERY_STRING" => Util.build_query_string(query || ""),
1846
- "PATH_INFO" => path,
1847
- "HTTPS" => https ? "on" : "off",
1848
- "SCRIPT_NAME" => "",
1849
- "CONTENT_LENGTH" => (input ? input.bytesize.to_s : "0"),
1850
- "CONTENT_TYPE" => ctype,
1851
- }
1852
- environ.delete("CONTENT_TYPE") if environ["CONTENT_TYPE"].nil?
1853
- headers.each do |name, value|
1854
- name =~ /\A[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*\z/ or
1855
- raise ArgumentError.new("invalid http header name: #{name.inspect}")
1856
- value.is_a?(String) or
1857
- raise ArgumentError.new("http header value should be a string but got: #{value.inspect}")
1858
- ## ex: 'X-Requested-With' -> 'HTTP_X_REQUESTED_WITH'
1859
- k = "HTTP_#{name.upcase.gsub(/-/, '_')}"
1860
- environ[k] = value
1861
- end if headers
1862
- env.each do |name, value|
1863
- case name
1864
- when /\Arack\./
1865
- # ok
1866
- when /\A[A-Z]+(_[A-Z0-9]+)*\z/
1867
- value.is_a?(String) or
1868
- raise ArgumentError.new("rack env value should be a string but got: #{value.inspect}")
1869
- else
1870
- raise ArgumentError.new("invalid rack env key: #{name}")
1871
- end
1872
- environ[name] = value
1873
- end if env
1874
- if cookie
1875
- s = ! cookie.is_a?(Hash) ? cookie.to_s : cookie.map {|k, v|
1876
- "#{Util.percent_encode(k)}=#{Util.percent_encode(v)}"
1877
- }.join('; ')
1878
- s = "#{environ['HTTP_COOKIE']}; #{s}" if environ['HTTP_COOKIE']
1879
- environ['HTTP_COOKIE'] = s
1880
- end
1881
- return environ
1882
- end
1883
-
1884
-
1885
- class MultipartBuilder
1886
-
1887
- def initialize(boundary=nil)
1888
- #; [!ajfgl] sets random string as boundary when boundary is nil.
1889
- @boundary = boundary || Util.randstr_b64()
1890
- @params = []
1891
- end
1892
-
1893
- attr_reader :boundary
1894
-
1895
- def add(name, value, filename=nil, content_type=nil)
1896
- #; [!tp4bk] detects content type from filename when filename is not nil.
1897
- content_type ||= Util.guess_content_type(filename) if filename
1898
- @params << [name, value, filename, content_type]
1899
- self
1900
- end
1901
-
1902
- def add_file(name, file, content_type=nil)
1903
- #; [!uafqa] detects content type from filename when content type is not provided.
1904
- content_type ||= Util.guess_content_type(file.path)
1905
- #; [!b5811] reads file content and adds it as param value.
1906
- add(name, file.read(), File.basename(file.path), content_type)
1907
- #; [!36bsu] closes opened file automatically.
1908
- file.close()
1909
- self
1910
- end
1911
-
1912
- def to_s
1913
- #; [!61gc4] returns multipart form string.
1914
- boundary = @boundary
1915
- s = "".force_encoding('ASCII-8BIT')
1916
- @params.each do |name, value, filename, content_type|
1917
- s << "--#{boundary}\r\n"
1918
- if filename
1919
- s << "Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n"
1920
- else
1921
- s << "Content-Disposition: form-data; name=\"#{name}\"\r\n"
1922
- end
1923
- s << "Content-Type: #{content_type}\r\n" if content_type
1924
- s << "\r\n"
1925
- s << value.force_encoding('ASCII-8BIT')
1926
- s << "\r\n"
1927
- end
1928
- s << "--#{boundary}--\r\n"
1929
- return s
1930
- end
1931
-
1932
- end
1933
-
1934
-
1935
- ##
1936
- ## Wrapper class to test Rack application.
1937
- ##
1938
- ## ex:
1939
- ## http = K8::Mock::TestApp.new(app)
1940
- ## resp = http.GET('/api/hello', query={'name'=>'World'})
1941
- ## assert_equal 200, resp.status
1942
- ## assert_equal "application/json", resp.headers['Content-Type']
1943
- ## assert_equal {"message"=>"Hello World!"}, resp.body_json
1944
- ##
1945
- class TestApp
1946
-
1947
- def initialize(app=nil)
1948
- @app = app
1949
- end
1950
-
1951
- def request(meth, path, query: nil, form: nil, multipart: nil, json: nil, input: nil, headers: nil, cookie: nil, env: nil)
1952
- #; [!4xpwa] creates env object and calls app with it.
1953
- env = K8::Mock.new_env(meth, path, query: query, form: form, multipart: multipart, json: json, input: input, headers: headers, cookie: cookie, env: env)
1954
- status, headers, body = @app.call(env)
1955
- return TestResponse.new(status, headers, body)
1956
- end
1957
-
1958
- def GET path, kwargs={}; request(:GET , path, kwargs); end
1959
- def POST path, kwargs={}; request(:POST , path, kwargs); end
1960
- def PUT path, kwargs={}; request(:PUT , path, kwargs); end
1961
- def DELETE path, kwargs={}; request(:DELETE , path, kwargs); end
1962
- def HEAD path, kwargs={}; request(:HEAD , path, kwargs); end
1963
- def PATCH path, kwargs={}; request(:PATCH , path, kwargs); end
1964
- def OPTIONS path, kwargs={}; request(:OPTIONS, path, kwargs); end
1965
- def TRACE path, kwargs={}; request(:TRACE , path, kwargs); end
1966
-
1967
- end
1968
-
1969
-
1970
- class TestResponse
1971
-
1972
- def initialize(status, headers, body)
1973
- @status = status
1974
- @headers = headers
1975
- @body = body
1976
- end
1977
-
1978
- attr_accessor :status, :headers, :body
1979
-
1980
- def body_binary
1981
- #; [!mb0i4] returns body as binary string.
1982
- s = @body.join()
1983
- @body.close() if @body.respond_to?(:close)
1984
- return s
1985
- end
1986
-
1987
- def body_text
1988
- #; [!rr18d] error when 'Content-Type' header is missing.
1989
- ctype = @headers['Content-Type'] or
1990
- raise "body_text(): missing 'Content-Type' header."
1991
- #; [!dou1n] converts body text according to 'charset' in 'Content-Type' header.
1992
- if ctype =~ /; *charset=(\w[-\w]*)/
1993
- charset = $1
1994
- #; [!cxje7] assumes charset as 'utf-8' when 'Content-Type' is json.
1995
- elsif ctype == "application/json"
1996
- charset = 'utf-8'
1997
- #; [!n4c71] error when non-json 'Content-Type' header has no 'charset'.
1998
- else
1999
- raise "body_text(): missing 'charset' in 'Content-Type' header."
2000
- end
2001
- #; [!vkj9h] returns body as text string, according to 'charset' in 'Content-Type'.
2002
- return body_binary().force_encoding(charset)
2003
- end
2004
-
2005
- def body_json
2006
- #; [!qnic1] returns Hash object representing JSON string.
2007
- return JSON.parse(body_text())
2008
- end
2009
-
2010
- def content_type
2011
- #; [!40hcz] returns 'Content-Type' header value.
2012
- return @headers['Content-Type']
2013
- end
2014
-
2015
- def content_length
2016
- #; [!5lb19] returns 'Content-Length' header value as integer.
2017
- #; [!qjktz] returns nil when 'Content-Length' is not set.
2018
- len = @headers['Content-Length']
2019
- return len ? len.to_i : len
2020
- end
2021
-
2022
- def location
2023
- #; [!8y8lg] returns 'Location' header value.p
2024
- return @headers['Location']
2025
- end
2026
-
2027
- end
2028
-
2029
-
2030
- end
2031
-
2032
-
2033
1772
  end