keight 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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