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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +263 -0
- data/Rakefile +92 -0
- data/bench/bench.rb +278 -0
- data/bench/benchmarker.rb +502 -0
- data/bin/k8rb +496 -0
- data/keight.gemspec +36 -0
- data/lib/keight/skeleton/.gitignore +10 -0
- data/lib/keight/skeleton/app/action.rb +98 -0
- data/lib/keight/skeleton/app/api/hello.rb +39 -0
- data/lib/keight/skeleton/app/form/.keep +0 -0
- data/lib/keight/skeleton/app/helper/.keep +0 -0
- data/lib/keight/skeleton/app/model/.keep +0 -0
- data/lib/keight/skeleton/app/model.rb +144 -0
- data/lib/keight/skeleton/app/page/welcome.rb +17 -0
- data/lib/keight/skeleton/app/template/_layout.html.eruby +56 -0
- data/lib/keight/skeleton/app/template/welcome.html.eruby +6 -0
- data/lib/keight/skeleton/app/usecase/.keep +0 -0
- data/lib/keight/skeleton/config/app.rb +29 -0
- data/lib/keight/skeleton/config/app_dev.private +11 -0
- data/lib/keight/skeleton/config/app_dev.rb +8 -0
- data/lib/keight/skeleton/config/app_prod.rb +7 -0
- data/lib/keight/skeleton/config/app_stg.rb +5 -0
- data/lib/keight/skeleton/config/app_test.private +11 -0
- data/lib/keight/skeleton/config/app_test.rb +8 -0
- data/lib/keight/skeleton/config/server_puma.rb +22 -0
- data/lib/keight/skeleton/config/server_unicorn.rb +21 -0
- data/lib/keight/skeleton/config/urlpath_mapping.rb +16 -0
- data/lib/keight/skeleton/config.rb +44 -0
- data/lib/keight/skeleton/config.ru +21 -0
- data/lib/keight/skeleton/index.txt +38 -0
- data/lib/keight/skeleton/static/lib/jquery/1.11.3/jquery.min.js +6 -0
- data/lib/keight/skeleton/static/lib/jquery/1.11.3/jquery.min.js.gz +0 -0
- data/lib/keight/skeleton/static/lib/modernizr/2.8.3/modernizr.min.js +4 -0
- data/lib/keight/skeleton/static/lib/modernizr/2.8.3/modernizr.min.js.gz +0 -0
- data/lib/keight/skeleton/tmp/upload/.keep +0 -0
- data/lib/keight.rb +2017 -0
- data/test/data/example1.jpg +0 -0
- data/test/data/example1.png +0 -0
- data/test/data/multipart.form +0 -0
- data/test/data/wabisabi.js +77 -0
- data/test/data/wabisabi.js.gz +0 -0
- data/test/keight_test.rb +3161 -0
- data/test/oktest.rb +1537 -0
- 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 = {'&'=>'&', '<'=>'<', '>'=>'>', '"'=>'"', "'"=>'''}
|
180
|
+
|
181
|
+
def escape_html(str)
|
182
|
+
#; [!90jx8] escapes '& < > " \'' into '& < > " ''.
|
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
|