keight 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGES.md +237 -0
  3. data/README.md +285 -81
  4. data/Rakefile +27 -1
  5. data/bench/benchmarker.rb +2 -2
  6. data/bin/k8rb +112 -305
  7. data/keight.gemspec +4 -6
  8. data/lib/keight.rb +860 -822
  9. data/test/keight_test.rb +1007 -933
  10. data/test/oktest.rb +2 -2
  11. metadata +4 -68
  12. data/CHANGES.txt +0 -64
  13. data/lib/keight/skeleton/.gitignore +0 -10
  14. data/lib/keight/skeleton/README.txt +0 -13
  15. data/lib/keight/skeleton/app/action.rb +0 -106
  16. data/lib/keight/skeleton/app/api/hello.rb +0 -39
  17. data/lib/keight/skeleton/app/form/.keep +0 -0
  18. data/lib/keight/skeleton/app/helper/.keep +0 -0
  19. data/lib/keight/skeleton/app/model.rb +0 -144
  20. data/lib/keight/skeleton/app/model/.keep +0 -0
  21. data/lib/keight/skeleton/app/page/welcome.rb +0 -17
  22. data/lib/keight/skeleton/app/template/_layout.html.eruby +0 -56
  23. data/lib/keight/skeleton/app/template/welcome.html.eruby +0 -6
  24. data/lib/keight/skeleton/app/usecase/.keep +0 -0
  25. data/lib/keight/skeleton/config.rb +0 -46
  26. data/lib/keight/skeleton/config.ru +0 -6
  27. data/lib/keight/skeleton/config/app.rb +0 -29
  28. data/lib/keight/skeleton/config/app_dev.rb +0 -8
  29. data/lib/keight/skeleton/config/app_prod.rb +0 -7
  30. data/lib/keight/skeleton/config/app_stg.rb +0 -5
  31. data/lib/keight/skeleton/config/app_test.rb +0 -8
  32. data/lib/keight/skeleton/config/server_puma.rb +0 -22
  33. data/lib/keight/skeleton/config/server_unicorn.rb +0 -21
  34. data/lib/keight/skeleton/config/urlpath_mapping.rb +0 -16
  35. data/lib/keight/skeleton/index.txt +0 -39
  36. data/lib/keight/skeleton/main.rb +0 -22
  37. data/lib/keight/skeleton/static/lib/.keep +0 -0
  38. data/lib/keight/skeleton/test/api/hello_test.rb +0 -27
  39. data/lib/keight/skeleton/test/test_helper.rb +0 -9
@@ -3,12 +3,12 @@
3
3
 
4
4
  Gem::Specification.new do |o|
5
5
  o.name = "keight"
6
- o.version = "$Release: 0.2.0 $".split[1]
6
+ o.version = "$Release: 0.3.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
10
10
  o.homepage = "https://github.com/kwatch/keight/tree/ruby"
11
- o.license = "MIT Lisense"
11
+ o.license = "MIT" # ref: http://spdx.org/licenses/
12
12
  o.summary = "Jet-speed webapp framework for Ruby"
13
13
  o.description = <<'END'
14
14
  Keight.rb is the crazy-fast webapp framework for Ruby.
@@ -18,9 +18,9 @@ See https://github.com/kwatch/keight/tree/ruby for details.
18
18
  END
19
19
 
20
20
  o.files = Dir[*%w[
21
- README.md CHANGES.txt MIT-LICENSE keight.gemspec setup.rb Rakefile
21
+ README.md CHANGES.md MIT-LICENSE keight.gemspec Rakefile
22
22
  bin/k8rb
23
- lib/keight.rb lib/keight/**/*.{[a-z]*} lib/keight/**/.{[a-z]*}
23
+ lib/keight.rb
24
24
  test/*_test.rb test/data/* test/oktest.rb
25
25
  bench/bench.rb bench/benchmarker.rb
26
26
  ]]
@@ -30,8 +30,6 @@ END
30
30
  o.test_file = "test/keight_test.rb"
31
31
 
32
32
  o.required_ruby_version = '>= 2.0'
33
- o.add_runtime_dependency 'rack', '~> 1.6'
34
- o.add_runtime_dependency 'baby_erubis', '~> 2.1', '>= 2.1.1'
35
33
  #o.add_development_dependency "oktest", "~> 0"
36
34
  o.add_development_dependency 'rack-test_app', '~> 1.0', '>= 1.0.0'
37
35
  end
@@ -1,7 +1,7 @@
1
1
  # -*- coding: utf-8 -*-
2
2
 
3
3
  ###
4
- ### $Release: 0.2.0 $
4
+ ### $Release: 0.3.0 $
5
5
  ### $Copyright: copyright(c) 2014-2016 kuwata-lab.com all rights reserved $
6
6
  ### $License: MIT License $
7
7
  ###
@@ -15,7 +15,7 @@ require 'digest/sha1'
15
15
 
16
16
  module K8
17
17
 
18
- RELEASE = '$Release: 0.2.0 $'.split()[1]
18
+ RELEASE = '$Release: 0.3.0 $'.split()[1]
19
19
 
20
20
  FILEPATH = __FILE__
21
21
 
@@ -30,60 +30,93 @@ module K8
30
30
  "TRACE" => :TRACE,
31
31
  }
32
32
 
33
+ ## ref: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes
33
34
  HTTP_RESPONSE_STATUS = {
34
35
  100 => "Continue",
35
36
  101 => "Switching Protocols",
36
- 102 => "Processing",
37
+ 102 => "Processing", # WebDAV; RFC 2518
38
+ 103 => "Checkpoint", # Unofficial
37
39
  200 => "OK",
38
40
  201 => "Created",
39
41
  202 => "Accepted",
40
- 203 => "Non-Authoritative Information",
42
+ 203 => "Non-Authoritative Information", # since HTTP/1.1
41
43
  204 => "No Content",
42
44
  205 => "Reset Content",
43
- 206 => "Partial Content",
44
- 207 => "Multi-Status",
45
- 208 => "Already Reported",
46
- 226 => "IM Used",
45
+ 206 => "Partial Content", # RFC 7233
46
+ 207 => "Multi-Status", # WebDAV; RFC 4918
47
+ 208 => "Already Reported", # WebDAV; RFC 5842
48
+ 226 => "IM Used", # RFC 3229
47
49
  300 => "Multiple Choices",
48
50
  301 => "Moved Permanently",
49
51
  302 => "Found",
50
- 303 => "See Other",
51
- 304 => "Not Modified",
52
- 305 => "Use Proxy",
53
- 307 => "Temporary Redirect",
52
+ 303 => "See Other", # since HTTP/1.1
53
+ 304 => "Not Modified", # RFC 7232
54
+ 305 => "Use Proxy", # since HTTP/1.1
55
+ 306 => "Switch Proxy",
56
+ 307 => "Temporary Redirect", # since HTTP/1.1
57
+ 308 => "Permanent Redirect", # RFC 7538
54
58
  400 => "Bad Request",
55
- 401 => "Unauthorized",
59
+ 401 => "Unauthorized", # RFC 7235
56
60
  402 => "Payment Required",
57
61
  403 => "Forbidden",
58
62
  404 => "Not Found",
59
63
  405 => "Method Not Allowed",
60
64
  406 => "Not Acceptable",
61
- 407 => "Proxy Authentication Required",
65
+ 407 => "Proxy Authentication Required", # RFC 7235
62
66
  408 => "Request Timeout",
63
67
  409 => "Conflict",
64
68
  410 => "Gone",
65
69
  411 => "Length Required",
66
- 412 => "Precondition Failed",
67
- 413 => "Request Entity Too Large",
68
- 414 => "Request-URI Too Long",
70
+ 412 => "Precondition Failed", # RFC 7232
71
+ 413 => "Payload Too Large", # RFC 7231
72
+ 414 => "URI Too Long", # RFC 7231
69
73
  415 => "Unsupported Media Type",
70
- 416 => "Requested Range Not Satisfiable",
74
+ 416 => "Range Not Satisfiable", # RFC 7233
71
75
  417 => "Expectation Failed",
72
- 418 => "I'm a teapot",
73
- 422 => "Unprocessable Entity",
74
- 423 => "Locked",
75
- 424 => "Failed Dependency",
76
+ 418 => "I'm a teapot", # RFC 2324
77
+ #420 => "Enhance Your Calm", # Twitter
78
+ #420 => "Method Failure", # Spring Framework
79
+ 421 => "Misdirected Request", # RFC 7540
80
+ 422 => "Unprocessable Entity", # WebDAV; RFC 4918
81
+ 423 => "Locked", # WebDAV; RFC 4918
82
+ 424 => "Failed Dependency", # WebDAV; RFC 4918
76
83
  426 => "Upgrade Required",
84
+ 428 => "Precondition Required", # RFC 6585
85
+ 429 => "Too Many Requests", # RFC 6585
86
+ 431 => "Request Header Fields Too Large", # RFC 6585
87
+ #440 => "Login Timeout", # IIS
88
+ 444 => "No Response", # nginx
89
+ #449 => "Retry With", # IIS
90
+ #450 => "Blocked by Windows Parental Controls", # Microsoft
91
+ #451 => "Redirect", # IIS
92
+ 451 => "Unavailable For Legal Reasons",
93
+ 495 => "SSL Certificate Error", # nginx
94
+ 496 => "SSL Certificate Required", # nginx
95
+ 497 => "HTTP Request Sent to HTTPS Port", # nginx
96
+ #498 => "Invalid Token", # Esri
97
+ 499 => "Client Closed Request", # nginx
98
+ #499 => "Request has been forbidden by antivirus",
99
+ #499 => "Token Required", # Esri
77
100
  500 => "Internal Server Error",
78
101
  501 => "Not Implemented",
79
102
  502 => "Bad Gateway",
80
103
  503 => "Service Unavailable",
81
104
  504 => "Gateway Timeout",
82
105
  505 => "HTTP Version Not Supported",
83
- 506 => "Variant Also Negotiates",
84
- 507 => "Insufficient Storage",
85
- 508 => "Loop Detected",
86
- 510 => "Not Extended",
106
+ 506 => "Variant Also Negotiates", # RFC 2295
107
+ 507 => "Insufficient Storage", # WebDAV; RFC 4918
108
+ 508 => "Loop Detected", # WebDAV; RFC 5842
109
+ 509 => "Bandwidth Limit Exceeded", # Apache Web Server/cPanel
110
+ 510 => "Not Extended", # RFC 2774
111
+ 511 => "Network Authentication Required", # RFC 6585
112
+ 520 => "Unknown Error", # CloudFlare
113
+ 521 => "Web Server Is Down", # CloudFlare
114
+ 522 => "Connection Timed Out", # CloudFlare
115
+ 523 => "Origin Is Unreachable", # CloudFlare
116
+ 524 => "A Timeout Occurred", # CloudFlare
117
+ 525 => "SSL Handshake Failed", # CloudFlare
118
+ 526 => "Invalid SSL Certificate", # CloudFlare
119
+ #530 => "Site is frozen", # Unofficial
87
120
  }.each {|_, v| v.freeze }
88
121
 
89
122
  MIME_TYPES = {
@@ -210,10 +243,10 @@ module K8
210
243
  end
211
244
 
212
245
  def _parse(query_str, separator)
213
- #; [!engr6] returns empty Hash object when query string is empty.
246
+ #; [!engr6] returns empty Hash/dict object when query string is empty.
214
247
  d = {}
215
248
  return d if query_str.empty?
216
- #; [!fzt3w] parses query string and returns Hahs object.
249
+ #; [!fzt3w] parses query string and returns Hash/dict object.
217
250
  equal = '='
218
251
  brackets = '[]'
219
252
  query_str.split(separator).each do |s|
@@ -363,15 +396,204 @@ module K8
363
396
  MONTHS = [nil, 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
364
397
  'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].freeze
365
398
 
366
- t = Time.now.utc
367
- if t.strftime('%a') != WEEKDAYS[t.wday] || t.strftime('%b') != MONTHS[t.month]
368
- def http_utc_time(utc_time)
369
- utc_time.utc? or
370
- raise ArgumentError.new("http_utc_time(#{utc_time.inspect}): expected UTC time but got local time.")
371
- return utc_time.strftime("#{WEEKDAYS[utc_time.wday]}, %d #{MONTHS[utc_time.month]} %Y %H:%M:%S GMT")
399
+ def _http_utc_time_without_locale(utc_time)
400
+ utc_time.utc? or
401
+ raise ArgumentError.new("http_utc_time(#{utc_time.inspect}): expected UTC time but got local time.")
402
+ return utc_time.strftime("#{WEEKDAYS[utc_time.wday]}, %d #{MONTHS[utc_time.month]} %Y %H:%M:%S GMT")
403
+ end
404
+
405
+ has_diff = (1..12).collect {|mo| Time.utc(2000, mo, 1) }.any? {|t|
406
+ http_utc_time(t) != _http_utc_time_without_locale(t)
407
+ }
408
+ alias http_utc_time _http_utc_time_without_locale if has_diff
409
+
410
+
411
+ ##
412
+ ## Temporary file which is removed automatically at end of response.
413
+ ##
414
+ ## Example:
415
+ ##
416
+ ## def do_download_csv()
417
+ ## ## generate temporary filepath
418
+ ## tmpfile = K8::Util::TemporaryFile.new
419
+ ## p tmpfile.path #=> ex: '/var/tmp/tmp-0372042668525.tmp'
420
+ ## p File.exist?(tmpfile.path) #=> false
421
+ ## ## create temporary file and content
422
+ ## sql = "select * from table1"
423
+ ## system("psql dbname -A -F',' -c \"#{sql}\" | gzip > #{tmpfile.path}")
424
+ ## p File.exist?(tmpfile.path) #=> true
425
+ ## ## set resonse headers
426
+ ## @resp.headers['Content-Type'] = 'text/csv;charset=UTF-8'
427
+ ## @resp.headers['Content-Length'] = File.size(tmpfile.path)
428
+ ## @resp.headers['Content-Disposition'] = 'attachment;filename="file1.csv"'
429
+ ## @resp.headers['Content-Encoding'] = "gzip"
430
+ ## ## return temporary file
431
+ ## return tmpfile # will be removed automatically at end of response!!
432
+ ## end
433
+ ##
434
+ class TemporaryFile
435
+
436
+ TMPDIR = (ENV['TMPDIR'] || proc {require 'etc'; Etc.systmpdir}.call || '/tmp').chomp('/')
437
+ CHUNK_SIZE = 8 * 1024 # 8KB
438
+
439
+ def self.new_path(tmpdir=nil)
440
+ #; [!hvnzd] generates new temorary filepath under temporary directory.
441
+ #; [!ulb2e] uses default temporary directory path if tmpdir is not specified.
442
+ tmpdir ||= TMPDIR
443
+ randstr = rand().to_s[2..14]
444
+ return "#{tmpdir}/tmp-#{randstr}.tmp"
445
+ end
446
+
447
+ def initialize(path=nil, tmpdir: nil, chunk_size: nil)
448
+ #; [!ljilm] generates temporary filepath automatically when filepath is not specified.
449
+ @path = path || self.class.new_path(tmpdir)
450
+ @tmpdir = tmpdir if tmpdir
451
+ @chunk_size = chunk_size if chunk_size
452
+ end
453
+
454
+ attr_reader :path
455
+
456
+ def each
457
+ #; [!d9suq] opens temporary file with binary mode.
458
+ #; [!68xdj] reads chunk size data from temporary file per iteration.
459
+ size = @chunk_size || CHUNK_SIZE
460
+ File.open(@path, 'rb') do |f|
461
+ yield f.read(size)
462
+ end
463
+ ensure
464
+ #; [!i0dmd] removes temporary file automatically at end of loop.
465
+ #; [!347an] removes temporary file even if error raised in block.
466
+ File.unlink @path
467
+ self
468
+ end
469
+
470
+ end
471
+
472
+
473
+ ##
474
+ ## Invoke shell command and responses it's output to client.
475
+ ##
476
+ ## Example:
477
+ ##
478
+ ## def do_download_csv()
479
+ ## ## for example, run SQL and generate CSV file (for postgresql)
480
+ ## sql = "select * from table1"
481
+ ## cmd = "psql -AF',' -U dbuser dbname | iconv -f UTF-8 -t CP932 -c | gzip"
482
+ ## shell_command = K8::Util::ShellCommand.new(cmd, input: sql) do
483
+ ## ## callback after sending response body
484
+ ## File.unlink(tempfile) if File.exist?(templfile) # for example
485
+ ## end
486
+ ## begin
487
+ ## return shell_command.start() do
488
+ ## ## callback before sending response body
489
+ ## @resp.headers['Content-Type'] = "text/csv;charset=Shift_JIS"
490
+ ## @resp.headers['Content-Disposition'] = 'attachment;filename="file.csv"'
491
+ ## @resp.headers['Content-Encoding'] = "gzip"
492
+ ## end
493
+ ## rescue K8::Util::ShellCommandError => ex
494
+ ## logger = @req.env['rack.logger']
495
+ ## logger.error(ex.message) if logger
496
+ ## @resp.status = 500
497
+ ## @resp.headers['Content-Type'] = "text/plain;charset=UTF-8"
498
+ ## return ex.message
499
+ ## end
500
+ ## end
501
+ ##
502
+ class ShellCommand
503
+
504
+ CHUNK_SIZE = 8 * 1024
505
+
506
+ def initialize(command, input: nil, chunk_size: nil, &teardown)
507
+ #; [!j95pi] takes shell command and input string.
508
+ @command = command # ex: "psql -AF',' dbname | gzip"
509
+ @input = input # ex: "select * from table1"
510
+ @chunk_size = chunk_size || CHUNK_SIZE
511
+ @teardown = teardown
512
+ @process_id = nil
513
+ @tuple = nil
514
+ end
515
+
516
+ attr_reader :command, :input, :process_id
517
+
518
+ def start
519
+ #; [!66uck] not allowed to start more than once.
520
+ @process_id.nil? or # TODO: close sout and serr
521
+ raise ShellCommandError.new("Already started (comand: #{@command.inspect})")
522
+ #; [!9seos] invokes shell command.
523
+ require 'open3' unless defined?(::Open3)
524
+ sin, sout, serr, waiter = ::Open3.popen3(@command)
525
+ @process_id = waiter.pid
526
+ size = @chunk_size
527
+ begin
528
+ #; [!d766y] writes input string if provided to initializer.
529
+ sin.write(input) if input
530
+ sin.close()
531
+ #; [!f651x] reads first chunk data.
532
+ #; [!cjstj] raises ShellCommandError when command prints something to stderr.
533
+ chunk = sout.read(size)
534
+ if chunk.nil?
535
+ error = serr.read()
536
+ log_error(error.to_s)
537
+ error = "Command failed: #{@command}" if ! error || error.empty?
538
+ raise ShellCommandError.new(error)
539
+ end
540
+ #; [!bt12n] saves stdout, stderr, command process, and first chunk data.
541
+ @tuple = [sout, serr, waiter, chunk]
542
+ #; [!kgnel] yields callback (if given) when command invoked successfully.
543
+ yield if block_given?
544
+ #; [!2989u] closes both stdout and stderr when error raised.
545
+ rescue => ex
546
+ sout.close()
547
+ serr.close()
548
+ raise
549
+ end
550
+ #; [!fp98i] returns self.
551
+ self
552
+ end
553
+
554
+ def each
555
+ #; [!ssgmm] '#start()' should be called before '#each()'.
556
+ @process_id or
557
+ raise ShellCommandError.new("Not started yet (command: #{@command.inspect}).")
558
+ #; [!vpmbw] yields each chunk data.
559
+ sout, serr, waiter, chunk = @tuple
560
+ @tuple = nil
561
+ yield chunk
562
+ size = @chunk_size
563
+ ex = nil
564
+ begin
565
+ while (chunk = sout.read(size))
566
+ yield chunk
567
+ end
568
+ #; [!70xdy] logs stderr output.
569
+ error = serr.read()
570
+ log_error(error) if error && ! error.empty?
571
+ rescue => ex
572
+ raise
573
+ ensure
574
+ #; [!2wll8] closes stdout and stderr, even if error raised.
575
+ sout.close()
576
+ serr.close()
577
+ #; [!0ebq5] calls callback specified at initializer with error object.
578
+ @teardown.yield(ex) if @teardown
579
+ end
580
+ #; [!ln8we] returns self.
581
+ self
582
+ end
583
+
584
+ def log_error(message)
585
+ $stderr.write("[ERROR] ShellCommand: #{@command.inspect} #-------\n")
586
+ $stderr.write(message); $stderr.write("\n") unless message.end_with?("\n")
587
+ $stderr.write("--------------------\n")
372
588
  end
589
+
373
590
  end
374
591
 
592
+
593
+ class ShellCommandError < StandardError
594
+ end
595
+
596
+
375
597
  end
376
598
 
377
599
 
@@ -426,300 +648,27 @@ module K8
426
648
  end
427
649
 
428
650
 
429
- class BaseError < Exception
430
- end
431
-
432
-
433
- class ContentTypeRequiredError < BaseError
651
+ class BaseError < StandardError
434
652
  end
435
653
 
436
654
 
437
- class UnknownActionMethodError < BaseError
655
+ class ActionMappingError < BaseError
438
656
  end
439
657
 
440
658
 
441
- class UnknownContentError < BaseError
659
+ class ContentTypeRequiredError < BaseError
442
660
  end
443
661
 
444
662
 
445
- class Request
446
-
447
- def initialize(env)
448
- #; [!yb9k9] sets @env.
449
- @env = env
450
- #; [!yo22o] sets @method as Symbol value.
451
- @method = HTTP_REQUEST_METHODS[env['REQUEST_METHOD']] or
452
- raise HTTPException.new(400, "#{env['REQUEST_METHOD'].inspect}: unknown request method.")
453
- #; [!twgmi] sets @path.
454
- @path = (x = env['PATH_INFO'])
455
- #; [!ae8ws] uses SCRIPT_NAME as urlpath when PATH_INFO is not provided.
456
- @path = env['SCRIPT_NAME'] if x.nil? || x.empty?
457
- end
458
-
459
- attr_reader :env, :method, :path
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
-
467
- def header(name)
468
- #; [!1z7wj] returns http header value from environment.
469
- return @env["HTTP_#{name.upcase.sub('-', '_')}"]
470
- end
471
-
472
- def request_method
473
- #; [!y8eos] returns env['REQUEST_METHOD'] as string.
474
- return @env['REQUEST_METHOD']
475
- end
476
-
477
- ##--
478
- #def get? ; @method == :GET ; end
479
- #def post? ; @method == :POST ; end
480
- #def put? ; @method == :PUT ; end
481
- #def delete? ; @method == :DELETE ; end
482
- #def head? ; @method == :HEAD ; end
483
- #def patch? ; @method == :PATCH ; end
484
- #def options? ; @method == :OPTIONS ; end
485
- #def trace? ; @method == :TRACE ; end
486
- ##++
487
-
488
- def script_name ; @env['SCRIPT_NAME' ] || ''; end # may be empty
489
- def path_info ; @env['PATH_INFO' ] || ''; end # may be empty
490
- def query_string ; @env['QUERY_STRING'] || ''; end # may be empty
491
- def server_name ; @env['SERVER_NAME' ] ; end # should NOT be empty
492
- def server_port ; @env['SERVER_PORT' ].to_i ; end # should NOT be empty
493
-
494
- def content_type
495
- #; [!95g9o] returns env['CONTENT_TYPE'].
496
- return @env['CONTENT_TYPE']
497
- end
498
-
499
- def content_length
500
- #; [!0wbek] returns env['CONTENT_LENGHT'] as integer.
501
- len = @env['CONTENT_LENGTH']
502
- return len ? len.to_i : len
503
- end
504
-
505
- def referer ; @env['HTTP_REFERER'] ; end
506
- def user_agent ; @env['HTTP_USER_AGENT'] ; end
507
- def x_requested_with ; @env['HTTP_X_REQUESTED_WITH']; end
508
-
509
- def xhr?
510
- #; [!hsgkg] returns true when 'X-Requested-With' header is 'XMLHttpRequest'.
511
- return self.x_requested_with == 'XMLHttpRequest'
512
- end
513
-
514
- def client_ip_addr
515
- #; [!e1uvg] returns 'X-Real-IP' header value if provided.
516
- addr = @env['HTTP_X_REAL_IP'] # nginx
517
- return addr if addr
518
- #; [!qdlyl] returns first item of 'X-Forwarded-For' header if provided.
519
- addr = @env['HTTP_X_FORWARDED_FOR'] # apache, squid, etc
520
- return addr.split(',').first if addr
521
- #; [!8nzjh] returns 'REMOTE_ADDR' if neighter 'X-Real-IP' nor 'X-Forwarded-For' provided.
522
- addr = @env['REMOTE_ADDR'] # http standard
523
- return addr
524
- end
525
-
526
- def scheme
527
- #; [!jytwy] returns 'https' when env['HTTPS'] is 'on'.
528
- return 'https' if @env['HTTPS'] == 'on'
529
- #; [!zg8r2] returns env['rack.url_scheme'] ('http' or 'https').
530
- return @env['rack.url_scheme']
531
- end
532
-
533
- def rack_version ; @env['rack.version'] ; end # ex: [1, 3]
534
- def rack_url_scheme ; @env['rack.url_scheme'] ; end # ex: 'http' or 'https'
535
- def rack_input ; @env['rack.input'] ; end # ex: $stdout
536
- def rack_errors ; @env['rack.errors'] ; end # ex: $stderr
537
- def rack_multithread ; @env['rack.multithread'] ; end # ex: true
538
- def rack_multiprocess ; @env['rack.multiprocess'] ; end # ex: true
539
- def rack_run_once ; @env['rack.run_once'] ; end # ex: false
540
- def rack_session ; @env['rack.session'] ; end # ex: {}
541
- def rack_logger ; @env['rack.logger'] ; end # ex: Logger.new
542
- def rack_hijack ; @env['rack.hijack'] ; end # ex: callable object
543
- def rack_hijack? ; @env['rack.hijack?'] ; end # ex: true or false
544
- def rack_hijack_io ; @env['rack.hijack_io'] ; end # ex: socket object
545
-
546
- def params_query
547
- #; [!6ezqw] parses QUERY_STRING and returns it as Hash object.
548
- #; [!o0ws7] unquotes both keys and values.
549
- return @params_query ||= Util.parse_query_string(@env['QUERY_STRING'] || "")
550
- end
551
- alias query params_query
552
-
553
- MAX_POST_SIZE = 10*1024*1024
554
- MAX_MULTIPART_SIZE = 100*1024*1024
555
-
556
- def params_form
557
- d = @params_form
558
- return d if d
559
- #
560
- d = @params_form = _parse_post_data(:form)
561
- return d
562
- end
563
- alias form params_form
564
-
565
- def params_multipart
566
- d1 = @params_form
567
- d2 = @params_file
568
- return d1, d2 if d1 && d2
569
- d1, d2 = _parse_post_data(:multipart)
570
- @params_form = d1; @params_file = d2
571
- return d1, d2
572
- end
573
- alias multipart params_multipart
574
-
575
- def params_json
576
- d = @params_json
577
- return d if d
578
- d = @params_json = _parse_post_data(:json)
579
- return d
580
- end
581
- alias json params_json
582
-
583
- def _parse_post_data(kind)
584
- #; [!q88w9] raises error when content length is missing.
585
- cont_len = @env['CONTENT_LENGTH'] or
586
- raise HttpException.new(400, 'Content-Length header expected.')
587
- #; [!gi4qq] raises error when content length is invalid.
588
- cont_len =~ /\A\d+\z/ or
589
- raise HttpException.new(400, 'Content-Length should be an integer.')
590
- #
591
- len = cont_len.to_i
592
- case @env['CONTENT_TYPE']
593
- #; [!59ad2] parses form parameters and returns it as Hash object when form requested.
594
- when 'application/x-www-form-urlencoded'
595
- kind == :form or
596
- raise HttpException.new(400, 'unexpected form data (expected multipart).')
597
- #; [!puxlr] raises error when content length is too long (> 10MB).
598
- len <= MAX_POST_SIZE or
599
- raise HttpException.new(400, 'Content-Length is too long.')
600
- qstr = @env['rack.input'].read(len)
601
- d = Util.parse_query_string(qstr)
602
- return d
603
- #; [!y1jng] parses multipart when multipart form requested.
604
- when /\Amultipart\/form-data(?:;\s*boundary=(.*))?/
605
- kind == :multipart or
606
- raise HttpException.new(400, 'unexpected multipart data.')
607
- boundary = $1 or
608
- raise HttpException.new(400, 'bounday attribute of multipart required.')
609
- #; [!mtx6t] raises error when content length of multipart is too long (> 100MB).
610
- len <= MAX_MULTIPART_SIZE or
611
- raise HttpException.new(400, 'Content-Length of multipart is too long.')
612
- d1, d2 = Util.parse_multipart(@env['rack.input'], boundary, len, nil, nil)
613
- return d1, d2
614
- #; [!ugik5] parses json data and returns it as hash object when json data is sent.
615
- when /\Aapplication\/json\b/
616
- kind == :json or
617
- raise HttpException.new(400, 'unexpected JSON data.')
618
- json_str = @env['rack.input'].read(10*1024*1024) # TODO
619
- d = JSON.parse(json_str)
620
- #; [!p9ybb] raises error when not a form data.
621
- else
622
- raise HttpException.new(400, 'POST data expected, but not.')
623
- end
624
- end
625
- private :_parse_post_data
626
-
627
- def params
628
- #; [!erlc7] parses QUERY_STRING when request method is GET or HEAD.
629
- #; [!cr0zj] parses JSON when content type is 'application/json'.
630
- #; [!j2lno] parses form parameters when content type is 'application/x-www-form-urlencoded'.
631
- #; [!4rmn9] parses multipart when content type is 'multipart/form-data'.
632
- if @method == :GET || @method == :HEAD
633
- return params_query()
634
- end
635
- case @env['CONTENT_TYPE']
636
- when /\Aapplication\/json\b/
637
- return params_json()
638
- when /\Aapplication\/x-www-form-urlencoded\b/
639
- return params_form()
640
- when /\Amultipart\/form-data\b/
641
- return params_multipart()
642
- else
643
- return {}
644
- end
645
- end
646
-
647
- def cookies
648
- #; [!c9pwr] parses cookie data and returns it as hash object.
649
- return @cookies ||= Util.parse_cookie_string(@env['HTTP_COOKIE'] || "")
650
- end
651
-
652
- def clear
653
- #; [!0jdal] removes uploaded files.
654
- d = nil
655
- d.each {|_, uploaded| uploaded.clean() } if (d = @params_file)
656
- end
657
-
663
+ class UnknownActionMethodError < BaseError
658
664
  end
659
665
 
660
666
 
661
- class Response
662
-
663
- def initialize
664
- @status_code = 200
665
- @headers = {}
666
- end
667
-
668
- attr_accessor :status_code
669
- attr_reader :headers
670
- ## for compatibility with Rack::Response
671
- alias status status_code
672
- alias status= status_code=
673
-
674
- def content_type
675
- return @headers['Content-Type']
676
- end
677
-
678
- def content_type=(content_type)
679
- @headers['Content-Type'] = content_type
680
- end
681
-
682
- def content_length
683
- s = @headers['Content-Length']
684
- return s ? s.to_i : nil
685
- end
686
-
687
- def content_length=(length)
688
- @headers['Content-Length'] = length.to_s
689
- end
690
-
691
- def set_cookie(name, value, domain: nil, path: nil, expires: nil, max_age: nil, httponly: nil, secure: nil)
692
- s = "#{name}=#{value}"
693
- s << "; Domain=#{domain}" if domain
694
- s << "; Path=#{path}" if path
695
- s << "; Expires=#{expires}" if expires
696
- s << "; Max-Age=#{max_age}" if max_age
697
- s << "; HttpOnly" if httponly
698
- s << "; Secure" if secure
699
- value = @headers['Set-Cookie']
700
- @headers['Set-Cookie'] = value ? (value << "\n" << s) : s
701
- return value
702
- end
703
-
704
- def clear
705
- end
706
-
667
+ class UnknownContentError < BaseError
707
668
  end
708
669
 
709
670
 
710
- REQUEST_CLASS = Request
711
- RESPONSE_CLASS = Response
712
-
713
- def self.REQUEST_CLASS=(klass)
714
- #; [!7uqb4] changes default request class.
715
- remove_const :REQUEST_CLASS
716
- const_set :REQUEST_CLASS, klass
717
- end
718
-
719
- def self.RESPONSE_CLASS=(klass)
720
- #; [!c1bd0] changes default response class.
721
- remove_const :RESPONSE_CLASS
722
- const_set :RESPONSE_CLASS, klass
671
+ class PayloadParseError < BaseError
723
672
  end
724
673
 
725
674
 
@@ -730,20 +679,19 @@ module K8
730
679
  #; [!uotpb] accepts request and response objects.
731
680
  @req = req
732
681
  @resp = resp
733
- #; [!7sfyf] sets session object.
734
- @sess = req.env['rack.session']
735
682
  end
736
683
 
737
684
  attr_reader :req, :resp, :sess
738
685
 
739
- def handle_action(action_method, urlpath_params)
740
- @current_action = action_method
686
+ def handle_action(action_name, urlpath_args)
687
+ @action_name = action_name
688
+ @action_args = urlpath_args
741
689
  ex = nil
742
690
  begin
743
691
  #; [!5jnx6] calls '#before_action()' before handling request.
744
692
  before_action()
745
693
  #; [!ddgx3] invokes action method with urlpath params.
746
- content = invoke_action(action_method, urlpath_params)
694
+ content = invoke_action(action_name, urlpath_args)
747
695
  #; [!aqa4e] returns content.
748
696
  return handle_content(content)
749
697
  rescue Exception => ex
@@ -763,8 +711,8 @@ module K8
763
711
  def after_action(ex)
764
712
  end
765
713
 
766
- def invoke_action(action_method, urlpath_params)
767
- return self.__send__(action_method, *urlpath_params)
714
+ def invoke_action(action_name, urlpath_args)
715
+ return self.__send__(action_name, *urlpath_args)
768
716
  end
769
717
 
770
718
  def handle_content(content)
@@ -776,32 +724,39 @@ module K8
776
724
  ## mapping '/', :GET=>:do_index, :POST=>:do_create
777
725
  ## mapping '/{id}', :GET=>:do_show, :PUT=>:do_update, :DELETE=>:do_delete
778
726
  ##
779
- def self.mapping(urlpath_pattern, methods={})
727
+ def self.mapping(urlpath_pattern, action_methods={})
728
+ action_methods.each do |req_meth, action_name|
729
+ HTTP_REQUEST_METHODS[req_meth.to_s] or
730
+ raise ArgumentError.new("#{req_meth.inspect}: unknown request method.")
731
+ req_meth.is_a?(Symbol) or
732
+ raise ArgumentError.new("#{req_meth.inspect}: should be a Symbol but got #{req_meth.class.name}")
733
+ end
780
734
  #; [!o148k] maps urlpath pattern and request methods.
781
- self._action_method_mapping.map(urlpath_pattern, methods)
735
+ self._mappings << [urlpath_pattern, action_methods]
736
+ return self
782
737
  end
783
738
 
784
- def self._action_method_mapping
785
- return @action_method_mapping ||= ActionMethodMapping.new
739
+ def self._mappings
740
+ return @action_method_mapping ||= []
786
741
  end
787
742
 
788
743
  def self._build_action_info(full_urlpath_pattern) # :nodoc:
789
744
  #; [!ordhc] build ActionInfo objects for each action methods.
790
745
  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|
746
+ d = {}
747
+ self._mappings.each do |urlpath_pat, action_methods|
748
+ action_methods.each do |req_meth, action_name|
794
749
  info = ActionInfo.create(req_meth, "#{parent}#{urlpath_pat}")
795
- @action_infos[action_method_name] = info
750
+ d[action_name] = info
796
751
  end
797
752
  end
798
- @action_infos
753
+ @action_infos = d
799
754
  end
800
755
 
801
- def self.[](action_method_name)
756
+ def self.[](action_name)
802
757
  #; [!1tq8z] returns ActionInfo object corresponding to action method.
803
758
  #; [!6g2iw] returns nil when not mounted yet.
804
- return (@action_infos || {})[action_method_name]
759
+ return (@action_infos || {})[action_name]
805
760
  end
806
761
 
807
762
  end
@@ -817,9 +772,15 @@ module K8
817
772
  alias response resp # just for compatibility with other frameworks; use @resp!
818
773
  alias session sess # just for compatibility with other frameworks; use @sess!
819
774
 
820
- protected
821
-
822
- def before_action
775
+ def initialize(req, res)
776
+ super
777
+ #; [!7sfyf] sets session object.
778
+ @sess = req.env['rack.session']
779
+ end
780
+
781
+ protected
782
+
783
+ def before_action
823
784
  csrf_protection() if csrf_protection_required?()
824
785
  end
825
786
 
@@ -833,7 +794,7 @@ module K8
833
794
  end
834
795
  end
835
796
 
836
- def invoke_action(action_method, urlpath_params)
797
+ def invoke_action(action_name, urlpath_args)
837
798
  begin
838
799
  return super
839
800
  #; [!d5v0l] handles exception when handler method defined.
@@ -942,7 +903,7 @@ module K8
942
903
  return false if @req.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
943
904
  #; [!vwrqv] returns true when request method is one of POST, PUT, or DELETE.
944
905
  #; [!jfhla] returns true when request method is GET or HEAD.
945
- req_meth = @req.method
906
+ req_meth = @req.meth
946
907
  return req_meth == :POST || req_meth == :PUT || req_meth == :DELETE
947
908
  end
948
909
 
@@ -1032,27 +993,28 @@ module K8
1032
993
  class ActionInfo
1033
994
 
1034
995
  def initialize(method, urlpath_format)
1035
- @method = method
996
+ @meth = method
1036
997
  @urlpath_format = urlpath_format # ex: '/books/%s/comments/%s'
1037
998
  end
1038
999
 
1039
- attr_reader :method
1000
+ attr_reader :meth
1040
1001
 
1041
- def method(name=nil)
1042
- return name ? super : @method
1002
+ ## (experimental; use #meth instead)
1003
+ def method(name=nil) # :nodoc:
1004
+ return name ? super : @meth
1043
1005
  end
1044
1006
 
1045
- def urlpath(*args)
1007
+ def path(*args)
1046
1008
  return @urlpath_format % args
1047
1009
  end
1048
1010
 
1049
1011
  def form_action_attr(*args)
1050
1012
  #; [!qyhkm] returns '/api/books/123' when method is POST.
1051
1013
  #; [!kogyx] returns '/api/books/123?_method=PUT' when method is not POST.
1052
- if @method == 'POST'
1053
- return urlpath(*args)
1014
+ if @meth == 'POST'
1015
+ return path(*args)
1054
1016
  else
1055
- return "#{urlpath(*args)}?_method=#{@method}"
1017
+ return "#{path(*args)}?_method=#{@meth}"
1056
1018
  end
1057
1019
  end
1058
1020
 
@@ -1079,153 +1041,252 @@ module K8
1079
1041
  end
1080
1042
 
1081
1043
  class ActionInfo0 < ActionInfo # :nodoc:
1082
- def urlpath(); @urlpath_format; end
1044
+ def path(); @urlpath_format; end
1083
1045
  end
1084
1046
 
1085
1047
  class ActionInfo1 < ActionInfo # :nodoc:
1086
- def urlpath(a); @urlpath_format % [a]; end
1048
+ def path(a); @urlpath_format % [a]; end
1087
1049
  end
1088
1050
 
1089
1051
  class ActionInfo2 < ActionInfo # :nodoc:
1090
- def urlpath(a, b); @urlpath_format % [a, b]; end
1052
+ def path(a, b); @urlpath_format % [a, b]; end
1091
1053
  end
1092
1054
 
1093
1055
  class ActionInfo3 < ActionInfo # :nodoc:
1094
- def urlpath(a, b, c); @urlpath_format % [a, b, c]; end
1056
+ def path(a, b, c); @urlpath_format % [a, b, c]; end
1095
1057
  end
1096
1058
 
1097
1059
  class ActionInfo4 < ActionInfo # :nodoc:
1098
- def urlpath(a, b, c, d); @urlpath_format % [a, b, c, d]; end
1060
+ def path(a, b, c, d); @urlpath_format % [a, b, c, d]; end
1099
1061
  end
1100
1062
 
1101
1063
  ActionInfo::SUBCLASSES << ActionInfo0 << ActionInfo1 << ActionInfo2 << ActionInfo3 << ActionInfo4
1102
1064
 
1103
1065
 
1104
- class DefaultPatterns
1066
+ class ActionMapping
1105
1067
 
1106
- def initialize
1107
- @patterns = []
1068
+ def initialize(urlpath_mapping, urlpath_cache_size: 0,
1069
+ enable_urlpath_param_range: true)
1070
+ #; [!34o67] keyword arg 'enable_urlpath_param_range' controls to generate range object or not.
1071
+ @enable_urlpath_param_range = enable_urlpath_param_range
1072
+ #; [!buj0d] prepares LRU cache if cache size specified.
1073
+ @urlpath_cache_size = urlpath_cache_size
1074
+ @urlpath_lru_cache = urlpath_cache_size > 0 ? {} : nil
1075
+ #; [!wsz8g] compiles urlpath mapping passed.
1076
+ @fixed_endpoints = {} # urlpath patterns which have no urlpath params
1077
+ @variable_endpoints = [] # urlpath patterns which have any ulrpath param
1078
+ @all_endpoints = [] # all urlpath patterns (fixed + variable)
1079
+ @urlpath_rexp = build(urlpath_mapping)
1108
1080
  end
1109
1081
 
1110
- def register(urlpath_param_name, default_pattern='[^/]*?', &converter)
1111
- #; [!yfsom] registers urlpath param name, default pattern and converter block.
1112
- @patterns << [urlpath_param_name, default_pattern, converter]
1113
- self
1114
- end
1082
+ attr_reader :urlpath_rexp
1115
1083
 
1116
- def unregister(urlpath_param_name)
1117
- #; [!3gplv] deletes matched record.
1118
- @patterns.delete_if {|tuple| tuple[0] == urlpath_param_name }
1119
- self
1120
- end
1084
+ private
1121
1085
 
1122
- def lookup(urlpath_param_name)
1123
- #; [!dvbqx] returns default pattern string and converter proc when matched.
1124
- #; [!6hblo] returns '[^/]+?' and nil as default pattern and converter proc when nothing matched.
1125
- for str_or_rexp, default_pat, converter in @patterns
1126
- return default_pat, converter if str_or_rexp === urlpath_param_name
1086
+ def build(urlpath_mapping)
1087
+ #; [!6f3vl] compiles urlpath mapping.
1088
+ empty_pargs = [].freeze
1089
+ rexp_str = traverse(urlpath_mapping, "") do |full_urlpath, action_class, action_methods|
1090
+ #; [!z2iax] classifies urlpath contains any parameter as variable one.
1091
+ if has_urlpath_param?(full_urlpath)
1092
+ pattern, pnames, procs = compile_urlpath(full_urlpath, true)
1093
+ rexp = Regexp.compile("\\A#{pattern}\\z")
1094
+ range = @enable_urlpath_param_range ? range_of_urlpath_param(full_urlpath) : nil
1095
+ tuple = [full_urlpath, action_class, action_methods, rexp, pnames, procs, range]
1096
+ @variable_endpoints << tuple
1097
+ #; [!rvdes] classifies urlpath contains no parameters as fixed one.
1098
+ else
1099
+ tuple = [full_urlpath, action_class, action_methods, empty_pargs]
1100
+ @fixed_endpoints[full_urlpath] = tuple
1101
+ end
1102
+ #
1103
+ @all_endpoints << tuple
1127
1104
  end
1128
- return '[^/]+?', nil
1105
+ return Regexp.compile("\\A#{rexp_str}\\z")
1129
1106
  end
1130
1107
 
1131
- end
1132
-
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()
1108
+ def traverse(urlpath_mapping, base_urlpath="", &block)
1109
+ buf = []
1110
+ urlpath_mapping.each do |urlpath, target|
1111
+ curr_urlpath = "#{base_urlpath}#{urlpath}"
1112
+ rexp_str = nil
1113
+ #; [!w45ad] can compile nested array.
1114
+ if target.is_a?(Array)
1115
+ rexp_str = traverse(target, curr_urlpath, &block)
1116
+ #; [!wd2eb] accepts subclass of Action class.
1117
+ else
1118
+ #; [!l2kz5] requires library when filepath and classname specified.
1119
+ klass = target.is_a?(String) ? require_action_class(target) : target
1120
+ #; [!irt5g] raises TypeError when unknown object specified.
1121
+ klass.is_a?(Class) && klass < BaseAction or
1122
+ raise TypeError.new("Action class or nested array expected, but got #{klass.inspect}")
1123
+ #; [!6xwhq] builds action infos for each action methods.
1124
+ action_class = klass
1125
+ action_class._build_action_info(curr_urlpath)
1126
+ #
1127
+ buf2 = []
1128
+ action_class._mappings.each do |upath, action_methods|
1129
+ validate(action_class, action_methods)
1130
+ #; [!m51yy] regards '.*' at end of urlpath pattern as extension.
1131
+ upath = upath.sub(/\.\*\z/, '{_:<(?:\.\w+)?>}')
1132
+ #
1133
+ full_urlpath = "#{curr_urlpath}#{upath}"
1134
+ if has_urlpath_param?(full_urlpath)
1135
+ buf2 << "#{compile_urlpath(upath)[0]}(\\z)" # ex: /{id} -> /\d+(\z)
1136
+ end
1137
+ yield full_urlpath, action_class, action_methods
1138
+ end
1139
+ n = buf2.length
1140
+ rexp_str = (n == 0 ? nil : n == 1 ? buf2[0] : union_urlpaths(buf2))
1141
+ end
1142
+ buf << "#{compile_urlpath(urlpath)[0]}#{rexp_str}" if rexp_str
1143
+ end
1144
+ #; [!169ad] removes unnecessary grouping.
1145
+ n = buf.length
1146
+ rexp_string = (n == 0 ? nil : n == 1 ? buf[0] : "(?:#{buf.join('|')})")
1147
+ return rexp_string # ex: '/books/\d+(?:(\z)|/edit(\z))'
1148
+ end
1152
1149
 
1150
+ def union_urlpaths(upaths)
1151
+ #; [!abj34] ex: (?:/\d+(\z)|/\d+/edit(\z)) -> /d+(?:(\z)|/edit(\z))
1152
+ prefixes = URLPATH_PARAM_PREFIXES # ex: ['/\d+', '/[^/]+']
1153
+ prefix = prefixes.find {|s| upaths.all? {|x| x.start_with?(s) } }
1154
+ upaths = upaths.map {|x| x[prefix.length..-1] } if prefix
1155
+ return "#{prefix}(?:#{upaths.join('|')})"
1156
+ end
1153
1157
 
1154
- class ActionMethodMapping
1158
+ #; [!92jcn] '{' and '}' are available in urlpath param pattern.
1159
+ #; [!do1zi] param type is optional (ex: '{id}' or '{id:<\d+>}').
1160
+ #; [!my6as] param pattern is optional (ex: '{id}' or '{id:int}').
1161
+ URLPATH_PARAM_REXP = /\{(\w*)(?::(\w*))?(?:<(.*?)>)?\}/
1155
1162
 
1156
- def initialize
1157
- @mappings = []
1163
+ def has_urlpath_param?(urlpath)
1164
+ return urlpath.include?('{')
1158
1165
  end
1159
1166
 
1160
- ##
1161
- ## ex:
1162
- ## map '/', :GET=>:do_index, :POST=>:do_create
1163
- ## map '/{id:\d+}', :GET=>:do_show, :PUT=>:do_update, :DELETE=>:do_delete
1164
- ##
1165
- def map(urlpath_pattern, action_methods={})
1166
- action_methods = _normalize(action_methods)
1167
- #; [!s7cs9] maps urlpath and methods.
1168
- #; [!o6cxr] returns self.
1169
- @mappings << [urlpath_pattern, action_methods]
1170
- return self
1167
+ ## ex: '/books/{id}', true -> ['/books/(\d+)', [['id', 'int', proc{|x| x.to_i}]]]
1168
+ def compile_urlpath(urlpath_pat, enable_capture=false)
1169
+ #; [!iln54] param names and conveter procs are nil when no urlpath params.
1170
+ pnames = nil # urlpath parameter names (ex: ['id'])
1171
+ procs = nil # proc objects to convert parameter value (ex: [proc{|x| x.to_i}])
1172
+ #
1173
+ rexp_str = urlpath_pat.gsub(URLPATH_PARAM_REXP) {
1174
+ #; [!3diea] '{id:<\d+>}' is ok but '{id<\d+>}' raises error.
1175
+ pname, ptype, pat = $1, $2, $3
1176
+ if ! ptype && pat # when ':ptype' is missing but '<pat>' exists
1177
+ raise ActionMappingError.new("'#{urlpath_pat}': missing ':' between param name and pattern (ex: '{id:<\\d+>}' is OK but '{id<\\d+>}' is not).")
1178
+ end
1179
+ ptype, pat, proc_ = resolve_param_type(pname, ptype, pat, urlpath_pat)
1180
+ #; [!lhtiz] skips empty param name.
1181
+ #; [!66zas] skips param name starting with '_'.
1182
+ skip = pname.empty? || pname.start_with?('_')
1183
+ pnames ||= []; pnames << pname unless skip
1184
+ procs ||= []; procs << proc_ unless skip
1185
+ #; [!bi7gr] captures urlpath params when 2nd argument is truthy.
1186
+ #; [!mprbx] ex: '/{id:x|y}' -> '/(x|y)', '/{:x|y}' -> '/(?:x|y)'
1187
+ if enable_capture && ! skip
1188
+ pat = "(#{pat})"
1189
+ elsif pat =~ /\|/
1190
+ pat = "(?:#{pat})"
1191
+ end
1192
+ pat
1193
+ }
1194
+ #; [!awfgs] returns regexp string, param names, and converter procs.
1195
+ return rexp_str, pnames, procs # ex: '/books/(\d+)', ['id'], [proc{|x| x.to_i}]}]
1171
1196
  end
1172
1197
 
1173
- def _normalize(action_methods)
1174
- d = {}
1175
- action_methods.each do |req_meth, action_method|
1176
- k = HTTP_REQUEST_METHODS[req_meth.to_s] or
1177
- raise ArgumentError.new("#{req_meth.inspect}: unknown request method.")
1178
- v = action_method
1179
- d[k] = v.is_a?(Symbol) ? v : v.to_s.intern
1198
+ _to_date = proc {|s|
1199
+ begin
1200
+ yr, mo, dy = s.split('-')
1201
+ Date.new(yr.to_i, mo.to_i, dy.to_i)
1202
+ rescue
1203
+ raise HttpException.new(404)
1204
+ end
1205
+ }
1206
+ URLPATH_PARAM_TYPES = [
1207
+ # ptype , pname regexp , urlpath pattern , converter
1208
+ ['int' , /(?:^|_)id\z/ , '\d+' , proc {|s| s.to_i }],
1209
+ ['date' , /(?:^|_)date\z/ , '\d\d\d\d-\d\d-\d\d' , _to_date ],
1210
+ ['str' , nil , '[^/]+' , nil ],
1211
+ ]
1212
+ _to_date = nil
1213
+ URLPATH_PARAM_PREFIXES = URLPATH_PARAM_TYPES.collect {|t| "/#{t[2]}" }
1214
+
1215
+ def resolve_param_type(pname, ptype, pattern, urlpath)
1216
+ tuple = nil
1217
+ if ! ptype || ptype.empty?
1218
+ tuple = URLPATH_PARAM_TYPES.find {|t| pname =~ t[1] }
1219
+ ptype = tuple ? tuple[0] : 'str'
1180
1220
  end
1181
- return d # ex: {:GET=>:do_index, :POST=>:do_create}
1221
+ tuple ||= URLPATH_PARAM_TYPES.find {|t| t[0] == ptype } or
1222
+ raise ActionMappingError.new("'#{urlpath}': unknown param type '#{ptype}'.")
1223
+ pattern = tuple[2] if ! pattern || pattern.empty?
1224
+ converter = tuple[3] # ex: '123' -> 123, '2000-01-01' -> Date.new(2000, 1, 1)
1225
+ return ptype, pattern, converter
1182
1226
  end
1183
- private :_normalize
1184
1227
 
1185
- def each
1186
- #; [!62y5q] yields each urlpath pattern and action methods.
1187
- @mappings.each do |urlpath_pattern, action_methods|
1188
- yield urlpath_pattern, action_methods
1228
+ ## range object to retrieve urlpath parameter value faster than Regexp matching
1229
+ ## ex:
1230
+ ## urlpath_pat == '/books/{id}/edit'
1231
+ ## range = _range_of_urlpath_param(urlpath_pat)
1232
+ ## p range #=> 7..-6 (Range object)
1233
+ ## p "/books/123/edit"[range] #=> '123'
1234
+ def range_of_urlpath_param(urlpath)
1235
+ i = 0
1236
+ m = nil
1237
+ urlpath.scan(URLPATH_PARAM_REXP) do
1238
+ i += 1
1239
+ m = Regexp.last_match
1189
1240
  end
1190
- self
1241
+ return nil unless i == 1
1242
+ return m.begin(0) .. (m.end(0) - urlpath.length - 1) # ex: 7..-6 (Range object)
1191
1243
  end
1192
1244
 
1193
- end
1194
-
1195
-
1196
- class ActionMapping
1197
-
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)
1245
+ ## ex: './api/admin/books:Admin::BookAPI' -> Admin::BookAPI
1246
+ def require_action_class(filepath_and_classname)
1247
+ #; [!px9jy] requires file and finds class object.
1248
+ str = filepath_and_classname # ex: './admin/api/book:Admin::BookAPI'
1249
+ filepath, classname = filepath_and_classname.split(':', 2)
1250
+ begin
1251
+ require filepath
1252
+ rescue LoadError => ex
1253
+ #; [!dlcks] don't rescue LoadError when it is not related to argument.
1254
+ raise unless ex.path == filepath
1255
+ #; [!mngjz] raises error when failed to load file.
1256
+ raise LoadError.new("'#{str}': cannot load '#{filepath}'.")
1257
+ end
1258
+ #; [!8n6pf] class name may have module prefix name.
1259
+ #; [!6lv7l] raises error when action class not found.
1260
+ begin
1261
+ klass = classname.split('::').inject(Object) {|cls, x| cls.const_get(x) }
1262
+ rescue NameError
1263
+ raise NameError.new("'#{str}': class not found (#{classname}).")
1264
+ end
1265
+ #; [!thf7t] raises TypeError when not a class.
1266
+ klass.is_a?(Class) or
1267
+ raise TypeError.new("'#{str}': class name expected but got #{klass.inspect}.")
1268
+ #; [!yqcgx] raises TypeError when not a subclass of K8::Action.
1269
+ klass < Action or
1270
+ raise TypeError.new("'#{str}': expected subclass of K8::Action but not.")
1271
+ #
1272
+ return klass
1208
1273
  end
1209
1274
 
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")
1217
- return self
1275
+ ## raises error when action method is not defined in action class
1276
+ def validate(action_class, action_methods)
1277
+ #; [!ue766] raises error when action method is not defined in action class.
1278
+ action_methods.each do |req_meth, action_name|
1279
+ action_class.method_defined?(action_name) or
1280
+ raise UnknownActionMethodError.new("#{req_meth.inspect}=>#{action_name.inspect}: unknown action method in #{action_class}.")
1281
+ end
1218
1282
  end
1219
1283
 
1220
- EMPTY_ARRAY = [].freeze # :nodoc:
1284
+ public
1221
1285
 
1222
- def lookup(req_urlpath)
1286
+ def find(req_urlpath)
1223
1287
  #; [!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
1228
- end
1288
+ tuple = @fixed_endpoints[req_urlpath]
1289
+ return tuple[1..-1] if tuple # ex: [BooksAction, {:GET=>:do_index}, []]
1229
1290
  #; [!uqwr7] uses LRU as cache algorithm.
1230
1291
  cache = @urlpath_lru_cache
1231
1292
  if cache && (result = cache.delete(req_urlpath))
@@ -1256,8 +1317,8 @@ module K8
1256
1317
  else ; procs.zip(strs).map {|pr, v| pr ? pr.call(v) : v }
1257
1318
  end # ex: ["123"] -> [123]
1258
1319
  end
1259
- #; [!jyxlm] returns action class and methods, parameter names and values.
1260
- result = [action_class, action_methods, pnames, pvalues]
1320
+ #; [!jyxlm] returns action class, action methods and urlpath param args.
1321
+ result = [action_class, action_methods, pvalues]
1261
1322
  #; [!uqwr7] stores result into cache if cache is enabled.
1262
1323
  if cache
1263
1324
  cache[req_urlpath] = result
@@ -1279,162 +1340,292 @@ module K8
1279
1340
  self
1280
1341
  end
1281
1342
 
1282
- private
1343
+ end
1283
1344
 
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.
1300
- else
1301
- raise TypeError.new("Action class or nested array expected, but got #{target.inspect}")
1302
- end
1303
- end
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))'
1345
+
1346
+ class RackRequest
1347
+
1348
+ def initialize(env)
1349
+ #; [!yb9k9] sets @env.
1350
+ @env = env
1351
+ #; [!yo22o] sets @meth as Symbol value.
1352
+ @meth = HTTP_REQUEST_METHODS[env['REQUEST_METHOD']] or
1353
+ raise HTTPException.new(400, "#{env['REQUEST_METHOD'].inspect}: unknown request method.")
1354
+ #; [!twgmi] sets @path.
1355
+ @path = (x = env['PATH_INFO'])
1356
+ #; [!ae8ws] uses SCRIPT_NAME as urlpath when PATH_INFO is not provided.
1357
+ @path = env['SCRIPT_NAME'] if x.nil? || x.empty?
1308
1358
  end
1309
1359
 
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
1330
- end
1331
- #
1332
- @all_endpoints << tuple
1333
- end
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))'
1360
+ attr_reader :env, :meth, :path
1361
+
1362
+ ## (experimental; use #meth instead)
1363
+ def method(name=nil) # :nodoc:
1364
+ #; [!084jo] returns current request method when argument is not specified.
1365
+ #; [!gwskf] calls Object#method() when argument specified.
1366
+ return name ? super : @meth
1339
1367
  end
1340
1368
 
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
1369
+ def path_ext
1370
+ #; [!tf6yz] returns extension of request path such as '.html' or '.json'.
1371
+ #; [!xnurj] returns empty string when no extension.
1372
+ return File.extname(@path)
1350
1373
  end
1351
1374
 
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})"
1382
- end
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}]}]
1375
+ def header(name)
1376
+ #; [!1z7wj] returns http header value from environment.
1377
+ return @env["HTTP_#{name.upcase.sub('-', '_')}"]
1387
1378
  end
1388
1379
 
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
1380
+ def request_method
1381
+ #; [!y8eos] returns env['REQUEST_METHOD'] as string.
1382
+ return @env['REQUEST_METHOD']
1395
1383
  end
1396
1384
 
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)
1385
+ ##--
1386
+ #def get? ; @meth == :GET ; end
1387
+ #def post? ; @meth == :POST ; end
1388
+ #def put? ; @meth == :PUT ; end
1389
+ #def delete? ; @meth == :DELETE ; end
1390
+ #def head? ; @meth == :HEAD ; end
1391
+ #def patch? ; @meth == :PATCH ; end
1392
+ #def options? ; @meth == :OPTIONS ; end
1393
+ #def trace? ; @meth == :TRACE ; end
1394
+ ##++
1395
+
1396
+ def script_name ; @env['SCRIPT_NAME' ] || ''; end # may be empty
1397
+ def path_info ; @env['PATH_INFO' ] || ''; end # may be empty
1398
+ def query_string ; @env['QUERY_STRING'] || ''; end # may be empty
1399
+ def server_name ; @env['SERVER_NAME' ] ; end # should NOT be empty
1400
+ def server_port ; @env['SERVER_PORT' ].to_i ; end # should NOT be empty
1401
+
1402
+ def content_type
1403
+ #; [!95g9o] returns env['CONTENT_TYPE'].
1404
+ return @env['CONTENT_TYPE']
1408
1405
  end
1409
1406
 
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}'.")
1407
+ def content_length
1408
+ #; [!0wbek] returns env['CONTENT_LENGHT'] as integer.
1409
+ len = @env['CONTENT_LENGTH']
1410
+ return len ? len.to_i : len
1411
+ end
1412
+
1413
+ def referer ; @env['HTTP_REFERER'] ; end
1414
+ def user_agent ; @env['HTTP_USER_AGENT'] ; end
1415
+ def x_requested_with ; @env['HTTP_X_REQUESTED_WITH']; end
1416
+
1417
+ def xhr?
1418
+ #; [!hsgkg] returns true when 'X-Requested-With' header is 'XMLHttpRequest'.
1419
+ return self.x_requested_with == 'XMLHttpRequest'
1420
+ end
1421
+
1422
+ def client_ip_addr
1423
+ #; [!e1uvg] returns 'X-Real-IP' header value if provided.
1424
+ addr = @env['HTTP_X_REAL_IP'] # nginx
1425
+ return addr if addr
1426
+ #; [!qdlyl] returns first item of 'X-Forwarded-For' header if provided.
1427
+ addr = @env['HTTP_X_FORWARDED_FOR'] # apache, squid, etc
1428
+ return addr.split(',').first if addr
1429
+ #; [!8nzjh] returns 'REMOTE_ADDR' if neighter 'X-Real-IP' nor 'X-Forwarded-For' provided.
1430
+ addr = @env['REMOTE_ADDR'] # http standard
1431
+ return addr
1432
+ end
1433
+
1434
+ def scheme
1435
+ #; [!jytwy] returns 'https' when env['HTTPS'] is 'on'.
1436
+ return 'https' if @env['HTTPS'] == 'on'
1437
+ #; [!zg8r2] returns env['rack.url_scheme'] ('http' or 'https').
1438
+ return @env['rack.url_scheme']
1439
+ end
1440
+
1441
+ def rack_version ; @env['rack.version'] ; end # ex: [1, 3]
1442
+ def rack_url_scheme ; @env['rack.url_scheme'] ; end # ex: 'http' or 'https'
1443
+ def rack_input ; @env['rack.input'] ; end # ex: $stdout
1444
+ def rack_errors ; @env['rack.errors'] ; end # ex: $stderr
1445
+ def rack_multithread ; @env['rack.multithread'] ; end # ex: true
1446
+ def rack_multiprocess ; @env['rack.multiprocess'] ; end # ex: true
1447
+ def rack_run_once ; @env['rack.run_once'] ; end # ex: false
1448
+ def rack_session ; @env['rack.session'] ; end # ex: {}
1449
+ def rack_logger ; @env['rack.logger'] ; end # ex: Logger.new
1450
+ def rack_hijack ; @env['rack.hijack'] ; end # ex: callable object
1451
+ def rack_hijack? ; @env['rack.hijack?'] ; end # ex: true or false
1452
+ def rack_hijack_io ; @env['rack.hijack_io'] ; end # ex: socket object
1453
+
1454
+ MAX_FORM_SIZE = 10*1024*1024
1455
+ MAX_JSON_SIZE = 10*1024*1024
1456
+ MAX_MULTIPART_SIZE = 100*1024*1024
1457
+
1458
+ def params_query
1459
+ #; [!6ezqw] parses QUERY_STRING and returns it as Hash/dict object.
1460
+ #; [!o0ws7] unquotes both keys and values.
1461
+ #; [!2fhrk] returns same value when called more than once.
1462
+ return @params_query ||= Util.parse_query_string(@env['QUERY_STRING'] || "")
1463
+ end
1464
+ alias query params_query
1465
+
1466
+ def params_form(max_content_length=nil)
1467
+ #; [!iultp] returns same value when called more than once.
1468
+ d = @params_form
1469
+ return d if d
1470
+ #; [!uq46o] raises 400 error when payload is not form data.
1471
+ self.content_type == 'application/x-www-form-urlencoded' or
1472
+ raise HttpException.new(400, "expected form data, but Content-Type header is #{self.content_type.inspect}.")
1473
+ #; [!puxlr] raises 400 error when content length is too large (> 10MB).
1474
+ clen = get_content_length(max_content_length || MAX_FORM_SIZE)
1475
+ #; [!59ad2] parses form parameters and returns it as Hash object.
1476
+ payload = get_input_stream().read(clen)
1477
+ d = @params_form = Util.parse_query_string(payload)
1478
+ return d
1479
+ end
1480
+ alias form params_form
1481
+
1482
+ def params_multipart(max_content_length=nil)
1483
+ #; [!gbdxu] returns same values when called more than once.
1484
+ d1 = @params_form
1485
+ d2 = @params_file
1486
+ return d1, d2 if d1 && d2
1487
+ #; [!ho5ii] raises 400 error when not multipart data.
1488
+ self.content_type =~ /\Amultipart\/form-data(?:;\s*boundary=(.+))?/ or
1489
+ raise HttpException.new(400, "expected multipart data, but Content-Type header is #{self.content_type.inspect}.")
1490
+ #; [!davzs] raises 400 error when boundary is missing.
1491
+ boundary = $1 or
1492
+ raise HttpException.new(400, 'bounday attribute of multipart required.')
1493
+ #; [!mtx6t] raises error when content length of multipart is too large (> 100MB).
1494
+ clen = get_content_length(max_content_length || MAX_MULTIPART_SIZE)
1495
+ #; [!y1jng] parses multipart when multipart data posted.
1496
+ d1, d2 = Util.parse_multipart(get_input_stream(), boundary, clen, nil, nil)
1497
+ @params_form = d1; @params_file = d2
1498
+ return d1, d2
1499
+ end
1500
+ alias multipart params_multipart
1501
+
1502
+ def params_json(max_content_length=nil)
1503
+ #; [!5kwij] returns same value when called more than once.
1504
+ d = @params_json
1505
+ return d if d
1506
+ #; [!qjgfz] raises 400 error when not JSON data.
1507
+ self.content_type =~ /\Aapplication\/json\b/ or
1508
+ raise HttpException.new(400, "expected JSON data, but Content-Type header is #{self.content_type.inspect}.")
1509
+ #; [!on107] raises error when content length of JSON is too large (> 10MB).
1510
+ clen = get_content_length(max_content_length || MAX_JSON_SIZE)
1511
+ #; [!ugik5] parses json data and returns it as hash object when json data is sent.
1512
+ payload = get_input_stream().read(clen)
1513
+ d = @params_json = JSON.parse(payload)
1514
+ return d
1515
+ end
1516
+ alias json params_json
1517
+
1518
+ def get_content_length(max_size)
1519
+ #; [!q88w9] raises error when content length is missing.
1520
+ clen = self.content_length or
1521
+ raise HttpException.new(400, 'Content-Length header expected.')
1522
+ #; [!ls6ir] raises error when content length is too large.
1523
+ clen <= max_size or
1524
+ raise HttpException.new(400, "Content-Length is too large (max: #{max_size}, actual: #{clen}).")
1525
+ return clen
1526
+ end
1527
+ private :get_content_length
1528
+
1529
+ def get_input_stream
1530
+ #; [!2buc6] returns input stream.
1531
+ return @env['rack.input']
1532
+ end
1533
+ protected :get_input_stream
1534
+
1535
+ def params
1536
+ #; [!erlc7] parses QUERY_STRING when request method is GET or HEAD.
1537
+ #; [!j2lno] parses form parameters when content type is 'application/x-www-form-urlencoded'.
1538
+ #; [!z5w4k] raises error when content type is 'multipart/form-data' (because params_multipart() returns two values).
1539
+ #; [!td6fw] raises error when content type is 'application/json' (because JSON data can contain non-string values).
1540
+ if @meth == :GET || @meth == :HEAD
1541
+ return params_query() # hash object
1422
1542
  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}).")
1543
+ case @env['CONTENT_TYPE']
1544
+ when /\Aapplication\/x-www-form-urlencoded\b/
1545
+ return params_form() # hash object
1546
+ when /\Amultipart\/form-data\b/
1547
+ #return params_multipart() # array of hash objects
1548
+ raise PayloadParseError.new("don't use `@req.params' for multipart data; use `@req.params_multipart' instead.")
1549
+ when /\Aapplication\/json\b/
1550
+ #return params_json() # hash object
1551
+ raise PayloadParseError.new("use `@req.json' for JSON data instead of `@req.params'.")
1552
+ else
1553
+ return {} # hash object
1429
1554
  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
1555
+ end
1556
+
1557
+ def cookies
1558
+ #; [!c9pwr] parses cookie data and returns it as hash object.
1559
+ return @cookies ||= Util.parse_cookie_string(@env['HTTP_COOKIE'] || "")
1560
+ end
1561
+
1562
+ def clear
1563
+ #; [!0jdal] removes uploaded files.
1564
+ d = @params_file
1565
+ d.each {|_, uploaded| uploaded.clean() } if d
1566
+ self
1567
+ end
1568
+
1569
+ end
1570
+
1571
+
1572
+ class RackResponse
1573
+
1574
+ def initialize
1575
+ #; [!ehdkl] default status code is 200.
1576
+ @status_code = 200
1577
+ @headers = {}
1578
+ end
1579
+
1580
+ attr_accessor :status_code
1581
+ attr_reader :headers
1582
+ ## for compatibility with Rack::Response
1583
+ alias status status_code
1584
+ alias status= status_code=
1585
+
1586
+ def status_line
1587
+ #; [!apy81] returns status line such as '200 OK'.
1588
+ return "#{@status_code} #{HTTP_RESPONSE_STATUS[@status_code] || 'UNKNOWN'}"
1589
+ end
1590
+
1591
+ def content_type
1592
+ return @headers['Content-Type']
1593
+ end
1594
+
1595
+ def content_type=(content_type)
1596
+ @headers['Content-Type'] = content_type
1597
+ end
1598
+
1599
+ def content_length
1600
+ s = @headers['Content-Length']
1601
+ return s ? s.to_i : nil
1602
+ end
1603
+
1604
+ def content_length=(length)
1605
+ @headers['Content-Length'] = length.to_s
1606
+ end
1607
+
1608
+ def set_cookie(name, value, domain: nil, path: nil, expires: nil, max_age: nil, httponly: nil, secure: nil)
1609
+ #; [!oanme] converts Time object into HTTP timestamp string.
1610
+ if expires && expires.is_a?(Time)
1611
+ expires = Util.http_utc_time(expires)
1612
+ end
1613
+ #; [!58tby] adds 'Set-Cookie' response header.
1614
+ s = "#{name}=#{value}"
1615
+ s << "; Domain=#{domain}" if domain
1616
+ s << "; Path=#{path}" if path
1617
+ s << "; Expires=#{expires}" if expires
1618
+ s << "; Max-Age=#{max_age}" if max_age
1619
+ s << "; HttpOnly" if httponly
1620
+ s << "; Secure" if secure
1621
+ #; [!u9w9l] supports multiple cookies.
1622
+ value = @headers['Set-Cookie']
1623
+ @headers['Set-Cookie'] = value ? (value << "\n" << s) : s
1624
+ #; [!7otip] returns cookie string.
1625
+ return s
1626
+ end
1627
+
1628
+ def clear
1438
1629
  end
1439
1630
 
1440
1631
  end
@@ -1442,59 +1633,84 @@ module K8
1442
1633
 
1443
1634
  class RackApplication
1444
1635
 
1445
- def initialize(urlpath_mapping=[], default_patterns: DEFAULT_PATTERNS, urlpath_cache_size: 0,
1636
+ REQUEST_CLASS = RackRequest
1637
+ RESPONSE_CLASS = RackResponse
1638
+
1639
+ def self.REQUEST_CLASS=(klass)
1640
+ #; [!7uqb4] changes default request class.
1641
+ remove_const :REQUEST_CLASS
1642
+ const_set :REQUEST_CLASS, klass
1643
+ end
1644
+
1645
+ def self.RESPONSE_CLASS=(klass)
1646
+ #; [!c1bd0] changes default response class.
1647
+ remove_const :RESPONSE_CLASS
1648
+ const_set :RESPONSE_CLASS, klass
1649
+ end
1650
+
1651
+ def initialize(urlpath_mapping=[], urlpath_cache_size: 0,
1446
1652
  enable_urlpath_param_range: true)
1447
1653
  #; [!vkp65] mounts urlpath mappings.
1448
1654
  @mapping = ActionMapping.new(urlpath_mapping,
1449
- default_patterns: default_patterns,
1450
- urlpath_cache_size: urlpath_cache_size,
1655
+ urlpath_cache_size: urlpath_cache_size,
1451
1656
  enable_urlpath_param_range: enable_urlpath_param_range)
1452
1657
  end
1453
1658
 
1454
- def lookup(req_path)
1659
+ def find(req_path)
1455
1660
  #; [!o0rnr] returns action class, action methods, urlpath names and values.
1456
- return @mapping.lookup(req_path)
1661
+ return @mapping.find(req_path)
1662
+ end
1663
+
1664
+ def lookup(req_meth, req_path, query_string="")
1665
+ #; [!7476i] uses '_method' value of query string as request method when 'POST' method.
1666
+ if req_meth == :POST && query_string =~ /\A_method=(\w+)/
1667
+ req_meth = HTTP_REQUEST_METHODS[$1.upcase] || $1.upcase
1668
+ end
1669
+ #
1670
+ tuple = find(req_path)
1671
+ unless tuple
1672
+ #; [!c0job] redirects only when request method is GET or HEAD.
1673
+ if req_meth == :GET || req_meth == :HEAD
1674
+ #; [!u1qfv] raises 301 when urlpath not found but found with tailing '/'.
1675
+ #; [!kbff3] raises 301 when urlpath not found but found without tailing '/'.
1676
+ rpath = req_path
1677
+ rpath2 = rpath.end_with?('/') ? rpath[0..-2] : rpath + '/'
1678
+ if find(rpath2)
1679
+ #; [!cgxx4] adds query string to 'Location' header when redirecting.
1680
+ qs = query_string
1681
+ location = ! qs || qs.empty? ? rpath2 : "#{rpath2}?#{qs}"
1682
+ status = 301 # 301 Moved Permanently
1683
+ raise HttpException.new(status, nil, {'Location'=>location})
1684
+ end
1685
+ end
1686
+ #; [!hdy1f] raises HTTP 404 when urlpath not found.
1687
+ raise HttpException.new(404) # 404 Not Found
1688
+ end
1689
+ action_class, action_methods, urlpath_args = tuple
1690
+ #; [!0znwr] uses 'GET' method to find action when request method is 'HEAD'.
1691
+ d = action_methods
1692
+ action_name = d[req_meth] || (req_meth == :HEAD ? d[:GET] : nil) || d[:ANY]
1693
+ #; [!bfpav] raises HTTP 405 when urlpath found but request method not allowed.
1694
+ action_name or
1695
+ raise HttpException.new(405) # 405 Method Not Allowed
1696
+ return action_class, action_name, urlpath_args
1457
1697
  end
1458
1698
 
1459
1699
  def call(env)
1460
1700
  #; [!uvmxe] takes env object.
1461
1701
  #; [!gpe4g] returns status, headers and content.
1462
- return handle_request(REQUEST_CLASS.new(env), RESPONSE_CLASS.new)
1463
- end
1464
-
1465
- protected
1466
-
1467
- def handle_request(req, resp)
1468
- req_meth = HTTP_REQUEST_METHODS[req.env['REQUEST_METHOD']]
1702
+ #; [!eb2ms] returns 301 when urlpath not found but found with tailing '/'.
1703
+ #; [!02dow] returns 301 when urlpath not found but found without tailing '/'.
1704
+ #; [!2a9c9] adds query string to 'Location' header.
1705
+ #; [!vz07j] redirects only when request method is GET or HEAD.
1469
1706
  #; [!l6kmc] uses 'GET' method to find action when request method is 'HEAD'.
1470
- if req_meth == :HEAD
1471
- req_meth_ = :GET
1472
1707
  #; [!4vmd3] uses '_method' value of query string as request method when 'POST' method.
1473
- elsif req_meth == :POST && /\A_method=(\w+)/.match(req.env['QUERY_STRING'])
1474
- req_meth_ = HTTP_REQUEST_METHODS[$1] || $1
1475
- else
1476
- req_meth_ = req_meth
1477
- end
1708
+ #; [!rz13i] returns HTTP 404 when urlpath not found.
1709
+ #; [!rv3cf] returns HTTP 405 when urlpath found but request method not allowed.
1710
+ req = REQUEST_CLASS.new(env)
1711
+ resp = RESPONSE_CLASS.new
1478
1712
  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
1487
- #; [!rz13i] returns HTTP 404 when urlpath not found.
1488
- tuple4 or
1489
- raise HttpException.new(404)
1490
- action_class, action_methods, urlpath_param_names, urlpath_param_values = tuple4
1491
- #; [!rv3cf] returns HTTP 405 when urlpath found but request method not allowed.
1492
- action_method = action_methods[req_meth_] or
1493
- raise HttpException.new(405)
1494
- #; [!0fgbd] finds action class and invokes action method with urlpath params.
1495
- action_obj = action_class.new(req, resp)
1496
- content = action_obj.handle_action(action_method, urlpath_param_values)
1497
- ret = [resp.status, resp.headers, content]
1713
+ ret = handle_request(req, resp)
1498
1714
  rescue HttpException => ex
1499
1715
  ret = handle_http(ex, req, resp)
1500
1716
  rescue Exception => ex
@@ -1504,11 +1720,23 @@ module K8
1504
1720
  req.clear() if req.respond_to?(:clear)
1505
1721
  resp.clear() if resp.respond_to?(:clear)
1506
1722
  end
1507
- #; [!9wp9z] returns empty body when request method is HEAD.
1508
- ret[2] = [""] if req_meth == :HEAD
1509
1723
  return ret
1510
1724
  end
1511
1725
 
1726
+ protected
1727
+
1728
+ def handle_request(req, resp)
1729
+ #; [!0fgbd] finds action class and invokes action method with urlpath params.
1730
+ req_meth = HTTP_REQUEST_METHODS[req.env['REQUEST_METHOD']] || req.env['REQUEST_METHOD']
1731
+ tuple = lookup(req_meth, req.path, req.env['QUERY_STRING'])
1732
+ action_class, action_name, pargs = tuple # ex: [BooksAction, :do_show, [123]]
1733
+ action_obj = action_class.new(req, resp)
1734
+ content = action_obj.handle_action(action_name, pargs)
1735
+ #; [!9wp9z] returns empty body when request method is HEAD.
1736
+ content = [""] if req_meth == :HEAD
1737
+ return [resp.status, resp.headers, content]
1738
+ end
1739
+
1512
1740
  def handle_http(ex, req, resp)
1513
1741
  if json_expected?(req)
1514
1742
  content = render_http_exception_as_json(ex, req, resp)
@@ -1551,14 +1779,6 @@ END
1551
1779
  return false
1552
1780
  end
1553
1781
 
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
-
1562
1782
  public
1563
1783
 
1564
1784
  def each_mapping(&block)
@@ -1573,8 +1793,8 @@ END
1573
1793
  s = ""
1574
1794
  each_mapping do |full_urlpath_pat, action_class, action_methods|
1575
1795
  arr = req_methods.collect {|req_meth|
1576
- action_method = action_methods[req_meth]
1577
- action_method ? "#{req_meth}: #{action_method}" : nil
1796
+ action_name = action_methods[req_meth]
1797
+ action_name ? "#{req_meth}: #{action_name}" : nil
1578
1798
  }.compact()
1579
1799
  s << "- urlpath: #{full_urlpath_pat}\n"
1580
1800
  s << " class: #{action_class}\n"
@@ -1587,186 +1807,4 @@ END
1587
1807
  end
1588
1808
 
1589
1809
 
1590
- class SecretValue < Object
1591
-
1592
- def initialize(name=nil)
1593
- #; [!fbwnh] takes environment variable name.
1594
- @name = name
1595
- end
1596
-
1597
- attr_reader :name
1598
-
1599
- def value
1600
- #; [!gg06v] returns environment variable value.
1601
- return @name ? ENV[@name] : nil
1602
- end
1603
-
1604
- def to_s
1605
- #; [!7ymqq] returns '<SECRET>' string when name not eixst.
1606
- #; [!x6edf] returns 'ENV[<name>]' string when name exists.
1607
- return @name ? "ENV['#{@name}']" : "<SECRET>"
1608
- end
1609
-
1610
- #; [!j27ji] 'inspect()' is alias of 'to_s()'.
1611
- alias inspect to_s
1612
-
1613
- def [](name)
1614
- #; [!jjqmn] creates new instance object with name.
1615
- self.class.new(name)
1616
- end
1617
-
1618
- end
1619
-
1620
-
1621
- class BaseConfig < Object
1622
-
1623
- SECRET = SecretValue.new
1624
-
1625
- def initialize(freeze: true)
1626
- #; [!vvd1n] copies key and values from class object.
1627
- self.class.each do |key, val, _, _|
1628
- #; [!xok12] when value is SECRET...
1629
- if val.is_a?(SecretValue)
1630
- #; [!a4a4p] raises error when key not specified.
1631
- val.name or
1632
- raise ConfigError.new("config '#{key}' should be set, but not.")
1633
- #; [!w4yl7] raises error when ENV value not specified.
1634
- ENV[val.name] or
1635
- raise ConfigError.new("config '#{key}' depends on ENV['#{val.name}'], but not set.")
1636
- #; [!he20d] get value from ENV.
1637
- val = ENV[val.name]
1638
- end
1639
- instance_variable_set("@#{key}", val)
1640
- end
1641
- #; [!6dilv] freezes self and class object if 'freeze:' is true.
1642
- self.class.freeze if freeze
1643
- self.freeze if freeze
1644
- end
1645
-
1646
- def self.validate_values # :nodoc:
1647
- not_set = []
1648
- not_env = []
1649
- each() do |key, val, _, _|
1650
- if val.is_a?(SecretValue)
1651
- if ! val.name
1652
- not_set << [key, val]
1653
- elsif ! ENV[val.name]
1654
- not_env << [key, val]
1655
- end
1656
- end
1657
- end
1658
- return nil if not_set.empty? && not_env.empty?
1659
- sb = []
1660
- sb << "**"
1661
- sb << "** ERROR: insufficient config"
1662
- unless not_set.empty?
1663
- sb << "**"
1664
- sb << "** The following configs should be set, but not."
1665
- sb << "**"
1666
- not_set.each do |key, val|
1667
- sb << "** %-25s %s" % [key, val]
1668
- end
1669
- end
1670
- unless not_env.empty?
1671
- sb << "**"
1672
- sb << "** The following configs expect environment variable, but not set."
1673
- sb << "**"
1674
- not_env.each do |key, val|
1675
- sb << "** %-25s %s" % [key, val]
1676
- end
1677
- end
1678
- sb << "**"
1679
- sb << ""
1680
- return sb.join("\n")
1681
- end
1682
-
1683
- private
1684
-
1685
- def self.__all
1686
- return @__all ||= {}
1687
- end
1688
-
1689
- public
1690
-
1691
- def self.has?(key)
1692
- #; [!dv87n] returns true iff key is set.
1693
- return __all().key?(key)
1694
- end
1695
-
1696
- def self.put(key, value, desc=nil)
1697
- #; [!h9b47] defines getter method.
1698
- attr_reader key
1699
- d = __all()
1700
- #; [!mun1v] keeps secret flag.
1701
- if d[key]
1702
- desc ||= d[key][1]
1703
- secret = d[key][2]
1704
- else
1705
- secret = value == SECRET
1706
- end
1707
- #; [!ncwzt] stores key with value, description and secret flag.
1708
- d[key] = [value, desc, secret]
1709
- nil
1710
- end
1711
-
1712
- def self.add(key, value, desc=nil)
1713
- #; [!envke] raises error when already added.
1714
- ! self.has?(key) or
1715
- raise K8::ConfigError.new("add(#{key.inspect}, #{value.inspect}): cannot add because already added; use set() or put() instead.")
1716
- #; [!6cmb4] adds new key, value and desc.
1717
- self.put(key, value, desc)
1718
- end
1719
-
1720
- def self.set(key, value, desc=nil)
1721
- #; [!2yis0] raises error when not added yet.
1722
- self.has?(key) or
1723
- raise K8::ConfigError.new("set(#{key.inspect}, #{value.inspect}): cannot set because not added yet; use add() or put() instead.")
1724
- #; [!3060g] sets key, value and desc.
1725
- self.put(key, value, desc)
1726
- end
1727
-
1728
- def self.each
1729
- #; [!iu88i] yields with key, value, desc and secret flag.
1730
- __all().each do |key, (value, desc, secret)|
1731
- yield key, value, desc, secret
1732
- end
1733
- nil
1734
- end
1735
-
1736
- def self.get(key, default=nil)
1737
- #; [!zlhnp] returns value corresponding to key.
1738
- #; [!o0k05] returns default value (=nil) when key is not added.
1739
- tuple = __all()[key]
1740
- return tuple ? tuple.first : default
1741
- end
1742
-
1743
- def [](key)
1744
- #; [!jn9l5] returns value corresponding to key.
1745
- return __send__(key)
1746
- end
1747
-
1748
- def get_all(prefix_key)
1749
- #; [!4ik3c] returns all keys and values which keys start with prefix as hash object.
1750
- prefix = "@#{prefix_key}"
1751
- symbol_p = prefix_key.is_a?(Symbol)
1752
- range = prefix.length..-1
1753
- d = {}
1754
- self.instance_variables.each do |ivar|
1755
- if ivar.to_s.start_with?(prefix)
1756
- val = self.instance_variable_get(ivar)
1757
- key = ivar[range].intern
1758
- key = key.intern if symbol_p
1759
- d[key] = val
1760
- end
1761
- end
1762
- return d
1763
- end
1764
-
1765
- end
1766
-
1767
-
1768
- class ConfigError < StandardError
1769
- end
1770
-
1771
-
1772
1810
  end