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