keight 0.0.1

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 (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