keight 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +263 -0
  4. data/Rakefile +92 -0
  5. data/bench/bench.rb +278 -0
  6. data/bench/benchmarker.rb +502 -0
  7. data/bin/k8rb +496 -0
  8. data/keight.gemspec +36 -0
  9. data/lib/keight/skeleton/.gitignore +10 -0
  10. data/lib/keight/skeleton/app/action.rb +98 -0
  11. data/lib/keight/skeleton/app/api/hello.rb +39 -0
  12. data/lib/keight/skeleton/app/form/.keep +0 -0
  13. data/lib/keight/skeleton/app/helper/.keep +0 -0
  14. data/lib/keight/skeleton/app/model/.keep +0 -0
  15. data/lib/keight/skeleton/app/model.rb +144 -0
  16. data/lib/keight/skeleton/app/page/welcome.rb +17 -0
  17. data/lib/keight/skeleton/app/template/_layout.html.eruby +56 -0
  18. data/lib/keight/skeleton/app/template/welcome.html.eruby +6 -0
  19. data/lib/keight/skeleton/app/usecase/.keep +0 -0
  20. data/lib/keight/skeleton/config/app.rb +29 -0
  21. data/lib/keight/skeleton/config/app_dev.private +11 -0
  22. data/lib/keight/skeleton/config/app_dev.rb +8 -0
  23. data/lib/keight/skeleton/config/app_prod.rb +7 -0
  24. data/lib/keight/skeleton/config/app_stg.rb +5 -0
  25. data/lib/keight/skeleton/config/app_test.private +11 -0
  26. data/lib/keight/skeleton/config/app_test.rb +8 -0
  27. data/lib/keight/skeleton/config/server_puma.rb +22 -0
  28. data/lib/keight/skeleton/config/server_unicorn.rb +21 -0
  29. data/lib/keight/skeleton/config/urlpath_mapping.rb +16 -0
  30. data/lib/keight/skeleton/config.rb +44 -0
  31. data/lib/keight/skeleton/config.ru +21 -0
  32. data/lib/keight/skeleton/index.txt +38 -0
  33. data/lib/keight/skeleton/static/lib/jquery/1.11.3/jquery.min.js +6 -0
  34. data/lib/keight/skeleton/static/lib/jquery/1.11.3/jquery.min.js.gz +0 -0
  35. data/lib/keight/skeleton/static/lib/modernizr/2.8.3/modernizr.min.js +4 -0
  36. data/lib/keight/skeleton/static/lib/modernizr/2.8.3/modernizr.min.js.gz +0 -0
  37. data/lib/keight/skeleton/tmp/upload/.keep +0 -0
  38. data/lib/keight.rb +2017 -0
  39. data/test/data/example1.jpg +0 -0
  40. data/test/data/example1.png +0 -0
  41. data/test/data/multipart.form +0 -0
  42. data/test/data/wabisabi.js +77 -0
  43. data/test/data/wabisabi.js.gz +0 -0
  44. data/test/keight_test.rb +3161 -0
  45. data/test/oktest.rb +1537 -0
  46. metadata +114 -0
data/lib/keight.rb ADDED
@@ -0,0 +1,2017 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ ###
4
+ ### $Release: 0.0.1 $
5
+ ### $Copyright: copyright(c) 2014-2015 kuwata-lab.com all rights reserved $
6
+ ### $License: MIT License $
7
+ ###
8
+
9
+ require 'json'
10
+ require 'date'
11
+ require 'uri'
12
+ require 'digest/sha1'
13
+ #require 'stringio' # on-demand load
14
+
15
+
16
+ module K8
17
+
18
+ FILEPATH = __FILE__
19
+
20
+ HTTP_REQUEST_METHODS = {
21
+ "GET" => :GET,
22
+ "POST" => :POST,
23
+ "PUT" => :PUT,
24
+ "DELETE" => :DELETE,
25
+ "HEAD" => :HEAD,
26
+ "PATCH" => :PATCH,
27
+ "OPTIONS" => :OPTIONS,
28
+ "TRACE" => :TRACE,
29
+ }.each {|k, _| k.freeze }
30
+
31
+ HTTP_RESPONSE_STATUS = {
32
+ 100 => "Continue",
33
+ 101 => "Switching Protocols",
34
+ 102 => "Processing",
35
+ 200 => "OK",
36
+ 201 => "Created",
37
+ 202 => "Accepted",
38
+ 203 => "Non-Authoritative Information",
39
+ 204 => "No Content",
40
+ 205 => "Reset Content",
41
+ 206 => "Partial Content",
42
+ 207 => "Multi-Status",
43
+ 208 => "Already Reported",
44
+ 226 => "IM Used",
45
+ 300 => "Multiple Choices",
46
+ 301 => "Moved Permanently",
47
+ 302 => "Found",
48
+ 303 => "See Other",
49
+ 304 => "Not Modified",
50
+ 305 => "Use Proxy",
51
+ 307 => "Temporary Redirect",
52
+ 400 => "Bad Request",
53
+ 401 => "Unauthorized",
54
+ 402 => "Payment Required",
55
+ 403 => "Forbidden",
56
+ 404 => "Not Found",
57
+ 405 => "Method Not Allowed",
58
+ 406 => "Not Acceptable",
59
+ 407 => "Proxy Authentication Required",
60
+ 408 => "Request Timeout",
61
+ 409 => "Conflict",
62
+ 410 => "Gone",
63
+ 411 => "Length Required",
64
+ 412 => "Precondition Failed",
65
+ 413 => "Request Entity Too Large",
66
+ 414 => "Request-URI Too Long",
67
+ 415 => "Unsupported Media Type",
68
+ 416 => "Requested Range Not Satisfiable",
69
+ 417 => "Expectation Failed",
70
+ 418 => "I'm a teapot",
71
+ 422 => "Unprocessable Entity",
72
+ 423 => "Locked",
73
+ 424 => "Failed Dependency",
74
+ 426 => "Upgrade Required",
75
+ 500 => "Internal Server Error",
76
+ 501 => "Not Implemented",
77
+ 502 => "Bad Gateway",
78
+ 503 => "Service Unavailable",
79
+ 504 => "Gateway Timeout",
80
+ 505 => "HTTP Version Not Supported",
81
+ 506 => "Variant Also Negotiates",
82
+ 507 => "Insufficient Storage",
83
+ 508 => "Loop Detected",
84
+ 510 => "Not Extended",
85
+ }.each {|_, v| v.freeze }
86
+
87
+ MIME_TYPES = {
88
+ '.html' => 'text/html',
89
+ '.htm' => 'text/html',
90
+ '.shtml' => 'text/html',
91
+ '.css' => 'text/css',
92
+ '.csv' => 'text/comma-separated-values',
93
+ '.tsv' => 'text/tab-separated-values',
94
+ '.xml' => 'text/xml',
95
+ '.mml' => 'text/mathml',
96
+ '.txt' => 'text/plain',
97
+ '.wml' => 'text/vnd.wap.wml',
98
+ '.gif' => 'image/gif',
99
+ '.jpeg' => 'image/jpeg',
100
+ '.jpg' => 'image/jpeg',
101
+ '.png' => 'image/png',
102
+ '.tif' => 'image/tiff',
103
+ '.tiff' => 'image/tiff',
104
+ '.ico' => 'image/x-icon',
105
+ '.bmp' => 'image/x-ms-bmp',
106
+ '.svg' => 'image/svg+xml',
107
+ '.svgz' => 'image/svg+xml',
108
+ '.webp' => 'image/webp',
109
+ '.mid' => 'audio/midi',
110
+ '.midi' => 'audio/midi',
111
+ '.kar' => 'audio/midi',
112
+ '.mp3' => 'audio/mpeg',
113
+ '.ogg' => 'audio/ogg',
114
+ '.m4a' => 'audio/x-m4a',
115
+ '.ra' => 'audio/x-realaudio',
116
+ '.3gpp' => 'video/3gpp',
117
+ '.3gp' => 'video/3gpp',
118
+ '.3g2' => 'video/3gpp2',
119
+ '.ts' => 'video/mp2t',
120
+ '.mp4' => 'video/mp4',
121
+ '.mpeg' => 'video/mpeg',
122
+ '.mpg' => 'video/mpeg',
123
+ '.mov' => 'video/quicktime',
124
+ '.webm' => 'video/webm',
125
+ '.flv' => 'video/x-flv',
126
+ '.m4v' => 'video/x-m4v',
127
+ '.mng' => 'video/x-mng',
128
+ '.asx' => 'video/x-ms-asf',
129
+ '.asf' => 'video/x-ms-asf',
130
+ '.wmv' => 'video/x-ms-wmv',
131
+ '.avi' => 'video/x-msvideo',
132
+ '.json' => 'application/json',
133
+ '.js' => 'application/javascript',
134
+ '.atom' => 'application/atom+xml',
135
+ '.rss' => 'application/rss+xml',
136
+ '.doc' => 'application/msword',
137
+ '.pdf' => 'application/pdf',
138
+ '.ps' => 'application/postscript',
139
+ '.eps' => 'application/postscript',
140
+ '.ai' => 'application/postscript',
141
+ '.rtf' => 'application/rtf',
142
+ '.xls' => 'application/vnd.ms-excel',
143
+ '.eot' => 'application/vnd.ms-fontobject',
144
+ '.ppt' => 'application/vnd.ms-powerpoint',
145
+ '.key' => 'application/vnd.apple.keynote',
146
+ '.pages' => 'application/vnd.apple.pages',
147
+ '.numbers' => 'application/vnd.apple.numbers',
148
+ '.zip' => 'application/zip',
149
+ '.lha' => 'application/x-lzh',
150
+ '.lzh' => 'application/x-lzh',
151
+ '.tar' => 'application/x-tar',
152
+ '.tgz' => 'application/x-tar',
153
+ '.gz' => 'application/x-gzip',
154
+ '.bz2' => 'application/x-bzip2',
155
+ '.xz' => 'application/x-xz',
156
+ '.7z' => 'application/x-7z-compressed',
157
+ '.rar' => 'application/x-rar-compressed',
158
+ '.rpm' => 'application/x-redhat-package-manager',
159
+ '.deb' => 'application/vnd.debian.binary-package',
160
+ '.swf' => 'application/x-shockwave-flash',
161
+ '.der' => 'application/x-x509-ca-cert',
162
+ '.pem' => 'application/x-x509-ca-cert',
163
+ '.crt' => 'application/x-x509-ca-cert',
164
+ '.xpi' => 'application/x-xpinstall',
165
+ '.xhtml' => 'application/xhtml+xml',
166
+ '.xspf' => 'application/xspf+xml',
167
+ '.yaml' => 'application/x-yaml',
168
+ '.yml' => 'application/x-yaml',
169
+ '.docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
170
+ '.xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
171
+ '.pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
172
+ }.each {|k, v| k.freeze; v.freeze }
173
+
174
+
175
+ module Util
176
+
177
+ module_function
178
+
179
+ ESCAPE_HTML = {'&'=>'&amp;', '<'=>'&lt;', '>'=>'&gt;', '"'=>'&quot;', "'"=>'&#39;'}
180
+
181
+ def escape_html(str)
182
+ #; [!90jx8] escapes '& < > " \'' into '&amp; &lt; &gt; &quot; &#39;'.
183
+ return str.gsub(/[&<>"']/, ESCAPE_HTML)
184
+ end
185
+
186
+ #; [!649wt] 'h()' is alias of 'escape_html()'
187
+ alias h escape_html
188
+ class << self
189
+ alias h escape_html
190
+ end
191
+
192
+ def percent_encode(str)
193
+ #; [!a96jo] encodes string into percent encoding format.
194
+ return URI.encode_www_form_component(str)
195
+ end
196
+
197
+ def percent_decode(str)
198
+ #; [!kl9sk] decodes percent encoded string.
199
+ return URI.decode_www_form_component(str)
200
+ end
201
+
202
+ def parse_query_string(query_str)
203
+ return _parse(query_str, /[&;]/)
204
+ end
205
+
206
+ def parse_cookie_string(cookie_str)
207
+ return _parse(cookie_str, /;\s*/)
208
+ end
209
+
210
+ def _parse(query_str, separator)
211
+ #; [!engr6] returns empty Hash object when query string is empty.
212
+ d = {}
213
+ return d if query_str.empty?
214
+ #; [!fzt3w] parses query string and returns Hahs object.
215
+ equal = '='
216
+ brackets = '[]'
217
+ query_str.split(separator).each do |s|
218
+ #kv = s.split('=', 2)
219
+ #if kv.length == 2
220
+ # k, v = kv
221
+ #else
222
+ # k = kv[0]; v = ""
223
+ #end
224
+ k, v = s.split(equal, 2)
225
+ v ||= ''
226
+ k = percent_decode(k) unless k =~ /\A[-.\w]+\z/
227
+ v = percent_decode(v) unless v =~ /\A[-.\w]+\z/
228
+ #; [!t0w33] regards as array of string when param name ends with '[]'.
229
+ if k.end_with?(brackets)
230
+ (d[k] ||= []) << v
231
+ else
232
+ d[k] = v
233
+ end
234
+ end
235
+ return d
236
+ end
237
+
238
+ def build_query_string(query)
239
+ case query
240
+ when nil ; return nil
241
+ when String ; return query
242
+ when Hash, Array
243
+ return query.collect {|k, v| "#{percent_decode(k.to_s)}=#{percent_decode(v.to_s)}" }.join('&')
244
+ else
245
+ raise ArgumentError.new("Hash or Array expected but got #{query.inspect}.")
246
+ end
247
+ end
248
+
249
+ MULTIPART_MAX_FILESIZE = 50 * 1024 * 1024 # 50MB
250
+ MULTIPART_BUFFER_SIZE = 10 * 1024 * 1024 # 10MB
251
+
252
+ def parse_multipart(stdin, boundary, content_length, max_filesize=nil, bufsize=nil)
253
+ max_filesize ||= MULTIPART_MAX_FILESIZE
254
+ bufsize ||= MULTIPART_BUFFER_SIZE
255
+ #; [!mqrei] parses multipart form data.
256
+ params = {} # {"name": "value"}
257
+ files = {} # {"name": UploadedFile}
258
+ _parse_multipart(stdin, boundary, content_length, max_filesize, bufsize) do |part|
259
+ header, body = part.split("\r\n\r\n")
260
+ pname, filename, cont_type = _parse_multipart_header(header)
261
+ if filename
262
+ upfile = UploadedFile.new(filename, cont_type) {|f| f.write(body) }
263
+ pvalue = filename
264
+ else
265
+ upfile = nil
266
+ pvalue = body
267
+ end
268
+ if pname.end_with?('[]')
269
+ (params[pname] ||= []) << pvalue
270
+ (files[pname] ||= []) << upfile if upfile
271
+ else
272
+ params[pname] = pvalue
273
+ files[pname] = upfile if upfile
274
+ end
275
+ end
276
+ return params, files
277
+ end
278
+
279
+ def _parse_multipart(stdin, boundary, content_length, max_filesize, bufsize)
280
+ first_line = "--#{boundary}\r\n"
281
+ last_line = "\r\n--#{boundary}--\r\n"
282
+ separator = "\r\n--#{boundary}\r\n"
283
+ s = stdin.read(first_line.bytesize)
284
+ s == first_line or
285
+ raise _mp_err("invalid first line. exected=#{first_line.inspect}, actual=#{s.inspect}")
286
+ len = content_length - first_line.bytesize - last_line.bytesize
287
+ len > 0 or
288
+ raise _mp_err("invalid content length.")
289
+ last = nil
290
+ while len > 0
291
+ n = bufsize < len ? bufsize : len
292
+ buf = stdin.read(n)
293
+ break if buf.nil? || buf.empty?
294
+ len -= buf.bytesize
295
+ buf = (last << buf) if last
296
+ parts = buf.split(separator)
297
+ ! (parts.length == 1 && buf.bytesize > max_filesize) or
298
+ raise _mp_err("too large file or data (max: about #{max_filesize/(1024*1024)}MB)")
299
+ last = parts.pop()
300
+ parts.each do |part|
301
+ yield part
302
+ end
303
+ end
304
+ yield last if last
305
+ s = stdin.read(last_line.bytesize)
306
+ s == last_line or
307
+ raise _mp_err("invalid last line.")
308
+ end
309
+ private :_parse_multipart
310
+
311
+ def _parse_multipart_header(header)
312
+ cont_disp = cont_type = nil
313
+ header.split("\r\n").each do |line|
314
+ name, val = line.split(/: */, 2)
315
+ if name == 'Content-Disposition'; cont_disp = val
316
+ elsif name == 'Content-Type' ; cont_type = val
317
+ else ; nil
318
+ end
319
+ end
320
+ cont_disp or
321
+ raise _mp_err("Content-Disposition is required.")
322
+ cont_disp =~ /form-data; *name=(?:"([^"\r\n]*)"|([^;\r\n]+))/ or
323
+ raise _mp_err("Content-Disposition is invalid.")
324
+ param_name = percent_decode($1 || $2)
325
+ filename = (cont_disp =~ /; *filename=(?:"([^"\r\n]+)"|([^;\r\n]+))/ \
326
+ ? percent_decode($1 || $2) : nil)
327
+ return param_name, filename, cont_type
328
+ end
329
+ private :_parse_multipart_header
330
+
331
+ def _mp_err(msg)
332
+ return HttpException.new(400, msg)
333
+ end
334
+ private :_mp_err
335
+
336
+ def randstr_b64()
337
+ #; [!yq0gv] returns random string, encoded with urlsafe base64.
338
+ ## Don't use SecureRandom; entropy of /dev/random or /dev/urandom
339
+ ## should be left for more secure-sensitive purpose.
340
+ s = "#{rand()}#{rand()}#{rand()}#{Time.now.to_f}"
341
+ binary = Digest::SHA1.digest(s)
342
+ return [binary].pack('m').chomp("=\n").tr('+/', '-_')
343
+ end
344
+
345
+ def guess_content_type(filename, default='application/octet-stream')
346
+ #; [!xw0js] returns content type guessed from filename.
347
+ #; [!dku5c] returns 'application/octet-stream' when failed to guess content type.
348
+ ext = File.extname(filename)
349
+ return MIME_TYPES[ext] || default
350
+ end
351
+
352
+ def http_utc_time(utc_time) # similar to Time#httpdate() in 'time.rb'
353
+ #; [!3z5lf] raises error when argument is not UTC.
354
+ utc_time.utc? or
355
+ raise ArgumentError.new("http_utc_time(#{utc_time.inspect}): expected UTC time but got local time.")
356
+ #; [!5k50b] converts Time object into HTTP date format string.
357
+ return utc_time.strftime('%a, %d %b %Y %H:%M:%S GMT')
358
+ end
359
+
360
+ WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].freeze
361
+ MONTHS = [nil, 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
362
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].freeze
363
+
364
+ t = Time.now.utc
365
+ if t.strftime('%a') != WEEKDAYS[t.wday] || t.strftime('%b') != MONTHS[t.month]
366
+ def http_utc_time(utc_time)
367
+ utc_time.utc? or
368
+ raise ArgumentError.new("http_utc_time(#{utc_time.inspect}): expected UTC time but got local time.")
369
+ return utc_time.strftime("#{WEEKDAYS[utc_time.wday]}, %d #{MONTHS[utc_time.month]} %Y %H:%M:%S GMT")
370
+ end
371
+ end
372
+
373
+ end
374
+
375
+
376
+ class UploadedFile
377
+
378
+ def initialize(filename, content_type)
379
+ #; [!ityxj] takes filename and content type.
380
+ @filename = filename
381
+ @content_type = content_type
382
+ #; [!5c8w6] sets temporary filepath with random string.
383
+ @tmp_filepath = new_filepath()
384
+ #; [!8ezhr] yields with opened temporary file.
385
+ File.open(@tmp_filepath, 'wb') {|f| yield f } if block_given?
386
+ end
387
+
388
+ attr_reader :filename, :content_type, :tmp_filepath
389
+
390
+ def clean
391
+ #; [!ft454] removes temporary file if exists.
392
+ File.unlink(@tmp_filepath) if @tmp_filepath
393
+ rescue SystemCallError # or Errno::ENOENT?
394
+ nil
395
+ end
396
+
397
+ protected
398
+
399
+ def new_filepath
400
+ #; [!zdkts] use $K8_UPLOAD_DIR environment variable as temporary directory.
401
+ dir = ENV['K8_UPLOAD_DIR']
402
+ dir ||= ENV['TMPDIR'] || ENV['TEMPDIR'] || '/tmp'
403
+ return File.join(dir, "up.#{Util.randstr_b64()}")
404
+ end
405
+
406
+ end
407
+
408
+
409
+ class HttpException < Exception
410
+
411
+ def initialize(status_code, message=nil, response_headers=nil)
412
+ response_headers, message = message, nil if message.is_a?(Hash)
413
+ @status_code = status_code
414
+ @message = message if message
415
+ @response_headers = response_headers if response_headers
416
+ end
417
+
418
+ attr_reader :status_code, :message, :response_headers
419
+
420
+ def status_message
421
+ return HTTP_RESPONSE_STATUS[@status_code]
422
+ end
423
+
424
+ end
425
+
426
+
427
+ class BaseError < Exception
428
+ end
429
+
430
+
431
+ class ContentTypeRequiredError < BaseError
432
+ end
433
+
434
+
435
+ class UnknownContentError < BaseError
436
+ end
437
+
438
+
439
+ class Request
440
+
441
+ def initialize(env)
442
+ #; [!yb9k9] sets @env.
443
+ @env = env
444
+ #; [!yo22o] sets @method as Symbol value.
445
+ @method = HTTP_REQUEST_METHODS[env['REQUEST_METHOD']] or
446
+ raise HTTPException.new(400, "#{env['REQUEST_METHOD'].inspect}: unknown request method.")
447
+ #; [!twgmi] sets @path.
448
+ @path = (x = env['PATH_INFO'])
449
+ #; [!ae8ws] uses SCRIPT_NAME as urlpath when PATH_INFO is not provided.
450
+ @path = env['SCRIPT_NAME'] if x.nil? || x.empty?
451
+ end
452
+
453
+ attr_reader :env, :method, :path
454
+
455
+ def header(name)
456
+ #; [!1z7wj] returns http header value from environment.
457
+ return @env["HTTP_#{name.upcase.sub('-', '_')}"]
458
+ end
459
+
460
+ def method(name=nil)
461
+ #; [!tp595] returns :GET, :POST, :PUT, ... when argument is not passed.
462
+ #; [!49f51] returns Method object when argument is passed.
463
+ return name.nil? ? @method : super(name)
464
+ end
465
+
466
+ def request_method
467
+ #; [!y8eos] returns env['REQUEST_METHOD'] as string.
468
+ return @env['REQUEST_METHOD']
469
+ end
470
+
471
+ ##--
472
+ #def get? ; @method == :GET ; end
473
+ #def post? ; @method == :POST ; end
474
+ #def put? ; @method == :PUT ; end
475
+ #def delete? ; @method == :DELETE ; end
476
+ #def head? ; @method == :HEAD ; end
477
+ #def patch? ; @method == :PATCH ; end
478
+ #def options? ; @method == :OPTIONS ; end
479
+ #def trace? ; @method == :TRACE ; end
480
+ ##++
481
+
482
+ def script_name ; @env['SCRIPT_NAME' ] || ''; end # may be empty
483
+ def path_info ; @env['PATH_INFO' ] || ''; end # may be empty
484
+ def query_string ; @env['QUERY_STRING'] || ''; end # may be empty
485
+ def server_name ; @env['SERVER_NAME' ] ; end # should NOT be empty
486
+ def server_port ; @env['SERVER_PORT' ].to_i ; end # should NOT be empty
487
+
488
+ def content_type
489
+ #; [!95g9o] returns env['CONTENT_TYPE'].
490
+ return @env['CONTENT_TYPE']
491
+ end
492
+
493
+ def content_length
494
+ #; [!0wbek] returns env['CONTENT_LENGHT'] as integer.
495
+ len = @env['CONTENT_LENGTH']
496
+ return len ? len.to_i : len
497
+ end
498
+
499
+ def referer ; @env['HTTP_REFERER'] ; end
500
+ def user_agent ; @env['HTTP_USER_AGENT'] ; end
501
+ def x_requested_with ; @env['HTTP_X_REQUESTED_WITH']; end
502
+
503
+ def xhr?
504
+ #; [!hsgkg] returns true when 'X-Requested-With' header is 'XMLHttpRequest'.
505
+ return self.x_requested_with == 'XMLHttpRequest'
506
+ end
507
+
508
+ def client_ip_addr
509
+ #; [!e1uvg] returns 'X-Real-IP' header value if provided.
510
+ addr = @env['HTTP_X_REAL_IP'] # nginx
511
+ return addr if addr
512
+ #; [!qdlyl] returns first item of 'X-Forwarded-For' header if provided.
513
+ addr = @env['HTTP_X_FORWARDED_FOR'] # apache, squid, etc
514
+ return addr.split(',').first if addr
515
+ #; [!8nzjh] returns 'REMOTE_ADDR' if neighter 'X-Real-IP' nor 'X-Forwarded-For' provided.
516
+ addr = @env['REMOTE_ADDR'] # http standard
517
+ return addr
518
+ end
519
+
520
+ def scheme
521
+ #; [!jytwy] returns 'https' when env['HTTPS'] is 'on'.
522
+ return 'https' if @env['HTTPS'] == 'on'
523
+ #; [!zg8r2] returns env['rack.url_scheme'] ('http' or 'https').
524
+ return @env['rack.url_scheme']
525
+ end
526
+
527
+ def rack_version ; @env['rack.version'] ; end # ex: [1, 3]
528
+ def rack_url_scheme ; @env['rack.url_scheme'] ; end # ex: 'http' or 'https'
529
+ def rack_input ; @env['rack.input'] ; end # ex: $stdout
530
+ def rack_errors ; @env['rack.errors'] ; end # ex: $stderr
531
+ def rack_multithread ; @env['rack.multithread'] ; end # ex: true
532
+ def rack_multiprocess ; @env['rack.multiprocess'] ; end # ex: true
533
+ def rack_run_once ; @env['rack.run_once'] ; end # ex: false
534
+ def rack_session ; @env['rack.session'] ; end # ex: {}
535
+ def rack_logger ; @env['rack.logger'] ; end # ex: Logger.new
536
+ def rack_hijack ; @env['rack.hijack'] ; end # ex: callable object
537
+ def rack_hijack? ; @env['rack.hijack?'] ; end # ex: true or false
538
+ def rack_hijack_io ; @env['rack.hijack_io'] ; end # ex: socket object
539
+
540
+ def params_query
541
+ #; [!6ezqw] parses QUERY_STRING and returns it as Hash object.
542
+ #; [!o0ws7] unquotes both keys and values.
543
+ return @params_query ||= Util.parse_query_string(@env['QUERY_STRING'] || "")
544
+ end
545
+ alias query params_query
546
+
547
+ MAX_POST_SIZE = 10*1024*1024
548
+ MAX_MULTIPART_SIZE = 100*1024*1024
549
+
550
+ def params_form
551
+ d = @params_form
552
+ return d if d
553
+ #
554
+ d = @params_form = _parse_post_data(:form)
555
+ return d
556
+ end
557
+ alias form params_form
558
+
559
+ def params_multipart
560
+ d1 = @params_form
561
+ d2 = @params_file
562
+ return d1, d2 if d1 && d2
563
+ d1, d2 = _parse_post_data(:multipart)
564
+ @params_form = d1; @params_file = d2
565
+ return d1, d2
566
+ end
567
+ alias multipart params_multipart
568
+
569
+ def params_json
570
+ d = @params_json
571
+ return d if d
572
+ d = @params_json = _parse_post_data(:json)
573
+ return d
574
+ end
575
+ alias json params_json
576
+
577
+ def _parse_post_data(kind)
578
+ #; [!q88w9] raises error when content length is missing.
579
+ cont_len = @env['CONTENT_LENGTH'] or
580
+ raise HttpException.new(400, 'Content-Length header expected.')
581
+ #; [!gi4qq] raises error when content length is invalid.
582
+ cont_len =~ /\A\d+\z/ or
583
+ raise HttpException.new(400, 'Content-Length should be an integer.')
584
+ #
585
+ len = cont_len.to_i
586
+ case @env['CONTENT_TYPE']
587
+ #; [!59ad2] parses form parameters and returns it as Hash object when form requested.
588
+ when 'application/x-www-form-urlencoded'
589
+ kind == :form or
590
+ raise HttpException.new(400, 'unexpected form data (expected multipart).')
591
+ #; [!puxlr] raises error when content length is too long (> 10MB).
592
+ len <= MAX_POST_SIZE or
593
+ raise HttpException.new(400, 'Content-Length is too long.')
594
+ qstr = @env['rack.input'].read(len)
595
+ d = Util.parse_query_string(qstr)
596
+ return d
597
+ #; [!y1jng] parses multipart when multipart form requested.
598
+ when /\Amultipart\/form-data(?:;\s*boundary=(.*))?/
599
+ kind == :multipart or
600
+ raise HttpException.new(400, 'unexpected multipart data.')
601
+ boundary = $1 or
602
+ raise HttpException.new(400, 'bounday attribute of multipart required.')
603
+ #; [!mtx6t] raises error when content length of multipart is too long (> 100MB).
604
+ len <= MAX_MULTIPART_SIZE or
605
+ raise HttpException.new(400, 'Content-Length of multipart is too long.')
606
+ d1, d2 = Util.parse_multipart(@env['rack.input'], boundary, len, nil, nil)
607
+ return d1, d2
608
+ #; [!ugik5] parses json data and returns it as hash object when json data is sent.
609
+ when /\Aapplication\/json\b/
610
+ kind == :json or
611
+ raise HttpException.new(400, 'unexpected JSON data.')
612
+ json_str = @env['rack.input'].read(10*1024*1024) # TODO
613
+ d = JSON.parse(json_str)
614
+ #; [!p9ybb] raises error when not a form data.
615
+ else
616
+ raise HttpException.new(400, 'POST data expected, but not.')
617
+ end
618
+ end
619
+ private :_parse_post_data
620
+
621
+ def params
622
+ #; [!erlc7] parses QUERY_STRING when request method is GET or HEAD.
623
+ #; [!cr0zj] parses JSON when content type is 'application/json'.
624
+ #; [!j2lno] parses form parameters when content type is 'application/x-www-form-urlencoded'.
625
+ #; [!4rmn9] parses multipart when content type is 'multipart/form-data'.
626
+ if @method == :GET || @method == :HEAD
627
+ return params_query()
628
+ end
629
+ case @env['CONTENT_TYPE']
630
+ when /\Aapplication\/json\b/
631
+ return params_json()
632
+ when /\Aapplication\/x-www-form-urlencoded\b/
633
+ return params_form()
634
+ when /\Amultipart\/form-data\b/
635
+ return params_multipart()
636
+ else
637
+ return {}
638
+ end
639
+ end
640
+
641
+ def cookies
642
+ #; [!c9pwr] parses cookie data and returns it as hash object.
643
+ return @cookies ||= Util.parse_cookie_string(@env['HTTP_COOKIE'] || "")
644
+ end
645
+
646
+ def clear
647
+ #; [!0jdal] removes uploaded files.
648
+ d = nil
649
+ d.each {|_, uploaded| uploaded.clean() } if (d = @params_file)
650
+ end
651
+
652
+ end
653
+
654
+
655
+ class Response
656
+
657
+ def initialize
658
+ @status_code = 200
659
+ @headers = {}
660
+ end
661
+
662
+ attr_accessor :status_code
663
+ attr_reader :headers
664
+ ## for compatibility with Rack::Response
665
+ alias status status_code
666
+ alias status= status_code=
667
+
668
+ def content_type
669
+ return @headers['Content-Type']
670
+ end
671
+
672
+ def content_type=(content_type)
673
+ @headers['Content-Type'] = content_type
674
+ end
675
+
676
+ def content_length
677
+ s = @headers['Content-Length']
678
+ return s ? s.to_i : nil
679
+ end
680
+
681
+ def content_length=(length)
682
+ @headers['Content-Length'] = length.to_s
683
+ end
684
+
685
+ def set_cookie(name, value, domain: nil, path: nil, expires: nil, max_age: nil, httponly: nil, secure: nil)
686
+ s = "#{name}=#{value}"
687
+ s << "; Domain=#{domain}" if domain
688
+ s << "; Path=#{path}" if path
689
+ s << "; Expires=#{expires}" if expires
690
+ s << "; Max-Age=#{max_age}" if max_age
691
+ s << "; HttpOnly" if httponly
692
+ s << "; Secure" if secure
693
+ value = @headers['Set-Cookie']
694
+ @headers['Set-Cookie'] = value ? (value << "\n" << s) : s
695
+ return value
696
+ end
697
+
698
+ def clear
699
+ end
700
+
701
+ end
702
+
703
+
704
+ REQUEST_CLASS = Request
705
+ RESPONSE_CLASS = Response
706
+
707
+ def self.REQUEST_CLASS=(klass)
708
+ #; [!7uqb4] changes default request class.
709
+ remove_const :REQUEST_CLASS
710
+ const_set :REQUEST_CLASS, klass
711
+ end
712
+
713
+ def self.RESPONSE_CLASS=(klass)
714
+ #; [!c1bd0] changes default response class.
715
+ remove_const :RESPONSE_CLASS
716
+ const_set :RESPONSE_CLASS, klass
717
+ end
718
+
719
+
720
+ ## Equivarent to BaseController or AbstractRequestHandler in other framework.
721
+ class BaseAction
722
+
723
+ def initialize(req, resp)
724
+ #; [!uotpb] accepts request and response objects.
725
+ @req = req
726
+ @resp = resp
727
+ #; [!7sfyf] sets session object.
728
+ @sess = req.env['rack.session']
729
+ end
730
+
731
+ attr_reader :req, :resp, :sess
732
+
733
+ def handle_action(action_method, urlpath_params)
734
+ @current_action = action_method
735
+ ex = nil
736
+ begin
737
+ #; [!5jnx6] calls '#before_action()' before handling request.
738
+ before_action()
739
+ #; [!ddgx3] invokes action method with urlpath params.
740
+ content = invoke_action(action_method, urlpath_params)
741
+ #; [!aqa4e] returns content.
742
+ return handle_content(content)
743
+ rescue Exception => ex
744
+ raise
745
+ ensure
746
+ #; [!67awf] calls '#after_action()' after handling request.
747
+ #; [!alpka] calls '#after_action()' even when error raised.
748
+ after_action(ex)
749
+ end
750
+ end
751
+
752
+ protected
753
+
754
+ def before_action
755
+ end
756
+
757
+ def after_action(ex)
758
+ end
759
+
760
+ def invoke_action(action_method, urlpath_params)
761
+ return self.__send__(action_method, *urlpath_params)
762
+ end
763
+
764
+ def handle_content(content)
765
+ return content
766
+ end
767
+
768
+ ##
769
+ ## ex:
770
+ ## mapping '/', :GET=>:do_index, :POST=>:do_create
771
+ ## mapping '/{id}', :GET=>:do_show, :PUT=>:do_update, :DELETE=>:do_delete
772
+ ##
773
+ def self.mapping(urlpath_pattern, methods={})
774
+ #; [!o148k] maps urlpath pattern and request methods.
775
+ self._action_method_mapping.map(urlpath_pattern, methods)
776
+ end
777
+
778
+ def self._action_method_mapping
779
+ return @action_method_mapping ||= ActionMethodMapping.new
780
+ end
781
+
782
+ end
783
+
784
+
785
+ ## Equivarent to Controller or RequestHandler in other framework.
786
+ class Action < BaseAction
787
+
788
+ #; [!siucz] request object is accessable with 'request' method as well as 'req'.
789
+ #; [!qnzp6] response object is accessable with 'response' method as well as 'resp'.
790
+ #; [!bd3y4] session object is accessable with 'session' method as well as 'sess'.
791
+ alias request req # just for compatibility with other frameworks; use @req!
792
+ alias response resp # just for compatibility with other frameworks; use @resp!
793
+ alias session sess # just for compatibility with other frameworks; use @sess!
794
+
795
+ protected
796
+
797
+ def before_action
798
+ csrf_protection() if csrf_protection_required?()
799
+ end
800
+
801
+ def after_action(ex)
802
+ return if ex
803
+ #; [!qsz2z] raises ContentTypeRequiredError when content type is not set.
804
+ unless @resp.headers['Content-Type']
805
+ status = @resp.status
806
+ status < 200 || 300 <= status || status == 204 or
807
+ raise ContentTypeRequiredError.new("Response header 'Content-Type' expected, but not provided.")
808
+ end
809
+ end
810
+
811
+ def invoke_action(action_method, urlpath_params)
812
+ begin
813
+ return super
814
+ #; [!d5v0l] handles exception when handler method defined.
815
+ rescue => ex
816
+ handler = "on_#{ex.class}"
817
+ return __send__(handler, ex) if respond_to?(handler)
818
+ raise
819
+ end
820
+ end
821
+
822
+ def handle_content(content)
823
+ case content
824
+ #; [!jhnzu] when content is nil...
825
+ when nil
826
+ #; [!sfwfz] returns [''].
827
+ return [""]
828
+ #; [!lkxua] when content is a hash object...
829
+ when Hash
830
+ #; [!9aaxl] converts hash object into JSON string.
831
+ #; [!c7nj7] sets content length.
832
+ #; [!j0c1d] sets content type as 'application/json' when not set.
833
+ #; [!gw05f] returns array of JSON string.
834
+ json_str = JSON.dump(content)
835
+ @resp.headers['Content-Length'] = json_str.bytesize.to_s
836
+ @resp.headers['Content-Type'] ||= "application/json"
837
+ return [json_str]
838
+ #; [!p6p99] when content is a string...
839
+ when String
840
+ #; [!1ejgh] sets content length.
841
+ #; [!uslm5] sets content type according to content when not set.
842
+ #; [!5q1u5] raises error when failed to detect content type.
843
+ #; [!79v6x] returns array of string.
844
+ @resp.headers['Content-Length'] = content.bytesize.to_s
845
+ @resp.headers['Content-Type'] ||= detect_content_type(content) or
846
+ raise ContentTypeRequiredError.new("Content-Type response header required.")
847
+ return [content]
848
+ #; [!s7eix] when content is an Enumerable object...
849
+ when Enumerable
850
+ #; [!md2go] just returns content.
851
+ #; [!ab3vr] neither content length nor content type are not set.
852
+ return content
853
+ #; [!apwh4] else...
854
+ else
855
+ #; [!wmgnr] raises K8::UnknownContentError.
856
+ raise UnknownContentError.new("Unknown content: class={content.class}, content=#{content.inspect}")
857
+ end
858
+ end
859
+
860
+ ## helpers
861
+
862
+ ## Returns "text/html; charset=utf-8" or "application/json" or nil.
863
+ def detect_content_type(text)
864
+ #; [!onjro] returns 'text/html; charset=utf-8' when text starts with '<'.
865
+ #; [!qiugc] returns 'application/json' when text starts with '{'.
866
+ #; [!zamnv] returns nil when text starts with neight '<' nor '{'.
867
+ case text
868
+ when /\A\s*</ ; return "text/html; charset=utf-8" # probably HTML
869
+ when /\A\s*\{/; return "application/json" # probably JSON
870
+ else ; return nil
871
+ end
872
+ end
873
+
874
+ #--
875
+ #def HTTP(status, message=nil, response_headers=nil)
876
+ # return HttpException.new(status, message, response_headers)
877
+ #end
878
+ #++
879
+
880
+ def redirect_to(location, status=302, flash: nil)
881
+ #; [!xkrfk] sets flash message if provided.
882
+ set_flash_message(flash) if flash
883
+ #; [!ev9nu] sets response status code as 302.
884
+ @resp.status = status
885
+ #; [!spfge] sets Location response header.
886
+ @resp.headers['Location'] = location
887
+ #; [!k3gvm] returns html anchor tag.
888
+ href = Util.h(location)
889
+ return "<a href=\"#{href}\">#{href}</a>"
890
+ end
891
+
892
+ def set_flash_message(message)
893
+ #; [!9f0iv] sets flash message into session.
894
+ self.sess['_flash'] = message
895
+ end
896
+
897
+ def get_flash_message
898
+ #; [!5minm] returns flash message stored in session.
899
+ #; [!056bp] deletes flash message from sesson.
900
+ return self.sess.delete('_flash')
901
+ end
902
+
903
+ def validation_failed
904
+ #; [!texnd] sets response status code as 422.
905
+ @resp.status = 422 # 422 Unprocessable Entity
906
+ nil
907
+ end
908
+
909
+ ##
910
+ ## helpers for CSRF protection
911
+ ##
912
+
913
+ protected
914
+
915
+ def csrf_protection_required?
916
+ #; [!8chgu] returns false when requested with 'XMLHttpRequest'.
917
+ return false if @req.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
918
+ #; [!vwrqv] returns true when request method is one of POST, PUT, or DELETE.
919
+ #; [!jfhla] returns true when request method is GET or HEAD.
920
+ req_meth = @req.method
921
+ return req_meth == :POST || req_meth == :PUT || req_meth == :DELETE
922
+ end
923
+
924
+ def csrf_protection
925
+ #; [!h5tzb] raises nothing when csrf token matched.
926
+ #; [!h0e0q] raises HTTP 400 when csrf token mismatched.
927
+ expected = csrf_get_token()
928
+ actual = csrf_get_param()
929
+ expected == actual or
930
+ raise HttpException.new(400, "invalid csrf token") # TODO: logging
931
+ nil
932
+ end
933
+
934
+ def csrf_get_token
935
+ #; [!mr6md] returns csrf cookie value.
936
+ @req.cookies['_csrf']
937
+ end
938
+
939
+ def csrf_set_token(token)
940
+ #; [!8hm2o] sets csrf cookie and returns token.
941
+ @resp.set_cookie('_csrf', token)
942
+ token
943
+ end
944
+
945
+ def csrf_get_param
946
+ #; [!pal33] returns csrf token in request parameter.
947
+ self.req.params['_csrf']
948
+ end
949
+
950
+ def csrf_new_token
951
+ #; [!zl6cl] returns new random token.
952
+ #; [!sfgfx] uses SHA1 + urlsafe BASE64.
953
+ return Util.randstr_b64()
954
+ end
955
+
956
+ def csrf_token
957
+ #; [!7gibo] returns current csrf token.
958
+ #; [!6vtqd] creates new csrf token and set it to cookie when csrf token is blank.
959
+ return @_csrf_token ||= (csrf_get_token() || csrf_set_token(csrf_new_token()))
960
+ end
961
+
962
+ ##
963
+
964
+ def send_file(filepath, content_type=nil)
965
+ #; [!iblvb] raises 404 Not Found when file not exist.
966
+ File.file?(filepath) or raise HttpException.new(404)
967
+ #; [!v7r59] returns nil with status code 304 when not modified.
968
+ mtime_utc = File.mtime(filepath).utc
969
+ mtime_str = K8::Util.http_utc_time(mtime_utc)
970
+ if mtime_str == @req.env['HTTP_IF_MODIFIED_SINCE']
971
+ @resp.status = 304
972
+ return nil
973
+ end
974
+ #; [!woho6] when gzipped file exists...
975
+ content_type ||= K8::Util.guess_content_type(filepath)
976
+ gzipped = "#{filepath}.gz"
977
+ if File.file?(gzipped) && mtime_utc <= File.mtime(gzipped).utc
978
+ #; [!9dmrf] returns gzipped file object when 'Accept-Encoding: gzip' exists.
979
+ #; [!m51dk] adds 'Content-Encoding: gzip' when 'Accept-Encoding: gzip' exists.
980
+ if /\bgzip\b/.match(@req.env['HTTP_ACCEPT_ENCODING'])
981
+ @resp.headers['Content-Encoding'] = 'gzip'
982
+ filepath = gzipped
983
+ end
984
+ end
985
+ #; [!e8l5o] sets Content-Type with guessing it from filename.
986
+ #; [!qhx0l] sets Content-Length with file size.
987
+ #; [!6j4fh] sets Last-Modified with file timestamp.
988
+ #; [!37i9c] returns opened file.
989
+ file = File.open(filepath)
990
+ headers = @resp.headers
991
+ headers['Content-Type'] ||= content_type
992
+ headers['Content-Length'] = File.size(filepath).to_s
993
+ headers['Last-Modified'] = mtime_str
994
+ return file
995
+ end
996
+
997
+ end
998
+
999
+
1000
+ class DefaultPatterns
1001
+
1002
+ def initialize
1003
+ @patterns = []
1004
+ end
1005
+
1006
+ def register(urlpath_param_name, default_pattern='[^/]*?', &converter)
1007
+ #; [!yfsom] registers urlpath param name, default pattern and converter block.
1008
+ @patterns << [urlpath_param_name, default_pattern, converter]
1009
+ self
1010
+ end
1011
+
1012
+ def unregister(urlpath_param_name)
1013
+ #; [!3gplv] deletes matched record.
1014
+ @patterns.delete_if {|tuple| tuple[0] == urlpath_param_name }
1015
+ self
1016
+ end
1017
+
1018
+ def lookup(urlpath_param_name)
1019
+ #; [!dvbqx] returns default pattern string and converter proc when matched.
1020
+ #; [!6hblo] returns '[^/]+?' and nil as default pattern and converter proc when nothing matched.
1021
+ for str_or_rexp, default_pat, converter in @patterns
1022
+ return default_pat, converter if str_or_rexp === urlpath_param_name
1023
+ end
1024
+ return '[^/]+?', nil
1025
+ end
1026
+
1027
+ end
1028
+
1029
+
1030
+ class ActionMethodMapping
1031
+
1032
+ def initialize
1033
+ @mappings = []
1034
+ end
1035
+
1036
+ ##
1037
+ ## ex:
1038
+ ## map '/', :GET=>:do_index, :POST=>:do_create
1039
+ ## map '/{id:\d+}', :GET=>:do_show, :PUT=>:do_update, :DELETE=>:do_delete
1040
+ ##
1041
+ def map(urlpath_pattern, action_methods={})
1042
+ action_methods = _normalize(action_methods)
1043
+ #; [!s7cs9] maps urlpath and methods.
1044
+ #; [!o6cxr] returns self.
1045
+ @mappings << [urlpath_pattern, action_methods]
1046
+ return self
1047
+ end
1048
+
1049
+ def _normalize(action_methods)
1050
+ d = {}
1051
+ action_methods.each do |req_meth, action_method|
1052
+ k = HTTP_REQUEST_METHODS[req_meth.to_s] or
1053
+ raise ArgumentError.new("#{req_meth.inspect}: unknown request method.")
1054
+ v = action_method
1055
+ d[k] = v.is_a?(Symbol) ? v : v.to_s.intern
1056
+ end
1057
+ return d # ex: {:GET=>:do_index, :POST=>:do_create}
1058
+ end
1059
+ private :_normalize
1060
+
1061
+ def each
1062
+ #; [!62y5q] yields each urlpath pattern and action methods.
1063
+ @mappings.each do |urlpath_pattern, action_methods|
1064
+ yield urlpath_pattern, action_methods
1065
+ end
1066
+ self
1067
+ end
1068
+
1069
+ end
1070
+
1071
+
1072
+ class ActionClassMapping
1073
+
1074
+ def initialize
1075
+ @mappings = []
1076
+ end
1077
+
1078
+ ##
1079
+ ## ex:
1080
+ ## mount '/', WelcomeAction
1081
+ ## mount '/books', BooksAction
1082
+ ## mount '/admin', [
1083
+ ## ['/session', AdminSessionAction],
1084
+ ## ['/books', AdminBooksAction],
1085
+ ## ]
1086
+ ##
1087
+ def mount(urlpath_pattern, action_class)
1088
+ _mount(@mappings, urlpath_pattern, action_class)
1089
+ #; [!w8mee] returns self.
1090
+ return self
1091
+ end
1092
+
1093
+ def _mount(mappings, urlpath_pattern, action_class)
1094
+ #; [!4l8xl] can accept array of pairs of urlpath and action class.
1095
+ if action_class.is_a?(Array)
1096
+ array = action_class
1097
+ child_mappings = []
1098
+ array.each {|upath, klass| _mount(child_mappings, upath, klass) }
1099
+ action_class = child_mappings
1100
+ #; [!ne804] when target class name is string...
1101
+ elsif action_class.is_a?(String)
1102
+ str = action_class
1103
+ action_class = _load_action_class(str, "mount('#{str}')")
1104
+ #; [!lvxyx] raises error when not an action class.
1105
+ else
1106
+ action_class.is_a?(Class) && action_class < BaseAction or
1107
+ raise ArgumentError.new("mount('#{urlpath_pattern}'): Action class expected but got: #{action_class.inspect}")
1108
+ end
1109
+ #; [!flb11] mounts action class to urlpath.
1110
+ mappings << [urlpath_pattern, action_class]
1111
+ end
1112
+ private :_mount
1113
+
1114
+ def _load_action_class(str, error)
1115
+ #; [!9brqr] raises error when string format is invalid.
1116
+ filepath, classname = str.split(/:/, 2)
1117
+ classname or
1118
+ raise ArgumentError.new("#{error}: expected 'file/path:ClassName'.")
1119
+ #; [!jpg56] loads file.
1120
+ #; [!vaazw] raises error when failed to load file.
1121
+ #; [!eiovd] raises original LoadError when it raises in loading file.
1122
+ begin
1123
+ require filepath
1124
+ rescue LoadError => ex
1125
+ raise unless ex.path == filepath
1126
+ raise ArgumentError.new("#{error}: failed to require file.")
1127
+ end
1128
+ #; [!au27n] finds target class.
1129
+ #; [!k9bpm] raises error when target class not found.
1130
+ begin
1131
+ action_class = classname.split(/::/).inject(Object) {|c, x| c.const_get(x) }
1132
+ rescue NameError
1133
+ raise ArgumentError.new("#{error}: no such action class.")
1134
+ end
1135
+ #; [!t6key] raises error when target class is not an action class.
1136
+ action_class.is_a?(Class) && action_class < BaseAction or
1137
+ raise ArgumentError.new("#{error}: not an action class.")
1138
+ return action_class
1139
+ end
1140
+ private :_load_action_class
1141
+
1142
+ def traverse(&block)
1143
+ _traverse(@mappings, "", &block)
1144
+ self
1145
+ end
1146
+
1147
+ def _traverse(mappings, base_urlpath_pat, &block)
1148
+ #; [!ds0fp] yields with event (:enter, :map or :exit).
1149
+ mappings.each do |urlpath_pattern, action_class|
1150
+ yield :enter, base_urlpath_pat, urlpath_pattern, action_class, nil
1151
+ curr_urlpath_pat = "#{base_urlpath_pat}#{urlpath_pattern}"
1152
+ if action_class.is_a?(Array)
1153
+ child_mappings = action_class
1154
+ _traverse(child_mappings, curr_urlpath_pat, &block)
1155
+ else
1156
+ action_method_mapping = action_class._action_method_mapping
1157
+ action_method_mapping.each do |upath_pat, action_methods|
1158
+ yield :map, curr_urlpath_pat, upath_pat, action_class, action_methods
1159
+ end
1160
+ end
1161
+ yield :exit, base_urlpath_pat, urlpath_pattern, action_class, nil
1162
+ end
1163
+ end
1164
+ private :_traverse
1165
+
1166
+ def each_mapping
1167
+ traverse() do
1168
+ |event, base_urlpath_pat, urlpath_pat, action_class, action_methods|
1169
+ next unless event == :map
1170
+ full_urlpath_pat = "#{base_urlpath_pat}#{urlpath_pat}"
1171
+ #; [!driqt] yields full urlpath pattern, action class and action methods.
1172
+ yield full_urlpath_pat, action_class, action_methods
1173
+ end
1174
+ self
1175
+ end
1176
+
1177
+ end
1178
+
1179
+
1180
+ class ActionFinder
1181
+
1182
+ def initialize(action_class_mapping, default_patterns=nil, urlpath_cache_size: 0)
1183
+ @default_patterns = default_patterns || K8::DefaultPatterns.new
1184
+ @urlpath_cache_size = urlpath_cache_size
1185
+ #; [!wb9l8] enables urlpath cache when urlpath_cache_size > 0.
1186
+ @urlpath_cache = urlpath_cache_size > 0 ? {} : nil # LRU cache of variable urlpath
1187
+ #; [!dnu4q] calls '#_construct()'.
1188
+ _construct(action_class_mapping)
1189
+ end
1190
+
1191
+ private
1192
+
1193
+ def _construct(action_class_mapping)
1194
+ ##
1195
+ ## Example of @rexp:
1196
+ ## \A # ...(0)
1197
+ ## (:? # ...(1)
1198
+ ## /api # ...(2)
1199
+ ## (?: # ...(3)
1200
+ ## /books # ...(2)
1201
+ ## (?: # ...(3)
1202
+ ## /\d+(\z) # ...(4)
1203
+ ## | # ...(5)
1204
+ ## /\d+/edit(\z) # ...(4)
1205
+ ## ) # ...(6)
1206
+ ## | # ...(7)
1207
+ ## /authors # ...(2)
1208
+ ## (:? # ...(4)
1209
+ ## /\d+(\z) # ...(4)
1210
+ ## | # ...(5)
1211
+ ## /\d+/edit(\z) # ...(4)
1212
+ ## ) # ...(6)
1213
+ ## ) # ...(6)
1214
+ ## | # ...(7)
1215
+ ## /admin # ...(2)
1216
+ ## (:? # ...(3)
1217
+ ## ....
1218
+ ## ) # ...(6)
1219
+ ## ) # ...(8)
1220
+ ##
1221
+ ## Example of @dict (fixed urlpaths):
1222
+ ## {
1223
+ ## "/api/books" # ...(9)
1224
+ ## => [BooksAction, {:GET=>:do_index, :POST=>:do_create}],
1225
+ ## "/api/books/new"
1226
+ ## => [BooksAction, {:GET=>:do_new}],
1227
+ ## "/api/authors"
1228
+ ## => [AuthorsAction, {:GET=>:do_index, :POST=>:do_create}],
1229
+ ## "/api/authors/new"
1230
+ ## => [AuthorsAction, {:GET=>:do_new}],
1231
+ ## "/admin/books"
1232
+ ## => ...
1233
+ ## ...
1234
+ ## }
1235
+ ##
1236
+ ## Example of @list (variable urlpaths):
1237
+ ## [
1238
+ ## [ # ...(10)
1239
+ ## %r'\A/api/books/(\d+)\z',
1240
+ ## ["id"], [proc {|x| x.to_i }],
1241
+ ## BooksAction,
1242
+ ## {:GET=>:do_show, :PUT=>:do_update, :DELETE=>:do_delete},
1243
+ ## ],
1244
+ ## [
1245
+ ## %r'\A/api/books/(\d+)/edit\z',
1246
+ ## ["id"], [proc {|x| x.to_i }],
1247
+ ## BooksAction,
1248
+ ## {:GET=>:do_edit},
1249
+ ## ],
1250
+ ## ...
1251
+ ## ]
1252
+ ##
1253
+ @dict = {}
1254
+ @list = []
1255
+ #; [!956fi] builds regexp object for variable urlpaths (= containing urlpath params).
1256
+ buf = ['\A'] # ...(0)
1257
+ buf << '(?:' # ...(1)
1258
+ action_class_mapping.traverse do
1259
+ |event, base_urlpath_pat, urlpath_pat, action_class, action_methods|
1260
+ first_p = buf[-1] == '(?:'
1261
+ case event
1262
+ when :map
1263
+ full_urlpath_pat = "#{base_urlpath_pat}#{urlpath_pat}"
1264
+ if full_urlpath_pat =~ /\{.*?\}/
1265
+ buf << '|' unless first_p # ...(5)
1266
+ buf << _compile(urlpath_pat, '', '(\z)').first # ...(4)
1267
+ full_urlpath_rexp_str, param_names, param_converters = \
1268
+ _compile(full_urlpath_pat, '\A', '\z', true)
1269
+ #; [!sl9em] builds list of variable urlpaths (= containing urlpath params).
1270
+ @list << [Regexp.compile(full_urlpath_rexp_str),
1271
+ param_names, param_converters,
1272
+ action_class, action_methods] # ...(9)
1273
+ else
1274
+ #; [!6tgj5] builds dict of fixed urlpaths (= no urlpath params).
1275
+ @dict[full_urlpath_pat] = [action_class, action_methods] # ...(10)
1276
+ end
1277
+ when :enter
1278
+ buf << '|' unless first_p # ...(7)
1279
+ buf << _compile(urlpath_pat, '', '').first # ...(2)
1280
+ buf << '(?:' # ...(3)
1281
+ when :exit
1282
+ if first_p
1283
+ buf.pop() # '(?:'
1284
+ buf.pop() # urlpath
1285
+ buf.pop() if buf[-1] == '|'
1286
+ else
1287
+ buf << ')' # ...(6)
1288
+ end
1289
+ else
1290
+ raise "** internal error: event=#{event.inspect}"
1291
+ end
1292
+ end
1293
+ buf << ')' # ...(8)
1294
+ @rexp = Regexp.compile(buf.join())
1295
+ buf.clear()
1296
+ end
1297
+
1298
+ def _compile(urlpath_pattern, start_pat='', end_pat='', grouping=false)
1299
+ #; [!izsbp] compiles urlpath pattern into regexp string and param names.
1300
+ #; [!olps9] allows '{}' in regular expression.
1301
+ #parse_rexp = /(.*?)<(\w*)(?::(.*?))?>/
1302
+ #parse_rexp = /(.*?)\{(\w*)(?::(.*?))?\}/
1303
+ #parse_rexp = /(.*?)\{(\w*)(?::(.*?(?:\{.*?\}.*?)*))?\}/
1304
+ parse_rexp = /(.*?)\{(\w*)(?::([^{}]*?(?:\{[^{}]*?\}[^{}]*?)*))?\}/
1305
+ param_names = []
1306
+ converters = []
1307
+ s = ""
1308
+ s << start_pat
1309
+ urlpath_pattern.scan(parse_rexp) do |text, name, pat|
1310
+ proc_ = nil
1311
+ pat, proc_ = @default_patterns.lookup(name) if pat.nil? || pat.empty?
1312
+ named = !name.empty?
1313
+ param_names << name if named
1314
+ converters << proc_ if named
1315
+ #; [!vey08] uses grouping when 4th argument is true.
1316
+ #; [!2zil2] don't use grouping when 4th argument is false.
1317
+ #; [!rda92] ex: '/{id:\d+}' -> '/(\d+)'
1318
+ #; [!jyz2g] ex: '/{:\d+}' -> '/\d+'
1319
+ #; [!hy3y5] ex: '/{:xx|yy}' -> '/(?:xx|yy)'
1320
+ #; [!gunsm] ex: '/{id:xx|yy}' -> '/(xx|yy)'
1321
+ if named && grouping
1322
+ pat = "(#{pat})"
1323
+ elsif pat =~ /\|/
1324
+ pat = "(?:#{pat})"
1325
+ end
1326
+ s << Regexp.escape(text) << pat
1327
+ end
1328
+ m = Regexp.last_match
1329
+ rest = m ? m.post_match : urlpath_pattern
1330
+ s << Regexp.escape(rest) << end_pat
1331
+ ## ex: ['/api/books/(\d+)', ["id"], [proc {|x| x.to_i }]]
1332
+ return s, param_names, converters
1333
+ end
1334
+
1335
+ public
1336
+
1337
+ def find(req_path)
1338
+ action_class, action_methods = @dict[req_path]
1339
+ if action_class
1340
+ #; [!p18w0] urlpath params are empty when matched to fixed urlpath pattern.
1341
+ param_names = []
1342
+ param_values = []
1343
+ #; [!gzy2w] fetches variable urlpath from LRU cache if LRU cache is enabled.
1344
+ elsif (cache = @urlpath_cache) && (tuple = cache.delete(req_path))
1345
+ cache[req_path] = tuple # Hash in Ruby >=1.9 keeps keys' order!
1346
+ action_class, action_methods, param_names, param_values = tuple
1347
+ else
1348
+ #; [!ps5jm] returns nil when not matched to any urlpath patterns.
1349
+ m = @rexp.match(req_path) or return nil
1350
+ i = m.captures.find_index('') or return nil
1351
+ #; [!t6yk0] urlpath params are not empty when matched to variable urlpath apttern.
1352
+ (full_urlpath_rexp, # ex: /\A\/api\/books\/(\d+)\z/
1353
+ param_names, # ex: ["id"]
1354
+ param_converters, # ex: [proc {|x| x.to_i }]
1355
+ action_class, # ex: BooksAction
1356
+ action_methods, # ex: {:GET=>:do_show, :PUT=>:do_edit, ...}
1357
+ ) = @list[i]
1358
+ #; [!0o3fe] converts urlpath param values according to default patterns.
1359
+ values = full_urlpath_rexp.match(req_path).captures
1360
+ procs = param_converters
1361
+ #param_values = procs.zip(values).map {|pr, v| pr ? pr.call(v) : v }
1362
+ param_values = \
1363
+ case procs.length
1364
+ when 1; pr0 = procs[0]
1365
+ [pr0 ? pr0.call(values[0]) : values[0]]
1366
+ when 2; pr0, pr1 = procs
1367
+ [pr0 ? pr0.call(values[0]) : values[0],
1368
+ pr1 ? pr1.call(values[1]) : values[1]]
1369
+ else ; procs.zip(values).map {|pr, v| pr ? pr.call(v) : v }
1370
+ end # ex: ["123"] -> [123]
1371
+ #; [!v2zbx] caches variable urlpath into LRU cache if cache is enabled.
1372
+ #; [!nczw6] LRU cache size doesn't growth over max cache size.
1373
+ if cache
1374
+ cache.shift() if cache.length > @urlpath_cache_size - 1
1375
+ cache[req_path] = [action_class, action_methods, param_names, param_values]
1376
+ end
1377
+ end
1378
+ #; [!ndktw] returns action class, action methods, urlpath names and values.
1379
+ ## ex: [BooksAction, {:GET=>:do_show}, ["id"], [123]]
1380
+ return action_class, action_methods, param_names, param_values
1381
+ end
1382
+
1383
+ end
1384
+
1385
+
1386
+ ## Router consists of urlpath mapping and finder.
1387
+ class ActionRouter
1388
+
1389
+ def initialize(urlpath_cache_size: 0)
1390
+ @mapping = ActionClassMapping.new
1391
+ @default_patterns = DefaultPatterns.new
1392
+ @finder = nil
1393
+ #; [!l1elt] saves finder options.
1394
+ @finder_opts = {:urlpath_cache_size=>urlpath_cache_size}
1395
+ end
1396
+
1397
+ attr_reader :default_patterns
1398
+
1399
+ def register(urlpath_param_name, default_pattern='[^/]*?', &converter)
1400
+ #; [!boq80] registers urlpath param pattern and converter.
1401
+ @default_patterns.register(urlpath_param_name, default_pattern, &converter)
1402
+ self
1403
+ end
1404
+
1405
+ def mount(urlpath_pattern, action_class)
1406
+ #; [!uc996] mouts action class to urlpath.
1407
+ @mapping.mount(urlpath_pattern, action_class)
1408
+ #; [!trs6w] removes finder object.
1409
+ @finder = nil
1410
+ self
1411
+ end
1412
+
1413
+ def each_mapping(&block)
1414
+ #; [!2kq9h] yields with full urlpath pattern, action class and action methods.
1415
+ @mapping.each_mapping(&block)
1416
+ end
1417
+
1418
+ def find(req_path)
1419
+ #; [!zsuzg] creates finder object automatically if necessary.
1420
+ #; [!9u978] urlpath_cache_size keyword argument will be passed to router oubject.
1421
+ @finder ||= ActionFinder.new(@mapping, @default_patterns, @finder_opts)
1422
+ #; [!m9klu] returns action class, action methods, urlpath param names and values.
1423
+ return @finder.find(req_path)
1424
+ end
1425
+
1426
+ end
1427
+
1428
+
1429
+ class RackApplication
1430
+
1431
+ def initialize(urlpath_mapping=[], urlpath_cache_size: 0)
1432
+ @router = ActionRouter.new(urlpath_cache_size: urlpath_cache_size)
1433
+ init_default_param_patterns(@router.default_patterns)
1434
+ #; [!vkp65] mounts urlpath mappings if provided.
1435
+ urlpath_mapping.each do |urlpath, klass|
1436
+ @router.mount(urlpath, klass)
1437
+ end if urlpath_mapping
1438
+ end
1439
+
1440
+ def init_default_param_patterns(default_patterns)
1441
+ #; [!i51id] registers '\d+' as default pattern of param 'id' or /_id\z/.
1442
+ x = default_patterns
1443
+ x.register('id', '\d+') {|val| val.to_i }
1444
+ x.register(/_id\z/, '\d+') {|val| val.to_i }
1445
+ #; [!2g08b] registers '(?:\.\w+)?' as default pattern of param 'ext'.
1446
+ x.register('ext', '(?:\.\w+)?')
1447
+ #; [!8x5mp] registers '\d\d\d\d-\d\d-\d\d' as default pattern of param 'date' or /_date\z/.
1448
+ to_date = proc {|val|
1449
+ #; [!wg9vl] raises 404 error when invalid date (such as 2012-02-30).
1450
+ yr, mo, dy = val.split(/-/).map(&:to_i)
1451
+ Date.new(yr, mo, dy) rescue
1452
+ raise HttpException.new(404, "#{val}: invalid date.")
1453
+ }
1454
+ x.register('date', '\d\d\d\d-\d\d-\d\d', &to_date)
1455
+ x.register(/_date\z/, '\d\d\d\d-\d\d-\d\d', &to_date)
1456
+ end
1457
+ protected :init_default_param_patterns
1458
+
1459
+ ##
1460
+ ## ex:
1461
+ ## mount '/', WelcomeAction
1462
+ ## mount '/books', BooksAction
1463
+ ## mount '/admin', [
1464
+ ## ['/session', AdminSessionAction],
1465
+ ## ['/books', AdminBooksAction],
1466
+ ## ]
1467
+ ##
1468
+ def mount(urlpath_pattern, action_class_or_array)
1469
+ #; [!zwva6] mounts action class to urlpath pattern.
1470
+ @router.mount(urlpath_pattern, action_class_or_array)
1471
+ return self
1472
+ end
1473
+
1474
+ def find(req_path)
1475
+ #; [!o0rnr] returns action class, action methods, urlpath names and values.
1476
+ return @router.find(req_path)
1477
+ end
1478
+
1479
+ def call(env)
1480
+ #; [!uvmxe] takes env object.
1481
+ #; [!gpe4g] returns status, headers and content.
1482
+ return handle_request(REQUEST_CLASS.new(env), RESPONSE_CLASS.new)
1483
+ end
1484
+
1485
+ protected
1486
+
1487
+ def handle_request(req, resp)
1488
+ req_meth = HTTP_REQUEST_METHODS[req.env['REQUEST_METHOD']]
1489
+ #; [!l6kmc] uses 'GET' method to find action when request method is 'HEAD'.
1490
+ if req_meth == :HEAD
1491
+ req_meth_ = :GET
1492
+ #; [!4vmd3] uses '_method' value of query string as request method when 'POST' method.
1493
+ elsif req_meth == :POST && /\A_method=(\w+)/.match(req.env['QUERY_STRING'])
1494
+ req_meth_ = HTTP_REQUEST_METHODS[$1] || $1
1495
+ else
1496
+ req_meth_ = req_meth
1497
+ end
1498
+ begin
1499
+ #; [!rz13i] returns HTTP 404 when urlpath not found.
1500
+ tuple = find(req.path) or
1501
+ raise HttpException.new(404)
1502
+ action_class, action_methods, urlpath_param_names, urlpath_param_values = tuple
1503
+ #; [!rv3cf] returns HTTP 405 when urlpath found but request method not allowed.
1504
+ action_method = action_methods[req_meth_] or
1505
+ raise HttpException.new(405)
1506
+ #; [!0fgbd] finds action class and invokes action method with urlpath params.
1507
+ action_obj = action_class.new(req, resp)
1508
+ content = action_obj.handle_action(action_method, urlpath_param_values)
1509
+ tuple = [resp.status, resp.headers, content]
1510
+ rescue HttpException => ex
1511
+ tuple = handle_http(ex, req, resp)
1512
+ rescue Exception => ex
1513
+ tuple = handle_error(ex, req, resp)
1514
+ ensure
1515
+ #; [!vdllr] clears request and response if possible.
1516
+ req.clear() if req.respond_to?(:clear)
1517
+ resp.clear() if resp.respond_to?(:clear)
1518
+ end
1519
+ #; [!9wp9z] returns empty body when request method is HEAD.
1520
+ tuple[2] = [""] if req_meth == :HEAD
1521
+ return tuple
1522
+ end
1523
+
1524
+ def handle_http(ex, req, resp)
1525
+ if json_expected?(req)
1526
+ content = render_http_exception_as_json(ex, req, resp)
1527
+ content_type = "application/json"
1528
+ else
1529
+ content = render_http_exception_as_html(ex, req, resp)
1530
+ content_type = "text/html;charset=utf-8"
1531
+ end
1532
+ headers = {
1533
+ "Content-Type" => content_type,
1534
+ "Content-Length" => content.bytesize.to_s,
1535
+ }
1536
+ headers.update(ex.response_headers) if ex.response_headers
1537
+ return [ex.status_code, headers, [content]]
1538
+ end
1539
+
1540
+ def handle_error(ex, req, resp)
1541
+ raise ex
1542
+ end
1543
+
1544
+ def render_http_exception_as_json(ex, req, resp)
1545
+ return JSON.dump({
1546
+ "error" => ex.message,
1547
+ "status" => "#{ex.status_code} #{ex.status_message}",
1548
+ })
1549
+ end
1550
+
1551
+ def render_http_exception_as_html(ex, req, resp)
1552
+ return <<"END"
1553
+ <div>
1554
+ <h2>#{ex.status_code} #{ex.status_message}</h2>
1555
+ <p>#{ex.message}</p>
1556
+ </div>
1557
+ END
1558
+ end
1559
+
1560
+ def json_expected?(req)
1561
+ return true if req.path.end_with?('.json')
1562
+ return true if req.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
1563
+ return false
1564
+ end
1565
+
1566
+ public
1567
+
1568
+ def each_mapping(&block)
1569
+ #; [!cgjyv] yields full urlpath pattern, action class and action methods.
1570
+ @router.each_mapping(&block)
1571
+ self
1572
+ end
1573
+
1574
+ def show_mappings()
1575
+ #; [!u1g77] returns all mappings as YAML string.
1576
+ req_methods = HTTP_REQUEST_METHODS.values() + [:ANY]
1577
+ s = ""
1578
+ each_mapping do |full_urlpath_pat, action_class, action_methods|
1579
+ arr = req_methods.collect {|req_meth|
1580
+ action_method = action_methods[req_meth]
1581
+ action_method ? "#{req_meth}: #{action_method}" : nil
1582
+ }.compact()
1583
+ s << "- urlpath: #{full_urlpath_pat}\n"
1584
+ s << " class: #{action_class}\n"
1585
+ s << " methods: {#{arr.join(', ')}}\n"
1586
+ s << "\n"
1587
+ end
1588
+ return s
1589
+ end
1590
+
1591
+ end
1592
+
1593
+
1594
+ class SecretValue < Object
1595
+
1596
+ def initialize(name=nil)
1597
+ #; [!fbwnh] takes environment variable name.
1598
+ @name = name
1599
+ end
1600
+
1601
+ attr_reader :name
1602
+
1603
+ def value
1604
+ #; [!gg06v] returns environment variable value.
1605
+ return @name ? ENV[@name] : nil
1606
+ end
1607
+
1608
+ def to_s
1609
+ #; [!7ymqq] returns '<SECRET>' string when name not eixst.
1610
+ #; [!x6edf] returns 'ENV[<name>]' string when name exists.
1611
+ return @name ? "ENV['#{@name}']" : "<SECRET>"
1612
+ end
1613
+
1614
+ #; [!j27ji] 'inspect()' is alias of 'to_s()'.
1615
+ alias inspect to_s
1616
+
1617
+ def [](name)
1618
+ #; [!jjqmn] creates new instance object with name.
1619
+ self.class.new(name)
1620
+ end
1621
+
1622
+ end
1623
+
1624
+
1625
+ class BaseConfig < Object
1626
+
1627
+ SECRET = SecretValue.new
1628
+
1629
+ def initialize(freeze: true)
1630
+ #; [!vvd1n] copies key and values from class object.
1631
+ self.class.each do |key, val, _, _|
1632
+ #; [!xok12] when value is SECRET...
1633
+ if val.is_a?(SecretValue)
1634
+ #; [!a4a4p] raises error when key not specified.
1635
+ val.name or
1636
+ raise ConfigError.new("config '#{key}' should be set, but not.")
1637
+ #; [!w4yl7] raises error when ENV value not specified.
1638
+ ENV[val.name] or
1639
+ raise ConfigError.new("config '#{key}' depends on ENV['#{val.name}'], but not set.")
1640
+ #; [!he20d] get value from ENV.
1641
+ val = ENV[val.name]
1642
+ end
1643
+ instance_variable_set("@#{key}", val)
1644
+ end
1645
+ #; [!6dilv] freezes self and class object if 'freeze:' is true.
1646
+ self.class.freeze if freeze
1647
+ self.freeze if freeze
1648
+ end
1649
+
1650
+ def self.validate_values # :nodoc:
1651
+ not_set = []
1652
+ not_env = []
1653
+ each() do |key, val, _, _|
1654
+ if val.is_a?(SecretValue)
1655
+ if ! val.name
1656
+ not_set << [key, val]
1657
+ elsif ! ENV[val.name]
1658
+ not_env << [key, val]
1659
+ end
1660
+ end
1661
+ end
1662
+ return nil if not_set.empty? && not_env.empty?
1663
+ sb = []
1664
+ sb << "**"
1665
+ sb << "** ERROR: insufficient config"
1666
+ unless not_set.empty?
1667
+ sb << "**"
1668
+ sb << "** The following configs should be set, but not."
1669
+ sb << "**"
1670
+ not_set.each do |key, val|
1671
+ sb << "** %-25s %s" % [key, val]
1672
+ end
1673
+ end
1674
+ unless not_env.empty?
1675
+ sb << "**"
1676
+ sb << "** The following configs expect environment variable, but not set."
1677
+ sb << "**"
1678
+ not_env.each do |key, val|
1679
+ sb << "** %-25s %s" % [key, val]
1680
+ end
1681
+ end
1682
+ sb << "**"
1683
+ sb << ""
1684
+ return sb.join("\n")
1685
+ end
1686
+
1687
+ private
1688
+
1689
+ def self.__all
1690
+ return @__all ||= {}
1691
+ end
1692
+
1693
+ public
1694
+
1695
+ def self.has?(key)
1696
+ #; [!dv87n] returns true iff key is set.
1697
+ return __all().key?(key)
1698
+ end
1699
+
1700
+ def self.put(key, value, desc=nil)
1701
+ #; [!h9b47] defines getter method.
1702
+ attr_reader key
1703
+ d = __all()
1704
+ #; [!mun1v] keeps secret flag.
1705
+ if d[key]
1706
+ desc ||= d[key][1]
1707
+ secret = d[key][2]
1708
+ else
1709
+ secret = value == SECRET
1710
+ end
1711
+ #; [!ncwzt] stores key with value, description and secret flag.
1712
+ d[key] = [value, desc, secret]
1713
+ nil
1714
+ end
1715
+
1716
+ def self.add(key, value, desc=nil)
1717
+ #; [!envke] raises error when already added.
1718
+ ! self.has?(key) or
1719
+ raise K8::ConfigError.new("add(#{key.inspect}, #{value.inspect}): cannot add because already added; use set() or put() instead.")
1720
+ #; [!6cmb4] adds new key, value and desc.
1721
+ self.put(key, value, desc)
1722
+ end
1723
+
1724
+ def self.set(key, value, desc=nil)
1725
+ #; [!2yis0] raises error when not added yet.
1726
+ self.has?(key) or
1727
+ raise K8::ConfigError.new("add(#{key.inspect}, #{value.inspect}): cannot set because not added yet; use add() or put() instead.")
1728
+ #; [!3060g] sets key, value and desc.
1729
+ self.put(key, value, desc)
1730
+ end
1731
+
1732
+ def self.each
1733
+ #; [!iu88i] yields with key, value, desc and secret flag.
1734
+ __all().each do |key, (value, desc, secret)|
1735
+ yield key, value, desc, secret
1736
+ end
1737
+ nil
1738
+ end
1739
+
1740
+ def self.get(key, default=nil)
1741
+ #; [!zlhnp] returns value corresponding to key.
1742
+ #; [!o0k05] returns default value (=nil) when key is not added.
1743
+ tuple = __all()[key]
1744
+ return tuple ? tuple.first : default
1745
+ end
1746
+
1747
+ def [](key)
1748
+ #; [!jn9l5] returns value corresponding to key.
1749
+ return __send__(key)
1750
+ end
1751
+
1752
+ def get_all(prefix_key)
1753
+ #; [!4ik3c] returns all keys and values which keys start with prefix as hash object.
1754
+ prefix = "@#{prefix_key}"
1755
+ symbol_p = prefix_key.is_a?(Symbol)
1756
+ range = prefix.length..-1
1757
+ d = {}
1758
+ self.instance_variables.each do |ivar|
1759
+ if ivar.to_s.start_with?(prefix)
1760
+ val = self.instance_variable_get(ivar)
1761
+ key = ivar[range].intern
1762
+ key = key.intern if symbol_p
1763
+ d[key] = val
1764
+ end
1765
+ end
1766
+ return d
1767
+ end
1768
+
1769
+ end
1770
+
1771
+
1772
+ class ConfigError < StandardError
1773
+ end
1774
+
1775
+
1776
+ module Mock
1777
+
1778
+
1779
+ module_function
1780
+
1781
+ def new_env(meth=:GET, path="/", query: nil, form: nil, multipart: nil, json: nil, input: nil, headers: nil, cookie: nil, env: nil)
1782
+ #uri = "http://localhost:80#{path}"
1783
+ #opts["REQUEST_METHOD"] = meth
1784
+ #env = Rack::MockRequest.env_for(uri, opts)
1785
+ require 'stringio' unless defined?(StringIO)
1786
+ https = env && (env['rack.url_scheme'] == 'https' || env['HTTPS'] == 'on')
1787
+ #
1788
+ err = proc {|a, b|
1789
+ ArgumentError.new("new_env(): not allowed both '#{a}' and '#{b}' at a time.")
1790
+ }
1791
+ ctype = nil
1792
+ if form
1793
+ #; [!c779l] raises ArgumentError when both form and json are specified.
1794
+ ! json or raise err.call('form', 'json')
1795
+ input = Util.build_query_string(form)
1796
+ ctype = "application/x-www-form-urlencoded"
1797
+ end
1798
+ if json
1799
+ ! multipart or raise err.call('json', 'multipart')
1800
+ input = json.is_a?(String) ? json : JSON.dump(json)
1801
+ ctype = "application/json"
1802
+ end
1803
+ if multipart
1804
+ ! form or raise err.call('multipart', 'form')
1805
+ #; [!gko8g] 'multipart:' kwarg accepts Hash object (which is converted into multipart data).
1806
+ if multipart.is_a?(Hash)
1807
+ dict = multipart
1808
+ multipart = dict.each_with_object(MultipartBuilder.new) do |(k, v), mp|
1809
+ v.is_a?(File) ? mp.add_file(k, v) : mp.add(k, v.to_s)
1810
+ end
1811
+ end
1812
+ input = multipart.to_s
1813
+ m = /\A--(\S+)\r\n/.match(input) or
1814
+ raise ArgumentError.new("invalid multipart format.")
1815
+ boundary = $1
1816
+ ctype = "multipart/form-data; boundary=#{boundary}"
1817
+ end
1818
+ environ = {
1819
+ "rack.version" => [1, 3],
1820
+ "rack.input" => StringIO.new(input || ""),
1821
+ "rack.errors" => StringIO.new,
1822
+ "rack.multithread" => true,
1823
+ "rack.multiprocess" => true,
1824
+ "rack.run_once" => false,
1825
+ "rack.url_scheme" => https ? "https" : "http",
1826
+ "REQUEST_METHOD" => meth.to_s,
1827
+ "SERVER_NAME" => "localhost",
1828
+ "SERVER_PORT" => https ? "443" : "80",
1829
+ "QUERY_STRING" => Util.build_query_string(query || ""),
1830
+ "PATH_INFO" => path,
1831
+ "HTTPS" => https ? "on" : "off",
1832
+ "SCRIPT_NAME" => "",
1833
+ "CONTENT_LENGTH" => (input ? input.bytesize.to_s : "0"),
1834
+ "CONTENT_TYPE" => ctype,
1835
+ }
1836
+ environ.delete("CONTENT_TYPE") if environ["CONTENT_TYPE"].nil?
1837
+ headers.each do |name, value|
1838
+ name =~ /\A[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*\z/ or
1839
+ raise ArgumentError.new("invalid http header name: #{name.inspect}")
1840
+ value.is_a?(String) or
1841
+ raise ArgumentError.new("http header value should be a string but got: #{value.inspect}")
1842
+ ## ex: 'X-Requested-With' -> 'HTTP_X_REQUESTED_WITH'
1843
+ k = "HTTP_#{name.upcase.gsub(/-/, '_')}"
1844
+ environ[k] = value
1845
+ end if headers
1846
+ env.each do |name, value|
1847
+ case name
1848
+ when /\Arack\./
1849
+ # ok
1850
+ when /\A[A-Z]+(_[A-Z0-9]+)*\z/
1851
+ value.is_a?(String) or
1852
+ raise ArgumentError.new("rack env value should be a string but got: #{value.inspect}")
1853
+ else
1854
+ raise ArgumentError.new("invalid rack env key: #{name}")
1855
+ end
1856
+ environ[name] = value
1857
+ end if env
1858
+ if cookie
1859
+ s = ! cookie.is_a?(Hash) ? cookie.to_s : cookie.map {|k, v|
1860
+ "#{Util.percent_encode(k)}=#{Util.percent_encode(v)}"
1861
+ }.join('; ')
1862
+ s = "#{environ['HTTP_COOKIE']}; #{s}" if environ['HTTP_COOKIE']
1863
+ environ['HTTP_COOKIE'] = s
1864
+ end
1865
+ return environ
1866
+ end
1867
+
1868
+
1869
+ class MultipartBuilder
1870
+
1871
+ def initialize(boundary=nil)
1872
+ #; [!ajfgl] sets random string as boundary when boundary is nil.
1873
+ @boundary = boundary || Util.randstr_b64()
1874
+ @params = []
1875
+ end
1876
+
1877
+ attr_reader :boundary
1878
+
1879
+ def add(name, value, filename=nil, content_type=nil)
1880
+ #; [!tp4bk] detects content type from filename when filename is not nil.
1881
+ content_type ||= Util.guess_content_type(filename) if filename
1882
+ @params << [name, value, filename, content_type]
1883
+ self
1884
+ end
1885
+
1886
+ def add_file(name, file, content_type=nil)
1887
+ #; [!uafqa] detects content type from filename when content type is not provided.
1888
+ content_type ||= Util.guess_content_type(file.path)
1889
+ #; [!b5811] reads file content and adds it as param value.
1890
+ add(name, file.read(), File.basename(file.path), content_type)
1891
+ #; [!36bsu] closes opened file automatically.
1892
+ file.close()
1893
+ self
1894
+ end
1895
+
1896
+ def to_s
1897
+ #; [!61gc4] returns multipart form string.
1898
+ boundary = @boundary
1899
+ s = "".force_encoding('ASCII-8BIT')
1900
+ @params.each do |name, value, filename, content_type|
1901
+ s << "--#{boundary}\r\n"
1902
+ if filename
1903
+ s << "Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n"
1904
+ else
1905
+ s << "Content-Disposition: form-data; name=\"#{name}\"\r\n"
1906
+ end
1907
+ s << "Content-Type: #{content_type}\r\n" if content_type
1908
+ s << "\r\n"
1909
+ s << value.force_encoding('ASCII-8BIT')
1910
+ s << "\r\n"
1911
+ end
1912
+ s << "--#{boundary}--\r\n"
1913
+ return s
1914
+ end
1915
+
1916
+ end
1917
+
1918
+
1919
+ ##
1920
+ ## Wrapper class to test Rack application.
1921
+ ##
1922
+ ## ex:
1923
+ ## http = K8::Mock::TestApp.new(app)
1924
+ ## resp = http.GET('/api/hello', query={'name'=>'World'})
1925
+ ## assert_equal 200, resp.status
1926
+ ## assert_equal "application/json", resp.headers['Content-Type']
1927
+ ## assert_equal {"message"=>"Hello World!"}, resp.body_json
1928
+ ##
1929
+ class TestApp
1930
+
1931
+ def initialize(app=nil)
1932
+ @app = app
1933
+ end
1934
+
1935
+ def request(meth, path, query: nil, form: nil, multipart: nil, json: nil, input: nil, headers: nil, cookie: nil, env: nil)
1936
+ #; [!4xpwa] creates env object and calls app with it.
1937
+ env = K8::Mock.new_env(meth, path, query: query, form: form, multipart: multipart, json: json, input: input, headers: headers, cookie: cookie, env: env)
1938
+ status, headers, body = @app.call(env)
1939
+ return TestResponse.new(status, headers, body)
1940
+ end
1941
+
1942
+ def GET path, kwargs={}; request(:GET , path, kwargs); end
1943
+ def POST path, kwargs={}; request(:POST , path, kwargs); end
1944
+ def PUT path, kwargs={}; request(:PUT , path, kwargs); end
1945
+ def DELETE path, kwargs={}; request(:DELETE , path, kwargs); end
1946
+ def HEAD path, kwargs={}; request(:HEAD , path, kwargs); end
1947
+ def PATCH path, kwargs={}; request(:PATCH , path, kwargs); end
1948
+ def OPTIONS path, kwargs={}; request(:OPTIONS, path, kwargs); end
1949
+ def TRACE path, kwargs={}; request(:TRACE , path, kwargs); end
1950
+
1951
+ end
1952
+
1953
+
1954
+ class TestResponse
1955
+
1956
+ def initialize(status, headers, body)
1957
+ @status = status
1958
+ @headers = headers
1959
+ @body = body
1960
+ end
1961
+
1962
+ attr_accessor :status, :headers, :body
1963
+
1964
+ def body_binary
1965
+ #; [!mb0i4] returns body as binary string.
1966
+ s = @body.join()
1967
+ @body.close() if @body.respond_to?(:close)
1968
+ return s
1969
+ end
1970
+
1971
+ def body_text
1972
+ #; [!rr18d] error when 'Content-Type' header is missing.
1973
+ ctype = @headers['Content-Type'] or
1974
+ raise "body_text(): missing 'Content-Type' header."
1975
+ #; [!dou1n] converts body text according to 'charset' in 'Content-Type' header.
1976
+ if ctype =~ /; *charset=(\w[-\w]*)/
1977
+ charset = $1
1978
+ #; [!cxje7] assumes charset as 'utf-8' when 'Content-Type' is json.
1979
+ elsif ctype == "application/json"
1980
+ charset = 'utf-8'
1981
+ #; [!n4c71] error when non-json 'Content-Type' header has no 'charset'.
1982
+ else
1983
+ raise "body_text(): missing 'charset' in 'Content-Type' header."
1984
+ end
1985
+ #; [!vkj9h] returns body as text string, according to 'charset' in 'Content-Type'.
1986
+ return body_binary().force_encoding(charset)
1987
+ end
1988
+
1989
+ def body_json
1990
+ #; [!qnic1] returns Hash object representing JSON string.
1991
+ return JSON.parse(body_text())
1992
+ end
1993
+
1994
+ def content_type
1995
+ #; [!40hcz] returns 'Content-Type' header value.
1996
+ return @headers['Content-Type']
1997
+ end
1998
+
1999
+ def content_length
2000
+ #; [!5lb19] returns 'Content-Length' header value as integer.
2001
+ #; [!qjktz] returns nil when 'Content-Length' is not set.
2002
+ len = @headers['Content-Length']
2003
+ return len ? len.to_i : len
2004
+ end
2005
+
2006
+ def location
2007
+ #; [!8y8lg] returns 'Location' header value.p
2008
+ return @headers['Location']
2009
+ end
2010
+
2011
+ end
2012
+
2013
+
2014
+ end
2015
+
2016
+
2017
+ end