rack-test_app 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 8f2ce0590323aed326f765838e0e0ff9fcbafa23
4
+ data.tar.gz: ac91d827a933137c4b1a44b15d453f64f3d4c094
5
+ SHA512:
6
+ metadata.gz: 084c7935751936114289ab16209e1b2e537d9acd4945ad34790ee611fc5cf8a24ef0e0efa6909fe01a0d3c3f2baaed3cc47cb585b51c2378fae373320a58fb85
7
+ data.tar.gz: 3982adf894d08f028bded159fe9f6e30ee9a3142a0e21cad104402e88b09abf9fc8f18b39f741075a79b5852db877fb3473d56ca428c6d0ec1149e160521a987
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ $Copyright: copyright(c) 2015 kuwata-lab.com all rights reserved $
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,122 @@
1
+ # Rack::TestApp
2
+
3
+
4
+ Rack::TestApp is another testing helper library for Rack application.
5
+ IMO, it is more intuitive than Rack::Test.
6
+
7
+ Rack::TestApp requires Ruby >= 2.0.
8
+
9
+
10
+ ## Installation
11
+
12
+ ```console
13
+ $ gem install rack-test_app
14
+ ```
15
+
16
+ Or:
17
+
18
+ ```console
19
+ $ echo "gem 'rack-test_app'" >> Gemfile
20
+ $ bundle
21
+ ```
22
+
23
+
24
+ ## Example
25
+
26
+ ```ruby
27
+ require 'rack'
28
+ require 'rack/lint'
29
+ require 'rack/test_app'
30
+
31
+ ## sample Rack app
32
+ app = proc {|env|
33
+ text = "{\"status\":\"OK\"}"
34
+ headers = {"Content-Type" => "application/json",
35
+ "Content-Length" => text.bytesize.to_s}
36
+ [200, headers, [text]]
37
+ }
38
+
39
+ ## crate wrapper objects
40
+ http = Rack::TestApp.wrap(Rack::Lint.new(app))
41
+ https = Rack::TestApp.wrap(Rack::Lint.new(app), env: {'HTTPS'=>'on'})
42
+
43
+ ## simulates http request
44
+ result = http.GET('/api/hello', query: {'name'=>'World'})
45
+ # or http.get(...) if you like.
46
+
47
+ ## test result
48
+ r = result
49
+ assert_equal 200, r.status
50
+ assert_equal "application/json", r.headers['Content-Type']
51
+ assert_equal "application/json", r.content_type
52
+ assert_equal 15, r.content_length
53
+ assert_equal ({"status"=>"OK"}), r.body_json
54
+ assert_equal "{\"status\":\"OK\"}", r.body_text
55
+ assert_equal "{\"status\":\"OK\"}", r.body_binary # encoing: ASCII-8BIT
56
+ assert_equal nil, r.location
57
+
58
+ ## (experimental) confirm environ object (if you want)
59
+ #p http.last_env
60
+ ```
61
+
62
+ * You can call `http.get()`/`http.post()` instead of `http.GET()`/`http.POST()`
63
+ if you prefer.
64
+ * `http.last_env` is an experimental feature (may be dropped in the future).
65
+
66
+
67
+ ## More Examples
68
+
69
+ ```ruby
70
+ ## query string
71
+ r = http.GET('/api/hello', query: 'name=World')
72
+ r = http.GET('/api/hello', query: {'name'=>'World'})
73
+
74
+ ## form parameters
75
+ r = http.POST('/api/hello', form: 'name=World')
76
+ r = http.POST('/api/hello', form: {'name'=>'World'})
77
+
78
+ ## json
79
+ r = http.POST('/api/hello', json: {'name'=>'World'})
80
+
81
+ ## multipart
82
+ mp = {
83
+ "name1" => "value1",
84
+ "file1" => File.open("data/example1.jpg", 'rb'),
85
+ }
86
+ r = http.POST('/api/hello', multipart: mp)
87
+
88
+ ## multipart #2
89
+ boundary = "abcdefg1234567" # or nil
90
+ mp = Rack::TestApp::MultipartBuilder.new(boundary)
91
+ mp.add("name1", "value1")
92
+ mp.add("file1", File.read('data/example1.jpg'), "example1.jpg", "image/jpeg")
93
+ r = http.POST('/api/hello', multipart: mp)
94
+
95
+ ## input
96
+ r = http.POST('/api/hello', input: "x=1&y=2&z=3")
97
+
98
+ ## headers
99
+ r = http.GET('/api/hello', headers: {"X-Requested-With"=>"XMLHttpRequest"})
100
+
101
+ ## cookies
102
+ r = http.GET('/api/hello', cookies: "name1=value1")
103
+ r = http.GET('/api/hello', cookies: {"name1"=>"value1"})
104
+ r = http.GET('/api/hello', cookies: {"name1"=>{:name=>'name1', :value=>'value1'}})
105
+
106
+ ## cookies #2
107
+ r1 = http.POST('/api/login')
108
+ r2 = http.GET('/api/hello', cookies: r1.cookies)
109
+ http.with(cookies: r1.cookies, headers: {}) do |http_|
110
+ r3 = http_.GET('/api/hello')
111
+ end
112
+
113
+ ## env
114
+ r = http.GET('/api/hello', env: {"HTTPS"=>"on"})
115
+ ```
116
+
117
+
118
+ ## Copyright and License
119
+
120
+ $Copyright: copyright(c) 2015 kuwata-lab.com all rights reserved $
121
+
122
+ $License: MIT-LICENSE $
@@ -0,0 +1,49 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ end
9
+
10
+ task :default => :test
11
+
12
+
13
+ desc "show how to release"
14
+ task :help do
15
+ puts <<END
16
+ How to release:
17
+
18
+ $ git checkout dev
19
+ $ git diff
20
+ $ which ruby
21
+ $ rake test # for confirmation
22
+ $ git checkout -b rel-1.0 # or git checkout rel-1.0
23
+ $ rake edit rel=1.0.0
24
+ $ git diff
25
+ $ git commit -a -m "release preparation for 1.0.0"
26
+ $ rake build # for confirmation
27
+ $ rake install # for confirmation
28
+ $ rake release
29
+ $ git push -u --tags origin rel-1.0
30
+ END
31
+
32
+ end
33
+
34
+
35
+ desc "edit files (for release preparation)"
36
+ task :edit do
37
+ rel = ENV['rel'] or
38
+ raise "ERROR: 'rel' environment variable expected."
39
+ filenames = Dir[*%w[lib/**/*.rb test/**/*_test.rb]]
40
+ filenames.each do |fname|
41
+ File.open(fname, 'r+', encoding: 'utf-8') do |f|
42
+ content = f.read()
43
+ x = content.gsub!(/\$Release:.*?\$/, "$Release: #{rel} $")
44
+ f.rewind()
45
+ f.truncate(0)
46
+ f.write(content)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,487 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ ###
4
+ ### $Release: 1.0.0 $
5
+ ### $Copyright: copyright(c) 2015 kuwata-lab.com all rights reserved $
6
+ ### $License: MIT License $
7
+ ###
8
+
9
+
10
+ require 'json'
11
+ require 'uri'
12
+ require 'stringio'
13
+ require 'digest/sha1'
14
+
15
+ require 'rack'
16
+
17
+
18
+ module Rack
19
+
20
+
21
+ module TestApp
22
+
23
+ VERSION = '$Release: 1.0.0 $'.split()[1]
24
+
25
+
26
+ module Util
27
+
28
+ module_function
29
+
30
+ def percent_encode(str)
31
+ #; [!a96jo] encodes string into percent encoding format.
32
+ return URI.encode_www_form_component(str)
33
+ end
34
+
35
+ def percent_decode(str)
36
+ #; [!kl9sk] decodes percent encoded string.
37
+ return URI.decode_www_form_component(str)
38
+ end
39
+
40
+ def build_query_string(query) # :nodoc:
41
+ #; [!098ac] returns nil when argument is nil.
42
+ #; [!z9ds2] returns argument itself when it is a string.
43
+ #; [!m5yyh] returns query string when Hash or Array passed.
44
+ #; [!nksh3] raises ArgumentError when passed value except nil, string, hash or array.
45
+ case query
46
+ when nil ; return nil
47
+ when String ; return query
48
+ when Hash, Array
49
+ return query.collect {|k, v| "#{percent_encode(k.to_s)}=#{percent_encode(v.to_s)}" }.join('&')
50
+ else
51
+ raise ArgumentError.new("Hash or Array expected but got #{query.inspect}.")
52
+ end
53
+ end
54
+
55
+ COOKIE_KEYS = {
56
+ 'path' => :path,
57
+ 'domain' => :domain,
58
+ 'expires' => :expires,
59
+ 'max-age' => :max_age,
60
+ 'httponly' => :httponly,
61
+ 'secure' => :secure,
62
+ }
63
+
64
+ def parse_set_cookie(set_cookie_value)
65
+ #; [!hvvu4] parses 'Set-Cookie' header value and returns hash object.
66
+ keys = COOKIE_KEYS
67
+ d = {}
68
+ set_cookie_value.split(/;\s*/).each do |string|
69
+ #; [!h75uc] sets true when value is missing such as 'secure' or 'httponly' attribute.
70
+ k, v = string.strip().split('=', 2)
71
+ #
72
+ if d.empty?
73
+ d[:name] = k
74
+ d[:value] = v
75
+ elsif (sym = keys[k.downcase])
76
+ #; [!q1h29] sets true as value for Secure or HttpOnly attribute.
77
+ #; [!50iko] raises error when attribute value specified for Secure or HttpOnly attirbute.
78
+ if sym == :secure || sym == :httponly
79
+ v.nil? or
80
+ raise TypeError.new("#{k}=#{v}: unexpected attribute value.")
81
+ v = true
82
+ #; [!sucrm] raises error when attribute value is missing when neighter Secure nor HttpOnly.
83
+ else
84
+ ! v.nil? or
85
+ raise TypeError.new("#{k}: attribute value expected but not specified.")
86
+ #; [!f3rk7] converts string into integer for Max-Age attribute.
87
+ #; [!wgzyz] raises error when Max-Age attribute value is not a positive integer.
88
+ if sym == :max_age
89
+ v =~ /\A\d+\z/ or
90
+ raise TypeError.new("#{k}=#{v}: positive integer expected.")
91
+ v = v.to_i
92
+ end
93
+ end
94
+ d[sym] = v
95
+ #; [!8xg63] raises ArgumentError when unknown attribute exists.
96
+ else
97
+ raise TypeError.new("#{k}=#{v}: unknown cookie attribute.")
98
+ end
99
+ end
100
+ return d
101
+ end
102
+
103
+ def randstr_b64()
104
+ #; [!yq0gv] returns random string, encoded with urlsafe base64.
105
+ ## Don't use SecureRandom; entropy of /dev/random or /dev/urandom
106
+ ## should be left for more secure-sensitive purpose.
107
+ s = "#{rand()}#{rand()}#{rand()}#{Time.now.to_f}"
108
+ binary = ::Digest::SHA1.digest(s)
109
+ return [binary].pack('m').chomp("=\n").tr('+/', '-_')
110
+ end
111
+
112
+ def guess_content_type(filename, default='application/octet-stream')
113
+ #; [!xw0js] returns content type guessed from filename.
114
+ #; [!dku5c] returns 'application/octet-stream' when failed to guess content type.
115
+ ext = ::File.extname(filename)
116
+ return Rack::Mime.mime_type(ext, default)
117
+ end
118
+
119
+ end
120
+
121
+
122
+ class MultipartBuilder
123
+
124
+ def initialize(boundary=nil)
125
+ #; [!ajfgl] sets random string as boundary when boundary is nil.
126
+ @boundary = boundary || Util.randstr_b64()
127
+ @params = []
128
+ end
129
+
130
+ attr_reader :boundary
131
+
132
+ def add(name, value, filename=nil, content_type=nil)
133
+ #; [!tp4bk] detects content type from filename when filename is not nil.
134
+ content_type ||= Util.guess_content_type(filename) if filename
135
+ @params << [name, value, filename, content_type]
136
+ self
137
+ end
138
+
139
+ def add_file(name, file, content_type=nil)
140
+ #; [!uafqa] detects content type from filename when content type is not provided.
141
+ content_type ||= Util.guess_content_type(file.path)
142
+ #; [!b5811] reads file content and adds it as param value.
143
+ add(name, file.read(), ::File.basename(file.path), content_type)
144
+ #; [!36bsu] closes opened file automatically.
145
+ file.close()
146
+ self
147
+ end
148
+
149
+ def to_s
150
+ #; [!61gc4] returns multipart form string.
151
+ boundary = @boundary
152
+ s = "".force_encoding('ASCII-8BIT')
153
+ @params.each do |name, value, filename, content_type|
154
+ s << "--#{boundary}\r\n"
155
+ if filename
156
+ s << "Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n"
157
+ else
158
+ s << "Content-Disposition: form-data; name=\"#{name}\"\r\n"
159
+ end
160
+ s << "Content-Type: #{content_type}\r\n" if content_type
161
+ s << "\r\n"
162
+ s << value.force_encoding('ASCII-8BIT')
163
+ s << "\r\n"
164
+ end
165
+ s << "--#{boundary}--\r\n"
166
+ return s
167
+ end
168
+
169
+ end
170
+
171
+
172
+ ##
173
+ ## Builds environ hash object.
174
+ ##
175
+ ## ex:
176
+ ## json = {"x"=>1, "y"=>2}
177
+ ## env = Rack::TestApp.new_env(:POST, '/api/entry?x=1', json: json)
178
+ ## p env['REQUEST_METHOD'] #=> 'POST'
179
+ ## p env['PATH_INFO'] #=> '/api/entry'
180
+ ## p env['QUERY_STRING'] #=> 'x=1'
181
+ ## p env['CONTENT_TYPE'] #=> 'application/json'
182
+ ## p JSON.parse(env['rack.input'].read()) #=> {"x"=>1, "y"=>2}
183
+ ##
184
+ def self.new_env(meth=:GET, path="/", query: nil, form: nil, multipart: nil, json: nil, input: nil, headers: nil, cookies: nil, env: nil)
185
+ #uri = "http://localhost:80#{path}"
186
+ #opts["REQUEST_METHOD"] = meth
187
+ #env = Rack::MockRequest.env_for(uri, opts)
188
+ #
189
+ #; [!j879z] sets 'HTTPS' with 'on' when 'rack.url_scheme' is 'https'.
190
+ #; [!vpwvu] sets 'HTTPS' with 'on' when 'HTTPS' is 'on'.
191
+ https = env && (env['rack.url_scheme'] == 'https' || env['HTTPS'] == 'on')
192
+ #
193
+ err = proc {|a, b|
194
+ ArgumentError.new("new_env(): not allowed both '#{a}' and '#{b}' at a time.")
195
+ }
196
+ ctype = nil
197
+ #; [!2uvyb] raises ArgumentError when both query string and 'query' kwarg specified.
198
+ if query
199
+ arr = path.split('?', 2)
200
+ arr.length != 2 or
201
+ raise ArgumentError.new("new_env(): not allowed both query string and 'query' kwarg at a time.")
202
+ #; [!8tq3m] accepts query string in path string.
203
+ else
204
+ path, query = path.split('?', 2)
205
+ end
206
+ #; [!d1c83] when 'form' kwarg specified...
207
+ if form
208
+ #; [!c779l] raises ArgumentError when both 'form' and 'json' are specified.
209
+ ! json or raise err.call('form', 'json')
210
+ input = Util.build_query_string(form)
211
+ #; [!5iv35] sets content type with 'application/x-www-form-urlencoded'.
212
+ ctype = "application/x-www-form-urlencoded"
213
+ end
214
+ #; [!prv5z] when 'json' kwarg specified...
215
+ if json
216
+ #; [!2o0ph] raises ArgumentError when both 'json' and 'multipart' are specified.
217
+ ! multipart or raise err.call('json', 'multipart')
218
+ input = json.is_a?(String) ? json : JSON.dump(json)
219
+ #; [!ta24a] sets content type with 'application/json'.
220
+ ctype = "application/json"
221
+ end
222
+ #; [!dnvgj] when 'multipart' kwarg specified...
223
+ if multipart
224
+ #; [!b1d1t] raises ArgumentError when both 'multipart' and 'form' are specified.
225
+ ! form or raise err.call('multipart', 'form')
226
+ #; [!gko8g] 'multipart:' kwarg accepts Hash object (which is converted into multipart data).
227
+ if multipart.is_a?(Hash)
228
+ dict = multipart
229
+ multipart = dict.each_with_object(MultipartBuilder.new) do |(k, v), mp|
230
+ v.is_a?(::File) ? mp.add_file(k, v) : mp.add(k, v.to_s)
231
+ end
232
+ end
233
+ input = multipart.to_s
234
+ #; [!dq33d] sets content type with 'multipart/form-data'.
235
+ m = /\A--(\S+)\r\n/.match(input) or
236
+ raise ArgumentError.new("invalid multipart format.")
237
+ boundary = $1
238
+ ctype = "multipart/form-data; boundary=#{boundary}"
239
+ end
240
+ #; [!iamrk] uses 'application/x-www-form-urlencoded' as default content type of input.
241
+ if input && ! ctype
242
+ ctype ||= headers['Content-Type'] || headers['content-type'] if headers
243
+ ctype ||= env['CONTENT_TYPE'] if env
244
+ ctype ||= "application/x-www-form-urlencoded"
245
+ end
246
+ #; [!7hfri] converts input string into binary.
247
+ input ||= ""
248
+ input = input.encode('ascii-8bit') if input.encoding != Encoding::ASCII_8BIT
249
+ #; [!r3soc] converts query string into binary.
250
+ query_str = Util.build_query_string(query || "")
251
+ query_str = query_str.encode('ascii-8bit')
252
+ #; [!na9w6] builds environ hash object.
253
+ environ = {
254
+ "rack.version" => Rack::VERSION,
255
+ "rack.input" => StringIO.new(input),
256
+ "rack.errors" => StringIO.new,
257
+ "rack.multithread" => true,
258
+ "rack.multiprocess" => true,
259
+ "rack.run_once" => false,
260
+ "rack.url_scheme" => https ? "https" : "http",
261
+ "REQUEST_METHOD" => meth.to_s,
262
+ "SERVER_NAME" => "localhost",
263
+ "SERVER_PORT" => https ? "443" : "80",
264
+ "QUERY_STRING" => query_str,
265
+ "PATH_INFO" => path,
266
+ "HTTPS" => https ? "on" : "off",
267
+ "SCRIPT_NAME" => "",
268
+ "CONTENT_LENGTH" => (input ? input.bytesize.to_s : "0"),
269
+ "CONTENT_TYPE" => ctype,
270
+ }
271
+ #; [!ezvdn] unsets CONTENT_TYPE when not input.
272
+ environ.delete("CONTENT_TYPE") if input.empty?
273
+ #; [!r4jz8] copies 'headers' kwarg content into environ with 'HTTP_' prefix.
274
+ #; [!ai9t3] don't add 'HTTP_' to Content-Length and Content-Type headers.
275
+ excepts = ['CONTENT_LENGTH', 'CONTENT_TYPE']
276
+ headers.each do |name, value|
277
+ name =~ /\A[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*\z/ or
278
+ raise ArgumentError.new("invalid http header name: #{name.inspect}")
279
+ value.is_a?(String) or
280
+ raise ArgumentError.new("http header value should be a string but got: #{value.inspect}")
281
+ ## ex: 'X-Requested-With' -> 'HTTP_X_REQUESTED_WITH'
282
+ k = name.upcase.gsub(/-/, '_')
283
+ k = "HTTP_#{k}" unless excepts.include?(k)
284
+ environ[k] = value
285
+ end if headers
286
+ #; [!a47n9] copies 'env' kwarg content into environ.
287
+ env.each do |name, value|
288
+ case name
289
+ when /\Arack\./
290
+ # ok
291
+ when /\A[A-Z]+(_[A-Z0-9]+)*\z/
292
+ value.is_a?(String) or
293
+ raise ArgumentError.new("rack env value should be a string but got: #{value.inspect}")
294
+ else
295
+ raise ArgumentError.new("invalid rack env key: #{name}")
296
+ end
297
+ environ[name] = value
298
+ end if env
299
+ #; [!pmefk] sets 'HTTP_COOKIE' when 'cookie' kwarg specified.
300
+ if cookies
301
+ s = cookies.is_a?(Hash) ? cookies.map {|k, v|
302
+ #; [!qj7b8] cookie value can be {:name=>'name', :value=>'value'}.
303
+ v = v[:value] if v.is_a?(Hash) && v[:value]
304
+ "#{Util.percent_encode(k)}=#{Util.percent_encode(v)}"
305
+ }.join('; ') : cookies.to_s
306
+ s = "#{environ['HTTP_COOKIE']}; #{s}" if environ['HTTP_COOKIE']
307
+ environ['HTTP_COOKIE'] = s
308
+ end
309
+ #; [!b3ts8] returns environ hash object.
310
+ return environ
311
+ end
312
+
313
+
314
+ class Result
315
+
316
+ def initialize(status, headers, body)
317
+ #; [!3lcsj] accepts response status, headers and body.
318
+ @status = status
319
+ @headers = headers
320
+ @body = body
321
+ #; [!n086q] parses 'Set-Cookie' header.
322
+ @cookies = {}
323
+ raw_str = @headers['Set-Cookie'] || @headers['set-cookie']
324
+ raw_str.split(/\r?\n/).each do |s|
325
+ if s && ! s.empty?
326
+ c = Util.parse_set_cookie(s)
327
+ @cookies[c[:name]] = c
328
+ end
329
+ end if raw_str
330
+ end
331
+
332
+ attr_accessor :status, :headers, :body, :cookies
333
+
334
+ def body_binary
335
+ #; [!mb0i4] returns body as binary string.
336
+ buf = []; @body.each {|x| buf << x }
337
+ s = buf.join()
338
+ @body.close() if @body.respond_to?(:close)
339
+ return s
340
+ end
341
+
342
+ def body_text
343
+ #; [!rr18d] error when 'Content-Type' header is missing.
344
+ ctype = self.content_type or
345
+ raise TypeError.new("body_text(): missing 'Content-Type' header.")
346
+ #; [!dou1n] converts body text according to 'charset' in 'Content-Type' header.
347
+ if ctype =~ /; *charset=(\w[-\w]*)/
348
+ charset = $1
349
+ #; [!cxje7] assumes charset as 'utf-8' when 'Content-Type' is json.
350
+ elsif ctype == "application/json"
351
+ charset = 'utf-8'
352
+ #; [!n4c71] error when non-json 'Content-Type' header has no 'charset'.
353
+ else
354
+ raise TypeError.new("body_text(): missing 'charset' in 'Content-Type' header.")
355
+ end
356
+ #; [!vkj9h] returns body as text string, according to 'charset' in 'Content-Type'.
357
+ return body_binary().force_encoding(charset)
358
+ end
359
+
360
+ def body_json
361
+ #; [!qnic1] returns Hash object representing JSON string.
362
+ return JSON.parse(body_text())
363
+ end
364
+
365
+ def content_type
366
+ #; [!40hcz] returns 'Content-Type' header value.
367
+ return @headers['Content-Type'] || @headers['content-type']
368
+ end
369
+
370
+ def content_length
371
+ #; [!5lb19] returns 'Content-Length' header value as integer.
372
+ #; [!qjktz] returns nil when 'Content-Length' is not set.
373
+ len = @headers['Content-Length'] || @headers['content-length']
374
+ return len ? Integer(len) : len
375
+ end
376
+
377
+ def location
378
+ #; [!8y8lg] returns 'Location' header value.
379
+ return @headers['Location'] || @headers['location']
380
+ end
381
+
382
+ def cookie_value(name)
383
+ #; [!neaf8] returns cookie value if exists.
384
+ #; [!oapns] returns nil if cookie not exists.
385
+ c = @cookies[name]
386
+ return c ? c[:value] : nil
387
+ end
388
+
389
+ end
390
+
391
+
392
+ ##
393
+ ## Wrapper class to test Rack application.
394
+ ## Use 'Rack::TestApp.wrap(app)' instead of 'Rack::TestApp::Wrapper.new(app)'.
395
+ ##
396
+ ## ex:
397
+ ## require 'rack/lint'
398
+ ## require 'rack/test_app'
399
+ ## http = Rack::TestApp.wrap(Rack::Lint.new(app))
400
+ ## https = Rack::TestApp.wrap(Rack::Lint.new(app)), env: {'HTTPS'=>'on'})
401
+ ## resp = http.GET('/api/hello', query: {'name'=>'World'})
402
+ ## assert_equal 200, resp.status
403
+ ## assert_equal "application/json", resp.headers['Content-Type']
404
+ ## assert_equal {"message"=>"Hello World!"}, resp.body_json
405
+ ##
406
+ class Wrapper
407
+
408
+ def initialize(app, env=nil)
409
+ #; [!zz9yg] takes app and optional env objects.
410
+ @app = app
411
+ @env = env
412
+ @last_env = nil
413
+ end
414
+
415
+ attr_reader :last_env
416
+
417
+ ##
418
+ ## helper method to create new wrapper object keeping cookies and headers.
419
+ ##
420
+ ## ex:
421
+ ## http = Rack::TestApp.wrap(Rack::Lint.new(app))
422
+ ## r1 = http.POST('/api/login', form: {user: 'user', password: 'pass'})
423
+ ## http.with(cookies: r1.cookies, headers: {}) do |http_|
424
+ ## r2 = http_.GET('/api/content') # request with r1.cookies
425
+ ## assert_equal 200, r2.status
426
+ ## end
427
+ ##
428
+ def with(headers: nil, cookies: nil, env: nil)
429
+ tmp_env = TestApp.new_env(headers: headers, cookies: cookies, env: env)
430
+ new_env = @env ? @env.dup : {}
431
+ http_headers = tmp_env.each do |k, v|
432
+ new_env[k] = v if k.start_with?('HTTP_')
433
+ end
434
+ new_wrapper = self.class.new(@app, new_env)
435
+ #; [!mkdbu] yields with new wrapper object if block given.
436
+ yield new_wrapper if block_given?
437
+ #; [!0bk12] returns new wrapper object, keeping cookies and headers.
438
+ new_wrapper
439
+ end
440
+
441
+ def request(meth, path, query: nil, form: nil, multipart: nil, json: nil, input: nil, headers: nil, cookies: nil, env: nil)
442
+ #; [!r6sod] merges @env if passed for initializer.
443
+ env = env ? env.merge(@env) : @env if @env
444
+ #; [!4xpwa] creates env object and calls app with it.
445
+ environ = TestApp.new_env(meth, path,
446
+ query: query, form: form, multipart: multipart, json: json,
447
+ input: input, headers: headers, cookies: cookies, env: env)
448
+ @last_env = environ
449
+ tuple = @app.call(environ)
450
+ status, headers, body = tuple
451
+ #; [!eb153] returns Rack::TestApp::Result object.
452
+ return Result.new(status, headers, body)
453
+ end
454
+
455
+ def GET path, kwargs={}; request(:GET , path, kwargs); end
456
+ def POST path, kwargs={}; request(:POST , path, kwargs); end
457
+ def PUT path, kwargs={}; request(:PUT , path, kwargs); end
458
+ def DELETE path, kwargs={}; request(:DELETE , path, kwargs); end
459
+ def HEAD path, kwargs={}; request(:HEAD , path, kwargs); end
460
+ def PATCH path, kwargs={}; request(:PATCH , path, kwargs); end
461
+ def OPTIONS path, kwargs={}; request(:OPTIONS, path, kwargs); end
462
+ def TRACE path, kwargs={}; request(:TRACE , path, kwargs); end
463
+
464
+ ## define aliases because ruby programmer prefers #get() rather than #GET().
465
+ alias get GET
466
+ alias post POST
467
+ alias put PUT
468
+ alias delete DELETE
469
+ alias head HEAD
470
+ alias patch PATCH
471
+ alias options OPTIONS
472
+ alias trace TRACE
473
+
474
+ end
475
+
476
+
477
+ ## Use Rack::TestApp.wrap(app) instead of Rack::TestApp::Wrapper.new(app).
478
+ def self.wrap(app, env=nil)
479
+ #; [!grqlf] creates new Wrapper object.
480
+ return Wrapper.new(app, env)
481
+ end
482
+
483
+
484
+ end
485
+
486
+
487
+ end