cacho 0.0.3 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 383e74d0ac66a216ed011020530efbc4570cce1e
4
+ data.tar.gz: 84c5210831578a5f131a66535fce4d53b709e203
5
+ SHA512:
6
+ metadata.gz: 9a447d7cf5f0dfeefa4e011409f54cfcf5b8f4d266855903002e578fde6d50165895f443da1be3acb280f4b3e4fc220d957e861f5c2336665e0b66bd40cb7bba
7
+ data.tar.gz: 3b6a1da1d8299e79bc0aa2626548ca12ab847471afe914a58e2936f1fe9add41d4a8851327cf4a12c79e2bbedb44bc36566d472946570a3a18de407c469e1c37
@@ -0,0 +1 @@
1
+ /pkg
@@ -0,0 +1,40 @@
1
+ Cacho
2
+ =====
3
+
4
+ A careless caching client optimized for scraping.
5
+
6
+ Description
7
+ -----------
8
+
9
+ Cacho is an HTTP client for scraping. It will do most of the things you want
10
+ when scraping:
11
+
12
+ * Follow redirects.
13
+ * Set the `User-Agent` to a browser-like string.
14
+ * Accept and process gzip encoding.
15
+ * Detect when it's been rate limited and wait.
16
+ * Retry on silly network errors.
17
+ * Use persistent HTTP connections.
18
+
19
+ Most importantly, Cacho will store responses on disk so that multiple runs of
20
+ your script will not hit the endpoints you are scraping. This prevents being
21
+ rate limited and also makes your script faster every time.
22
+
23
+ Usage
24
+ -----
25
+
26
+ require "cacho"
27
+
28
+ client = Cacho.new
29
+
30
+ res = client.request(:get, "https://news.ycombinator.com")
31
+
32
+ Installation
33
+ ------------
34
+
35
+ $ gem install cacho
36
+
37
+ License
38
+ -------
39
+
40
+ See the `UNLICENSE`.
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <http://unlicense.org/>
@@ -1,14 +1,15 @@
1
+ require_relative "lib/cacho"
2
+
1
3
  Gem::Specification.new do |s|
2
4
  s.name = "cacho"
3
- s.version = "0.0.3"
4
- s.summary = "Cache aware, Redis based HTTP client."
5
- s.description = "HTTP client that understands cache responses and stores results in Redis."
6
- s.authors = ["Damian Janowski", "Michel Martens"]
7
- s.email = ["djanowski@dimaion.com", "michel@soveran.com"]
8
- s.homepage = "http://github.com/djanowski/cacho"
5
+ s.version = Cacho::VERSION
6
+ s.summary = "A careless caching client optimized for scraping."
7
+ s.description = "A careless caching client optimized for scraping."
8
+ s.authors = ["Damian Janowski", "Martín Sarsale"]
9
+ s.email = ["djanowski@dimaion.com", "martin.sarsale@gmail.com"]
10
+ s.homepage = "https://github.com/djanowski/cacho"
9
11
 
10
- s.files = ["LICENSE", "Rakefile", "lib/cacho.rb", "cacho.gemspec", "test/cacho.rb"]
12
+ s.files = `git ls-files`.lines.to_a.map(&:chomp)
11
13
 
12
- s.add_dependency "nest", "~> 1.0"
13
- s.add_dependency "curb", "~> 0.7"
14
+ s.add_dependency "net-http-persistent"
14
15
  end
@@ -0,0 +1,5 @@
1
+ require_relative "../lib/cacho"
2
+
3
+ client = Cacho.new
4
+
5
+ puts client.request(:get, "https://news.ycombinator.com")
@@ -1,136 +1,203 @@
1
- # encoding: UTF-8
2
-
3
- require "json" unless defined?(JSON)
4
- require "curb"
5
- require "nest"
1
+ require "net/http/persistent"
2
+ require "uri"
3
+ require "zlib"
4
+ require "json"
5
+ require "stringio"
6
+ require "csv"
7
+ require "fileutils"
6
8
 
7
9
  class Cacho
8
- VERSION = "0.0.3"
10
+ VERSION = "0.1.0"
11
+
12
+ attr_accessor :hasher
9
13
 
10
- def self.get(url, request_headers = {})
11
- _request(:get, url, request_headers)
14
+ def initialize(*args)
15
+ @client = Client.new(*args)
16
+ @db = DB.new("~/.cacho/cache")
17
+ @hasher = -> *args { args }
12
18
  end
13
19
 
14
- def self.head(url, request_headers = {})
15
- _request(:head, url, request_headers)
20
+ def request(verb, *args)
21
+ if verb == :get
22
+ uri = @client.uri(*args)
23
+
24
+ @db.get(verb, uri) do
25
+ @client.request(verb, *args)
26
+ end
27
+ else
28
+ @client.request(verb, *args)
29
+ end
16
30
  end
31
+ end
32
+
33
+ class Cacho::Client
34
+ Error = Class.new(StandardError)
35
+ NotFound = Class.new(Error)
17
36
 
18
- def self.options(url, request_headers = {})
19
- _request(:options, url, request_headers)
37
+ def initialize(callbacks = {})
38
+ @http = Net::HTTP::Persistent.new
39
+ @callbacks = callbacks
40
+ @callbacks[:configure_http].(@http) if @callbacks[:configure_http]
20
41
  end
21
42
 
22
- def self._request(verb, url, request_headers = {})
23
- local = Local[verb, url]
43
+ def uri(url, options = {})
44
+ query = options.fetch(:query, {}).dup
24
45
 
25
- unless local.fresh?
26
- remote = Remote.request(verb, url, local.build_headers.merge(request_headers))
46
+ @callbacks[:process_query].(query) if @callbacks[:process_query]
27
47
 
28
- local.set(remote) unless remote.first == 304
29
- end
48
+ uri = URI(url)
30
49
 
31
- local.response
50
+ uri.query = URI.encode_www_form(query) if query.size > 0
51
+
52
+ uri
32
53
  end
33
54
 
34
- class Local
35
- attr :etag
36
- attr :last_modified
37
- attr :expire
38
- attr :response
55
+ def request(verb, url, options = {})
56
+ uri = self.uri(url, options)
39
57
 
40
- def initialize(key)
41
- @key = key
42
- @etag, @last_modified, @expire, @response = @key.hmget(:etag, :last_modified, :expire, :response)
43
- @response = JSON.parse(@response) if @response
44
- end
58
+ loop do
59
+ request = Net::HTTP.const_get(verb.capitalize).new(uri.request_uri)
45
60
 
46
- def self.[](verb, url)
47
- new(Nest.new(verb)[url])
48
- end
61
+ @callbacks[:before_request].(request) if @callbacks[:before_request]
49
62
 
50
- def expire_in(ttl)
51
- @expire = (Time.now + ttl).to_i
52
- end
63
+ if options.include?(:headers)
64
+ options[:headers].each do |key, value|
65
+ request[key] = value
66
+ end
67
+ end
68
+
69
+ request["Accept-Encoding"] = "gzip"
70
+
71
+ if verb == :post
72
+ post_data = options.fetch(:data)
53
73
 
54
- def build_headers
55
- {}.tap do |headers|
56
- headers["If-None-Match"] = etag if etag
57
- headers["If-Modified-Since"] = last_modified if last_modified
74
+ case options[:content_type]
75
+ when :json
76
+ request["Content-Type"] = "application/json; charset=utf-8"
77
+ request.body = post_data.to_json
78
+ else
79
+ request.body = URI.encode_www_form(post_data)
80
+ end
58
81
  end
59
- end
60
82
 
61
- def set(response)
62
- @response = response
83
+ if options[:content_encoding] == :deflate
84
+ request["Content-Encoding"] = "deflate"
63
85
 
64
- return unless cacheable?
86
+ request.body = Zlib::Deflate.deflate(request.body)
87
+ end
65
88
 
66
- _, headers, _ = response
89
+ $stderr.puts("-> #{verb.upcase} #{uri}")
67
90
 
68
- if headers["Cache-Control"]
69
- ttl = headers["Cache-Control"][/max\-age=(\d+)/, 1].to_i
70
- expire_in(ttl)
91
+ if verb_idempotent?(verb)
92
+ res = protect { @http.request(uri, request) }
93
+ else
94
+ res = @http.request(uri, request)
71
95
  end
72
96
 
73
- @etag = headers["ETag"]
74
- @last_modified = headers["Last-Modified"]
97
+ body = res.body
75
98
 
76
- store
77
- end
99
+ if res["Content-Encoding"] == "gzip"
100
+ body = Zlib::GzipReader.new(StringIO.new(body)).read
101
+ end
78
102
 
79
- def fresh?
80
- expire && expire.to_i >= Time.now.to_i
103
+ if res["Content-Type"].start_with?("application/json")
104
+ parsed = JSON.parse(body)
105
+ else
106
+ parsed = body
107
+ end
108
+
109
+ if @callbacks[:rate_limit_detector]
110
+ if seconds = @callbacks[:rate_limit_detector].(res, body, parsed)
111
+ $stderr.puts("Rate limited for #{seconds} seconds.")
112
+ sleep(seconds)
113
+ next
114
+ end
115
+ end
116
+
117
+ case res.code
118
+ when "200"
119
+ return parsed
120
+ when "303" , "302" , "301"
121
+ if res["Location"].to_s.index(/^https?:\/\//)
122
+ redirect_url = URI.escape(res["Location"])
123
+ else
124
+ redirect_url = @base_url, res["Location"]
125
+ end
126
+ $stderr.write(" redirected to #{redirect_url}\n")
127
+ uri = URI.join(redirect_url)
128
+ when "404"
129
+ return nil
130
+ else
131
+ raise "Got #{res.code}: #{body.inspect}"
132
+ end
81
133
  end
134
+ end
82
135
 
83
- protected
136
+ def verb_idempotent?(verb)
137
+ verb == :get || verb == :head || verb == :options
138
+ end
84
139
 
85
- def cacheable?
86
- status, headers, _ = response
140
+ def protect(options = {})
141
+ throttle = options.fetch(:throttle, 1)
142
+ maximum_retries = options.fetch(:retries, nil)
143
+ retries = 0
87
144
 
88
- status == 200 && (headers["Cache-Control"] || headers["ETag"] || headers["Last-Modified"])
89
- end
145
+ begin
146
+ result = yield
90
147
 
91
- def store
92
- fields = {response: response.to_json}
148
+ retries = 0
93
149
 
94
- fields[:etag] = etag if etag
95
- fields[:last_modified] = last_modified if last_modified
96
- fields[:expire] = expire if expire
150
+ return result
151
+ rescue SocketError, \
152
+ EOFError, \
153
+ Errno::ECONNREFUSED, \
154
+ Errno::ECONNRESET, \
155
+ Errno::EHOSTUNREACH, \
156
+ Errno::ENETUNREACH, \
157
+ Net::HTTP::Persistent::Error, \
158
+ Errno::ETIMEDOUT
97
159
 
98
- @key.redis.multi do
99
- @key.del
100
- @key.hmset(*fields.to_a.flatten)
101
- end
160
+ retries += 1
161
+
162
+ $stderr.puts("-> #{$!.class}: #{$!.message}")
163
+
164
+ sleep([retries ** 2 * throttle, 300].min)
165
+
166
+ retry if maximum_retries.nil? || retries < maximum_retries
102
167
  end
103
168
  end
169
+ end
104
170
 
105
- class Remote
106
- def self.request(verb, url, request_headers)
107
- status = nil
108
- headers = {}
109
- body = ""
171
+ class Cacho::DB
172
+ attr :path
110
173
 
111
- curl = Curl::Easy.new(url)
174
+ def initialize(path)
175
+ @path = File.expand_path(path)
112
176
 
113
- curl.headers = request_headers
177
+ FileUtils.mkdir_p(@path)
178
+ end
114
179
 
115
- curl.on_header do |header|
116
- headers.store(*header.rstrip.split(": ", 2)) if header.include?(":")
117
- header.bytesize
118
- end
180
+ def get(verb, uri)
181
+ parts = [
182
+ "#{uri.host}-#{uri.port}",
183
+ verb.to_s,
184
+ Digest::MD5.hexdigest(uri.to_s)
185
+ ]
119
186
 
120
- curl.on_body do |string|
121
- body << string.force_encoding(Encoding::UTF_8)
122
- string.bytesize
123
- end
187
+ doc_path = File.join(@path, *parts)
124
188
 
125
- curl.on_complete do |response|
126
- status = response.response_code
127
- end
189
+ FileUtils.mkdir_p(File.dirname(doc_path)) if doc_path.start_with?(@path)
190
+
191
+ if File.exist?(doc_path)
192
+ str = File.read(doc_path)
128
193
 
129
- curl.head = verb == :head
194
+ return Marshal.load(str)
195
+ else
196
+ value = yield
130
197
 
131
- curl.http(verb.to_s.upcase)
198
+ File.write(doc_path, Marshal.dump(value))
132
199
 
133
- [status, headers, body]
200
+ return value
134
201
  end
135
202
  end
136
203
  end
metadata CHANGED
@@ -1,98 +1,66 @@
1
- --- !ruby/object:Gem::Specification
1
+ --- !ruby/object:Gem::Specification
2
2
  name: cacho
3
- version: !ruby/object:Gem::Version
4
- prerelease: false
5
- segments:
6
- - 0
7
- - 0
8
- - 3
9
- version: 0.0.3
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
10
5
  platform: ruby
11
- authors:
6
+ authors:
12
7
  - Damian Janowski
13
- - Michel Martens
8
+ - Martín Sarsale
14
9
  autorequire:
15
10
  bindir: bin
16
11
  cert_chain: []
17
-
18
- date: 2010-11-26 00:00:00 -03:00
19
- default_executable:
20
- dependencies:
21
- - !ruby/object:Gem::Dependency
22
- name: nest
23
- prerelease: false
24
- requirement: &id001 !ruby/object:Gem::Requirement
25
- none: false
26
- requirements:
27
- - - ~>
28
- - !ruby/object:Gem::Version
29
- segments:
30
- - 1
31
- - 0
32
- version: "1.0"
12
+ date: 2014-10-30 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: net-http-persistent
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '0'
33
21
  type: :runtime
34
- version_requirements: *id001
35
- - !ruby/object:Gem::Dependency
36
- name: curb
37
22
  prerelease: false
38
- requirement: &id002 !ruby/object:Gem::Requirement
39
- none: false
40
- requirements:
41
- - - ~>
42
- - !ruby/object:Gem::Version
43
- segments:
44
- - 0
45
- - 7
46
- version: "0.7"
47
- type: :runtime
48
- version_requirements: *id002
49
- description: HTTP client that understands cache responses and stores results in Redis.
50
- email:
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '0'
28
+ description: A careless caching client optimized for scraping.
29
+ email:
51
30
  - djanowski@dimaion.com
52
- - michel@soveran.com
31
+ - martin.sarsale@gmail.com
53
32
  executables: []
54
-
55
33
  extensions: []
56
-
57
34
  extra_rdoc_files: []
58
-
59
- files:
60
- - LICENSE
35
+ files:
36
+ - ".gitignore"
37
+ - README.md
61
38
  - Rakefile
62
- - lib/cacho.rb
39
+ - UNLICENSE
63
40
  - cacho.gemspec
64
- - test/cacho.rb
65
- has_rdoc: true
66
- homepage: http://github.com/djanowski/cacho
41
+ - examples/simple.rb
42
+ - lib/cacho.rb
43
+ homepage: https://github.com/djanowski/cacho
67
44
  licenses: []
68
-
45
+ metadata: {}
69
46
  post_install_message:
70
47
  rdoc_options: []
71
-
72
- require_paths:
48
+ require_paths:
73
49
  - lib
74
- required_ruby_version: !ruby/object:Gem::Requirement
75
- none: false
76
- requirements:
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
77
52
  - - ">="
78
- - !ruby/object:Gem::Version
79
- segments:
80
- - 0
81
- version: "0"
82
- required_rubygems_version: !ruby/object:Gem::Requirement
83
- none: false
84
- requirements:
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
85
57
  - - ">="
86
- - !ruby/object:Gem::Version
87
- segments:
88
- - 0
89
- version: "0"
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
90
60
  requirements: []
91
-
92
61
  rubyforge_project:
93
- rubygems_version: 1.3.7
62
+ rubygems_version: 2.2.2
94
63
  signing_key:
95
- specification_version: 3
96
- summary: Cache aware, Redis based HTTP client.
64
+ specification_version: 4
65
+ summary: A careless caching client optimized for scraping.
97
66
  test_files: []
98
-
data/LICENSE DELETED
@@ -1,19 +0,0 @@
1
- Copyright (c) 2010 Damian Janowski & Michel Martens
2
-
3
- Permission is hereby granted, free of charge, to any person obtaining a copy
4
- of this software and associated documentation files (the "Software"), to deal
5
- in the Software without restriction, including without limitation the rights
6
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
- copies of the Software, and to permit persons to whom the Software is
8
- furnished to do so, subject to the following conditions:
9
-
10
- The above copyright notice and this permission notice shall be included in
11
- all copies or substantial portions of the Software.
12
-
13
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
- THE SOFTWARE.
@@ -1,152 +0,0 @@
1
- # encoding: UTF-8
2
-
3
- require "cutest"
4
- require "socket"
5
- require "mock_server"
6
-
7
- require File.expand_path("../lib/cacho", File.dirname(__FILE__))
8
-
9
- include MockServer::Methods
10
-
11
- mock_server do
12
- %w(GET OPTIONS).each do |method|
13
- route method, "/cacheable" do
14
- response.headers["Cache-Control"] = "public, max-age=2"
15
- response.headers["Content-Type"] = "text/plain"
16
- Time.now.httpdate
17
- end
18
- end
19
-
20
- get "/non-cacheable" do
21
- response.headers["Content-Type"] = "text/plain"
22
- Time.now.httpdate
23
- end
24
-
25
- get "/etag" do
26
- if request.env["HTTP_IF_MODIFIED_SINCE"]
27
- halt 304
28
- else
29
- time = Time.now
30
-
31
- response.headers["ETag"] = time.hash.to_s
32
- response.headers["Last-Modified"] = time.httpdate
33
- response.headers["Content-Type"] = "text/plain"
34
-
35
- time.httpdate
36
- end
37
- end
38
-
39
- get "/changing-etag" do
40
- if request.env["HTTP_IF_MODIFIED_SINCE"]
41
- time = Time.parse(request.env["HTTP_IF_MODIFIED_SINCE"]) + 1
42
- else
43
- time = Time.now
44
-
45
- response.headers["ETag"] = time.hash.to_s
46
- response.headers["Last-Modified"] = time.httpdate
47
- response.headers["Content-Type"] = "text/plain"
48
- end
49
-
50
- time.httpdate
51
- end
52
-
53
- get "/utf" do
54
- "Aló"
55
- end
56
-
57
- def route_missing
58
- request.env.map do |name, value|
59
- "#{name}: #{value}"
60
- end.join("\n")
61
- end
62
- end
63
-
64
- prepare do
65
- Redis.current.flushdb
66
- end
67
-
68
- test "handles GET" do
69
- _, _, body = Cacho.get("http://localhost:4000")
70
-
71
- assert body["REQUEST_METHOD: GET"]
72
- end
73
-
74
- test "handles OPTIONS" do
75
- _, _, body = Cacho.options("http://localhost:4000")
76
-
77
- assert body["REQUEST_METHOD: OPTIONS"]
78
- end
79
-
80
- test "handles HEAD" do
81
- status, headers, body = Cacho.head("http://localhost:4000")
82
-
83
- assert status == 200
84
- assert headers["Content-Type"]
85
- assert body.empty?
86
- end
87
-
88
- test "caches cacheable responses" do
89
- status, headers, body = Cacho.get("http://localhost:4000/cacheable")
90
-
91
- assert_equal status, 200
92
- assert_equal headers["Content-Type"], "text/plain"
93
-
94
- t1 = body
95
-
96
- sleep 1
97
-
98
- status, headers, body = Cacho.get("http://localhost:4000/cacheable")
99
-
100
- assert_equal t1, body
101
-
102
- sleep 2
103
-
104
- status, headers, body = Cacho.get("http://localhost:4000/cacheable")
105
-
106
- assert body > t1
107
- end
108
-
109
- test "varies cache by HTTP method" do
110
- status1, _, body1 = Cacho.get("http://localhost:4000/cacheable")
111
- sleep 1
112
- status2, _, body2 = Cacho.options("http://localhost:4000/cacheable")
113
-
114
- assert status1 == 200
115
- assert status2 == 200
116
-
117
- assert body2 > body1
118
- end
119
-
120
- test "does not cache non-cacheable responses" do
121
- _, _, t1 = Cacho.get("http://localhost:4000/non-cacheable")
122
- sleep 1
123
- _, _, t2 = Cacho.get("http://localhost:4000/non-cacheable")
124
-
125
- assert t2 > t1
126
- end
127
-
128
- test "performs conditional GETs" do
129
- _, _, t1 = Cacho.get("http://localhost:4000/etag")
130
- sleep 1
131
- _, _, t2 = Cacho.get("http://localhost:4000/etag")
132
-
133
- assert_equal t1, t2
134
-
135
- _, _, t1 = Cacho.get("http://localhost:4000/changing-etag")
136
- _, _, t2 = Cacho.get("http://localhost:4000/changing-etag")
137
-
138
- assert t2 > t1
139
- end
140
-
141
- test "allows to pass custom HTTP headers" do
142
- _, _, body = Cacho.get("http://localhost:4000", "Accept" => "text/plain")
143
-
144
- assert body["HTTP_ACCEPT: text/plain"]
145
- end
146
-
147
- test "accepts UTF-encoded bodies" do
148
- _, _, body = Cacho.get("http://localhost:4000/utf")
149
-
150
- assert body.include?("Aló")
151
- assert body.encoding == Encoding::UTF_8
152
- end