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.
- checksums.yaml +7 -0
- data/MIT-LICENSE.txt +21 -0
- data/README.md +122 -0
- data/Rakefile +49 -0
- data/lib/rack/test_app.rb +487 -0
- data/rack-test_app.gemspec +31 -0
- data/test/rack/test_app/MultipartBuilder_test.rb +158 -0
- data/test/rack/test_app/Result_test.rb +184 -0
- data/test/rack/test_app/TestApp_test.rb +238 -0
- data/test/rack/test_app/Util_test.rb +168 -0
- data/test/rack/test_app/Wrapper_test.rb +182 -0
- data/test/rack/test_app/data/example1.jpg +0 -0
- data/test/rack/test_app/data/example1.png +0 -0
- data/test/rack/test_app/data/multipart.form +0 -0
- data/test/test_helper.rb +4 -0
- metadata +116 -0
checksums.yaml
ADDED
@@ -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
|
data/MIT-LICENSE.txt
ADDED
@@ -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.
|
data/README.md
ADDED
@@ -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 $
|
data/Rakefile
ADDED
@@ -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
|