http 5.1.0 → 5.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3826a20981b5ed6a7e5f29fd99af814a8409dea3d556d8d4f1c1faeee0060211
4
- data.tar.gz: 5f1a624d39059a53df7535df9d8094ec2eb17330ce113ee5f8201f028cb08a01
3
+ metadata.gz: 8593353cd2283b2ac49c557ff71d5f1a668066dbac59b3d2a6f59429b9b2f5e8
4
+ data.tar.gz: e9316b3c4dd519825eeaa255a4f86932ce4a9e7f374f9be8273ca4051f5b9c3d
5
5
  SHA512:
6
- metadata.gz: f34965016796ff2864a48d86c45a95db18a5bd118921e9f5ab653b042d53f13332d155238b1b26d28be643b6dffea6f0bf9ac20bbc71bcab05e73e7358ca9b7d
7
- data.tar.gz: dfcf6ad46848750d86cb9139b7b0ab9d4ae28624bc7c0d779450f3cfbd8de11dd6ba242855fee759210ffade294743746059157cd1eab4eb69125f644653f5e5
6
+ metadata.gz: 0ae34b411a233ca8601aebe2295f2a1d8e0bec7e742a95fdd6e075b4f7dd3c68d42448cfbac1146b645ee59424e91af8eb3878c43e7a7fb17c3569681cf4ccf3
7
+ data.tar.gz: a76bc0a41338e67677370014d803ea79e3a34078172cc81491f31b085613394c7290b2402e199a2e1de77c7fecf7e05fde8fc2d9924016105ce429751f202e85
@@ -2,9 +2,9 @@ name: CI
2
2
 
3
3
  on:
4
4
  push:
5
- branches: [ main ]
5
+ branches: [ main, 5-x-stable ]
6
6
  pull_request:
7
- branches: [ main ]
7
+ branches: [ main, 5-x-stable ]
8
8
 
9
9
  env:
10
10
  BUNDLE_WITHOUT: "development"
@@ -16,11 +16,11 @@ jobs:
16
16
 
17
17
  strategy:
18
18
  matrix:
19
- ruby: [ ruby-2.6, ruby-2.7, ruby-3.0, ruby-3.1 ]
19
+ ruby: [ ruby-2.6, ruby-2.7, ruby-3.0, ruby-3.1, ruby-3.2, ruby-3.3 ]
20
20
  os: [ ubuntu-latest ]
21
21
 
22
22
  steps:
23
- - uses: actions/checkout@v3
23
+ - uses: actions/checkout@v4
24
24
 
25
25
  - uses: ruby/setup-ruby@v1
26
26
  with:
@@ -30,29 +30,31 @@ jobs:
30
30
  - name: bundle exec rspec
31
31
  run: bundle exec rspec --format progress --force-colour
32
32
 
33
- - name: Prepare Coveralls test coverage report
34
- uses: coverallsapp/github-action@v1.1.2
35
- with:
36
- github-token: ${{ secrets.GITHUB_TOKEN }}
37
- flag-name: "${{ matrix.ruby }} @${{ matrix.os }}"
38
- path-to-lcov: ./coverage/lcov/lcov.info
39
- parallel: true
33
+ test-flaky:
34
+ runs-on: ${{ matrix.os }}
35
+
36
+ strategy:
37
+ matrix:
38
+ ruby: [ jruby-9.3 ]
39
+ os: [ ubuntu-latest ]
40
40
 
41
- coveralls:
42
- needs: test
43
- runs-on: ubuntu-latest
44
41
  steps:
45
- - name: Finalize Coveralls test coverage report
46
- uses: coverallsapp/github-action@master
42
+ - uses: actions/checkout@v4
43
+
44
+ - uses: ruby/setup-ruby@v1
47
45
  with:
48
- github-token: ${{ secrets.GITHUB_TOKEN }}
49
- parallel-finished: true
46
+ ruby-version: ${{ matrix.ruby }}
47
+ bundler-cache: true
48
+
49
+ - name: bundle exec rspec
50
+ continue-on-error: true
51
+ run: bundle exec rspec --format progress --force-colour
50
52
 
51
53
  lint:
52
54
  runs-on: ubuntu-latest
53
55
 
54
56
  steps:
55
- - uses: actions/checkout@v3
57
+ - uses: actions/checkout@v4
56
58
 
57
59
  - uses: ruby/setup-ruby@v1
58
60
  with:
@@ -0,0 +1,4 @@
1
+ Metrics/BlockLength:
2
+ Exclude:
3
+ - 'spec/**/*.rb'
4
+ - '*.gemspec'
data/.rubocop.yml CHANGED
@@ -1,6 +1,7 @@
1
1
  inherit_from:
2
2
  - .rubocop_todo.yml
3
3
  - .rubocop/layout.yml
4
+ - .rubocop/metrics.yml
4
5
  - .rubocop/style.yml
5
6
 
6
7
  AllCops:
data/.rubocop_todo.yml CHANGED
@@ -74,7 +74,7 @@ Metrics/AbcSize:
74
74
  - 'lib/http/request.rb'
75
75
  - 'lib/http/response.rb'
76
76
 
77
- # Offense count: 69
77
+ # Offense count: 70
78
78
  # Configuration parameters: CountComments, Max, CountAsOne, ExcludedMethods, IgnoredMethods.
79
79
  # IgnoredMethods: refine
80
80
  Metrics/BlockLength:
@@ -98,6 +98,7 @@ Metrics/BlockLength:
98
98
  - 'spec/lib/http/response/parser_spec.rb'
99
99
  - 'spec/lib/http/response/status_spec.rb'
100
100
  - 'spec/lib/http/response_spec.rb'
101
+ - 'spec/lib/http/uri_spec.rb'
101
102
  - 'spec/lib/http_spec.rb'
102
103
  - 'spec/support/http_handling_shared.rb'
103
104
 
data/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [5.2.0] - 2024-02-05
11
+
12
+ ### Added
13
+
14
+ - Add `Connection#finished_request?`
15
+ ([#743](https://github.com/httprb/http/pull/743))
16
+ - Add `Instrumentation#on_error`
17
+ ([#746](https://github.com/httprb/http/pull/746))
18
+ - Add `base64` dependency (suppresses warnings on Ruby 3.0)
19
+ ([#759](https://github.com/httprb/http/pull/759))
20
+ - Add `PURGE` HTTP verb
21
+ ([#757](https://github.com/httprb/http/pull/757))
22
+ - Add Ruby-3.3 support
23
+
24
+ ### Changed
25
+
26
+ - **BREAKING** Process features in reverse order
27
+ ([#766](https://github.com/httprb/http/pull/766))
28
+ - **BREAKING** Downcase Content-Type charset name
29
+ ([#753](https://github.com/httprb/http/pull/753))
30
+ - **BREAKING** Make URI normalization more conservative
31
+ ([#758](https://github.com/httprb/http/pull/758))
32
+
33
+ ### Fixed
34
+
35
+ - Close sockets on initialize failure
36
+ ([#762](https://github.com/httprb/http/pull/762))
37
+ - Prevent CRLF injection due to broken URL normalizer
38
+ ([#765](https://github.com/httprb/http/pull/765))
39
+
40
+ [unreleased]: https://github.com/httprb/http/compare/v5.2.0...5-x-stable
41
+ [5.2.0]: https://github.com/httprb/http/compare/v5.1.1...v5.2.0
@@ -1,3 +1,13 @@
1
+ ## 5.1.1 (2022-12-17)
2
+
3
+ * [#731](https://github.com/httprb/http/pull/731)
4
+ Strip brackets from IPv6 addresses in `HTTP::URI`.
5
+ ([@jeraki])
6
+
7
+ * [#722](https://github.com/httprb/http/pull/722)
8
+ Add `on_redirect` callback.
9
+ ([@benubois])
10
+
1
11
  ## 5.1.0 (2022-06-17)
2
12
 
3
13
  * Drop ruby-2.5 support.
@@ -44,7 +54,7 @@
44
54
  Use features on redirected requests.
45
55
  ([@nomis])
46
56
 
47
- * [#678](https://github.com/schwern)
57
+ * [#678](https://github.com/httprb/http/pull/678)
48
58
  Restore `HTTP::Response` `:uri` option for backwards compatibility.
49
59
  ([@schwern])
50
60
 
@@ -988,3 +998,5 @@ end
988
998
  [@YuLeven]: https://github.com/YuLeven
989
999
  [@drwl]: https://github.com/drwl
990
1000
  [@tkellogg]: https://github.com/tkellogg
1001
+ [@jeraki]: https://github.com/jeraki
1002
+ [@benubois]: https://github.com/benubois
data/README.md CHANGED
@@ -110,10 +110,13 @@ and call `#readpartial` on it repeatedly until it returns `nil`:
110
110
  This library aims to support and is [tested against][build-link]
111
111
  the following Ruby versions:
112
112
 
113
+ - JRuby 9.3
113
114
  - Ruby 2.6
114
115
  - Ruby 2.7
115
116
  - Ruby 3.0
116
- - JRuby 9.2
117
+ - Ruby 3.1
118
+ - Ruby 3.2
119
+ - Ruby 3.3
117
120
 
118
121
  If something doesn't work on one of these versions, it's a bug.
119
122
 
@@ -159,5 +162,5 @@ See LICENSE.txt for further details.
159
162
  [//]: # (links)
160
163
 
161
164
  [documentation]: https://github.com/httprb/http/wiki
162
- [requests]: http://docs.python-requests.org/en/latest/
165
+ [requests]: https://docs.python-requests.org/en/latest/
163
166
  [llhttp]: https://llhttp.org/
data/SECURITY.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Security Policy
2
2
 
3
+ ## Supported Versions
4
+
5
+ Security updates are applied only to the most recent release.
6
+
3
7
  ## Reporting a Vulnerability
4
8
 
5
- Please report security issues to `bascule@gmail.com`
9
+ If you have discovered a security vulnerability in this project, please report
10
+ it privately. **Do not disclose it as a public issue.** This gives us time to
11
+ work with you to fix the issue before public exposure, reducing the chance that
12
+ the exploit will be used before a patch is released.
13
+
14
+ Please disclose it at [security advisory](https://github.com/httprb/http/security/advisories/new).
15
+
16
+ This project is maintained by a team of volunteers on a reasonable-effort basis.
17
+ As such, please give us at least 90 days to work on a fix before public exposure.
data/http.gemspec CHANGED
@@ -28,9 +28,10 @@ Gem::Specification.new do |gem|
28
28
  gem.required_ruby_version = ">= 2.6"
29
29
 
30
30
  gem.add_runtime_dependency "addressable", "~> 2.8"
31
+ gem.add_runtime_dependency "base64", "~> 0.1"
31
32
  gem.add_runtime_dependency "http-cookie", "~> 1.0"
32
33
  gem.add_runtime_dependency "http-form_data", "~> 2.2"
33
- gem.add_runtime_dependency "llhttp-ffi", "~> 0.4.0"
34
+ gem.add_runtime_dependency "llhttp-ffi", "~> 0.5.0"
34
35
 
35
36
  gem.add_development_dependency "bundler", "~> 2.0"
36
37
 
@@ -38,7 +39,7 @@ Gem::Specification.new do |gem|
38
39
  "source_code_uri" => "https://github.com/httprb/http",
39
40
  "wiki_uri" => "https://github.com/httprb/http/wiki",
40
41
  "bug_tracker_uri" => "https://github.com/httprb/http/issues",
41
- "changelog_uri" => "https://github.com/httprb/http/blob/v#{HTTP::VERSION}/CHANGES.md",
42
+ "changelog_uri" => "https://github.com/httprb/http/blob/v#{HTTP::VERSION}/CHANGELOG.md",
42
43
  "rubygems_mfa_required" => "true"
43
44
  }
44
45
  end
data/lib/http/client.rb CHANGED
@@ -184,7 +184,7 @@ module HTTP
184
184
  form
185
185
  when opts.json
186
186
  body = MimeType[:json].encode opts.json
187
- headers[Headers::CONTENT_TYPE] ||= "application/json; charset=#{body.encoding.name}"
187
+ headers[Headers::CONTENT_TYPE] ||= "application/json; charset=#{body.encoding.name.downcase}"
188
188
  body
189
189
  end
190
190
  end
@@ -46,6 +46,9 @@ module HTTP
46
46
  reset_timer
47
47
  rescue IOError, SocketError, SystemCallError => e
48
48
  raise ConnectionError, "failed to connect: #{e}", e.backtrace
49
+ rescue TimeoutError
50
+ close
51
+ raise
49
52
  end
50
53
 
51
54
  # @see (HTTP::Response::Parser#status_code)
@@ -126,12 +129,16 @@ module HTTP
126
129
  # Close the connection
127
130
  # @return [void]
128
131
  def close
129
- @socket.close unless @socket.closed?
132
+ @socket.close unless @socket&.closed?
130
133
 
131
134
  @pending_response = false
132
135
  @pending_request = false
133
136
  end
134
137
 
138
+ def finished_request?
139
+ !@pending_request && !@pending_response
140
+ end
141
+
135
142
  # Whether we're keeping the conn alive
136
143
  # @return [Boolean]
137
144
  def keep_alive?
@@ -19,11 +19,12 @@ module HTTP
19
19
  # and `finish` so the duration of the request can be calculated.
20
20
  #
21
21
  class Instrumentation < Feature
22
- attr_reader :instrumenter, :name
22
+ attr_reader :instrumenter, :name, :error_name
23
23
 
24
24
  def initialize(instrumenter: NullInstrumenter.new, namespace: "http")
25
25
  @instrumenter = instrumenter
26
26
  @name = "request.#{namespace}"
27
+ @error_name = "error.#{namespace}"
27
28
  end
28
29
 
29
30
  def wrap_request(request)
@@ -39,6 +40,10 @@ module HTTP
39
40
  response
40
41
  end
41
42
 
43
+ def on_error(request, error)
44
+ instrumenter.instrument(error_name, :request => request, :error => error)
45
+ end
46
+
42
47
  HTTP::Options.register_feature(:instrumentation, self)
43
48
 
44
49
  class NullInstrumenter
@@ -40,8 +40,9 @@ module HTTP
40
40
  # @option opts [Boolean] :strict (true) redirector hops policy
41
41
  # @option opts [#to_i] :max_hops (5) maximum allowed amount of hops
42
42
  def initialize(opts = {})
43
- @strict = opts.fetch(:strict, true)
44
- @max_hops = opts.fetch(:max_hops, 5).to_i
43
+ @strict = opts.fetch(:strict, true)
44
+ @max_hops = opts.fetch(:max_hops, 5).to_i
45
+ @on_redirect = opts.fetch(:on_redirect, nil)
45
46
  end
46
47
 
47
48
  # Follows redirects until non-redirect response found
@@ -65,6 +66,7 @@ module HTTP
65
66
  unless cookie_jar.empty?
66
67
  @request.headers.set(Headers::COOKIE, cookie_jar.cookies.map { |c| "#{c.name}=#{c.value}" }.join("; "))
67
68
  end
69
+ @on_redirect.call @response, @request if @on_redirect.respond_to?(:call)
68
70
  @response = yield @request
69
71
  collect_cookies_from_response
70
72
  end
data/lib/http/request.rb CHANGED
@@ -49,7 +49,10 @@ module HTTP
49
49
  :search,
50
50
 
51
51
  # RFC 4791: Calendaring Extensions to WebDAV -- CalDAV
52
- :mkcalendar
52
+ :mkcalendar,
53
+
54
+ # Implemented by several caching servers, like Squid, Varnish or Fastly
55
+ :purge
53
56
  ].freeze
54
57
 
55
58
  # Allowed schemes
@@ -172,7 +175,9 @@ module HTTP
172
175
  uri.omit(:fragment)
173
176
  else
174
177
  uri.request_uri
175
- end
178
+ end.to_s
179
+
180
+ raise RequestError, "Invalid request URI: #{request_uri.inspect}" if request_uri.match?(/\s/)
176
181
 
177
182
  "#{verb.to_s.upcase} #{request_uri} HTTP/#{version}"
178
183
  end
@@ -230,7 +235,11 @@ module HTTP
230
235
 
231
236
  # @return [String] Default host (with port if needed) header value.
232
237
  def default_host_header_value
233
- PORTS[@scheme] == port ? host : "#{host}:#{port}"
238
+ value = PORTS[@scheme] == port ? host : "#{host}:#{port}"
239
+
240
+ raise RequestError, "Invalid host: #{value.inspect}" if value.match?(/\s/)
241
+
242
+ value
234
243
  end
235
244
 
236
245
  def prepare_body(body)
@@ -1,15 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "forwardable"
4
3
  require "io/wait"
5
4
 
6
5
  module HTTP
7
6
  module Timeout
8
7
  class Null
9
- extend Forwardable
10
-
11
- def_delegators :@socket, :close, :closed?
12
-
13
8
  attr_reader :options, :socket
14
9
 
15
10
  def initialize(options = {})
@@ -27,6 +22,14 @@ module HTTP
27
22
  @socket.connect
28
23
  end
29
24
 
25
+ def close
26
+ @socket&.close
27
+ end
28
+
29
+ def closed?
30
+ @socket&.closed?
31
+ end
32
+
30
33
  # Configures the SSL connection and starts the connection
31
34
  def start_tls(host, ssl_socket_class, ssl_context)
32
35
  @socket = ssl_socket_class.new(socket, ssl_context)
data/lib/http/uri.rb CHANGED
@@ -9,7 +9,6 @@ module HTTP
9
9
  def_delegators :@uri, :scheme, :normalized_scheme, :scheme=
10
10
  def_delegators :@uri, :user, :normalized_user, :user=
11
11
  def_delegators :@uri, :password, :normalized_password, :password=
12
- def_delegators :@uri, :host, :normalized_host, :host=
13
12
  def_delegators :@uri, :authority, :normalized_authority, :authority=
14
13
  def_delegators :@uri, :origin, :origin=
15
14
  def_delegators :@uri, :normalized_port, :port=
@@ -20,12 +19,27 @@ module HTTP
20
19
  def_delegators :@uri, :fragment, :normalized_fragment, :fragment=
21
20
  def_delegators :@uri, :omit, :join, :normalize
22
21
 
22
+ # Host, either a domain name or IP address. If the host is an IPv6 address, it will be returned
23
+ # without brackets surrounding it.
24
+ #
25
+ # @return [String] The host of the URI
26
+ attr_reader :host
27
+
28
+ # Normalized host, either a domain name or IP address. If the host is an IPv6 address, it will
29
+ # be returned without brackets surrounding it.
30
+ #
31
+ # @return [String] The normalized host of the URI
32
+ attr_reader :normalized_host
33
+
23
34
  # @private
24
35
  HTTP_SCHEME = "http"
25
36
 
26
37
  # @private
27
38
  HTTPS_SCHEME = "https"
28
39
 
40
+ # @private
41
+ PERCENT_ENCODE = /[^\x21-\x7E]+/.freeze
42
+
29
43
  # @private
30
44
  NORMALIZER = lambda do |uri|
31
45
  uri = HTTP::URI.parse uri
@@ -33,8 +47,8 @@ module HTTP
33
47
  HTTP::URI.new(
34
48
  :scheme => uri.normalized_scheme,
35
49
  :authority => uri.normalized_authority,
36
- :path => uri.normalized_path,
37
- :query => uri.query,
50
+ :path => uri.path.empty? ? "/" : percent_encode(Addressable::URI.normalize_path(uri.path)),
51
+ :query => percent_encode(uri.query),
38
52
  :fragment => uri.normalized_fragment
39
53
  )
40
54
  end
@@ -60,6 +74,19 @@ module HTTP
60
74
  Addressable::URI.form_encode(form_values, sort)
61
75
  end
62
76
 
77
+ # Percent-encode all characters matching a regular expression.
78
+ #
79
+ # @param [String] string raw string
80
+ #
81
+ # @return [String] encoded value
82
+ #
83
+ # @private
84
+ def self.percent_encode(string)
85
+ string&.gsub(PERCENT_ENCODE) do |substr|
86
+ substr.encode(Encoding::UTF_8).bytes.map { |c| format("%%%02X", c) }.join
87
+ end
88
+ end
89
+
63
90
  # Creates an HTTP::URI instance from the given options
64
91
  #
65
92
  # @param [Hash, Addressable::URI] options_or_uri
@@ -83,6 +110,9 @@ module HTTP
83
110
  else
84
111
  raise TypeError, "expected Hash for options, got #{options_or_uri.class}"
85
112
  end
113
+
114
+ @host = process_ipv6_brackets(@uri.host)
115
+ @normalized_host = process_ipv6_brackets(@uri.normalized_host)
86
116
  end
87
117
 
88
118
  # Are these URI objects equal? Normalizes both URIs prior to comparison
@@ -110,6 +140,17 @@ module HTTP
110
140
  @hash ||= to_s.hash * -1
111
141
  end
112
142
 
143
+ # Sets the host component for the URI.
144
+ #
145
+ # @param [String, #to_str] new_host The new host component.
146
+ # @return [void]
147
+ def host=(new_host)
148
+ @uri.host = process_ipv6_brackets(new_host, :brackets => true)
149
+
150
+ @host = process_ipv6_brackets(@uri.host)
151
+ @normalized_host = process_ipv6_brackets(@uri.normalized_host)
152
+ end
153
+
113
154
  # Port number, either as specified or the default if unspecified
114
155
  #
115
156
  # @return [Integer] port number
@@ -146,5 +187,25 @@ module HTTP
146
187
  def inspect
147
188
  format("#<%s:0x%014x URI:%s>", self.class.name, object_id << 1, to_s)
148
189
  end
190
+
191
+ private
192
+
193
+ # Process a URI host, adding or removing surrounding brackets if the host is an IPv6 address.
194
+ #
195
+ # @param [Boolean] brackets When true, brackets will be added to IPv6 addresses if missing. When
196
+ # false, they will be removed if present.
197
+ #
198
+ # @return [String] Host with IPv6 address brackets added or removed
199
+ def process_ipv6_brackets(raw_host, brackets: false)
200
+ ip = IPAddr.new(raw_host)
201
+
202
+ if ip.ipv6?
203
+ brackets ? "[#{ip}]" : ip.to_s
204
+ else
205
+ raw_host
206
+ end
207
+ rescue IPAddr::Error
208
+ raw_host
209
+ end
149
210
  end
150
211
  end
data/lib/http/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HTTP
4
- VERSION = "5.1.0"
4
+ VERSION = "5.2.0"
5
5
  end
@@ -1,10 +1,12 @@
1
1
  # coding: utf-8
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "cgi"
5
+ require "logger"
6
+
4
7
  require "support/http_handling_shared"
5
8
  require "support/dummy_server"
6
9
  require "support/ssl_helper"
7
- require "logger"
8
10
 
9
11
  RSpec.describe HTTP::Client do
10
12
  run_server(:dummy) { DummyServer.new }
@@ -259,6 +261,7 @@ RSpec.describe HTTP::Client do
259
261
 
260
262
  expect(HTTP::Request).to receive(:new) do |opts|
261
263
  expect(opts[:body]).to eq '{"foo":"bar"}'
264
+ expect(opts[:headers]["Content-Type"]).to eq "application/json; charset=utf-8"
262
265
  end
263
266
 
264
267
  client.get("http://example.com/", :json => {:foo => :bar})
@@ -8,11 +8,31 @@ RSpec.describe HTTP::Connection do
8
8
  :headers => {}
9
9
  )
10
10
  end
11
- let(:socket) { double(:connect => nil) }
11
+ let(:socket) { double(:connect => nil, :close => nil) }
12
12
  let(:timeout_class) { double(:new => socket) }
13
13
  let(:opts) { HTTP::Options.new(:timeout_class => timeout_class) }
14
14
  let(:connection) { HTTP::Connection.new(req, opts) }
15
15
 
16
+ describe "#initialize times out" do
17
+ let(:req) do
18
+ HTTP::Request.new(
19
+ :verb => :get,
20
+ :uri => "https://example.com/",
21
+ :headers => {}
22
+ )
23
+ end
24
+
25
+ before do
26
+ expect(socket).to receive(:start_tls).and_raise(HTTP::TimeoutError)
27
+ expect(socket).to receive(:closed?) { false }
28
+ expect(socket).to receive(:close)
29
+ end
30
+
31
+ it "closes the connection" do
32
+ expect { connection }.to raise_error(HTTP::TimeoutError)
33
+ end
34
+ end
35
+
16
36
  describe "#read_headers!" do
17
37
  before do
18
38
  connection.instance_variable_set(:@pending_response, true)
@@ -58,9 +78,11 @@ RSpec.describe HTTP::Connection do
58
78
  connection.read_headers!
59
79
  buffer = String.new
60
80
  while (s = connection.readpartial(3))
81
+ expect(connection.finished_request?).to be false if s != ""
61
82
  buffer << s
62
83
  end
63
84
  expect(buffer).to eq "1234567890"
85
+ expect(connection.finished_request?).to be true
64
86
  end
65
87
  end
66
88
  end
@@ -59,4 +59,23 @@ RSpec.describe HTTP::Features::Instrumentation do
59
59
  expect(instrumenter.output[:finish]).to eq(:response => response)
60
60
  end
61
61
  end
62
+
63
+ describe "logging errors" do
64
+ let(:request) do
65
+ HTTP::Request.new(
66
+ :verb => :post,
67
+ :uri => "https://example.com/",
68
+ :headers => {:accept => "application/json"},
69
+ :body => '{"hello": "world!"}'
70
+ )
71
+ end
72
+
73
+ let(:error) { HTTP::TimeoutError.new }
74
+
75
+ it "should log the error" do
76
+ feature.on_error(request, error)
77
+
78
+ expect(instrumenter.output[:finish]).to eq(:request => request, :error => error)
79
+ end
80
+ end
62
81
  end
@@ -14,7 +14,11 @@ RSpec.describe HTTP::Options, "headers" do
14
14
  end
15
15
 
16
16
  it "accepts any object that respond to :to_hash" do
17
- x = Struct.new(:to_hash).new("accept" => "json")
17
+ x = if RUBY_VERSION >= "3.2.0"
18
+ Data.define(:to_hash).new(:to_hash => { "accept" => "json" })
19
+ else
20
+ Struct.new(:to_hash).new({ "accept" => "json" })
21
+ end
18
22
  expect(opts.with_headers(x).headers["accept"]).to eq("json")
19
23
  end
20
24
  end
@@ -148,6 +148,32 @@ RSpec.describe HTTP::Redirector do
148
148
  expect(cookies["deleted"]).to eq nil
149
149
  end
150
150
 
151
+ context "with on_redirect callback" do
152
+ let(:options) do
153
+ {
154
+ :on_redirect => proc do |response, location|
155
+ @redirect_response = response
156
+ @redirect_location = location
157
+ end
158
+ }
159
+ end
160
+
161
+ it "calls on_redirect" do
162
+ req = HTTP::Request.new :verb => :head, :uri => "http://example.com"
163
+ hops = [
164
+ redirect_response(301, "http://example.com/1"),
165
+ redirect_response(301, "http://example.com/2"),
166
+ simple_response(200, "foo")
167
+ ]
168
+
169
+ redirector.perform(req, hops.shift) do |prev_req, _|
170
+ expect(@redirect_location.uri.to_s).to eq prev_req.uri.to_s
171
+ expect(@redirect_response.code).to eq 301
172
+ hops.shift
173
+ end
174
+ end
175
+ end
176
+
151
177
  context "following 300 redirect" do
152
178
  context "with strict mode" do
153
179
  let(:options) { {:strict => true} }
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe HTTP::URI::NORMALIZER do
4
+ describe "scheme" do
5
+ it "lower-cases scheme" do
6
+ expect(HTTP::URI::NORMALIZER.call("HttP://example.com").scheme).to eq "http"
7
+ end
8
+ end
9
+
10
+ describe "hostname" do
11
+ it "lower-cases hostname" do
12
+ expect(HTTP::URI::NORMALIZER.call("http://EXAMPLE.com").host).to eq "example.com"
13
+ end
14
+
15
+ it "decodes percent-encoded hostname" do
16
+ expect(HTTP::URI::NORMALIZER.call("http://ex%61mple.com").host).to eq "example.com"
17
+ end
18
+
19
+ it "removes trailing period in hostname" do
20
+ expect(HTTP::URI::NORMALIZER.call("http://example.com.").host).to eq "example.com"
21
+ end
22
+
23
+ it "IDN-encodes non-ASCII hostname" do
24
+ expect(HTTP::URI::NORMALIZER.call("http://exämple.com").host).to eq "xn--exmple-cua.com"
25
+ end
26
+ end
27
+
28
+ describe "path" do
29
+ it "ensures path is not empty" do
30
+ expect(HTTP::URI::NORMALIZER.call("http://example.com").path).to eq "/"
31
+ end
32
+
33
+ it "preserves double slashes in path" do
34
+ expect(HTTP::URI::NORMALIZER.call("http://example.com//a///b").path).to eq "//a///b"
35
+ end
36
+
37
+ it "resolves single-dot segments in path" do
38
+ expect(HTTP::URI::NORMALIZER.call("http://example.com/a/./b").path).to eq "/a/b"
39
+ end
40
+
41
+ it "resolves double-dot segments in path" do
42
+ expect(HTTP::URI::NORMALIZER.call("http://example.com/a/b/../c").path).to eq "/a/c"
43
+ end
44
+
45
+ it "resolves leading double-dot segments in path" do
46
+ expect(HTTP::URI::NORMALIZER.call("http://example.com/../a/b").path).to eq "/a/b"
47
+ end
48
+
49
+ it "percent-encodes control characters in path" do
50
+ expect(HTTP::URI::NORMALIZER.call("http://example.com/\x00\x7F\n").path).to eq "/%00%7F%0A"
51
+ end
52
+
53
+ it "percent-encodes space in path" do
54
+ expect(HTTP::URI::NORMALIZER.call("http://example.com/a b").path).to eq "/a%20b"
55
+ end
56
+
57
+ it "percent-encodes non-ASCII characters in path" do
58
+ expect(HTTP::URI::NORMALIZER.call("http://example.com/キョ").path).to eq "/%E3%82%AD%E3%83%A7"
59
+ end
60
+
61
+ it "does not percent-encode non-special characters in path" do
62
+ expect(HTTP::URI::NORMALIZER.call("http://example.com/~.-_!$&()*,;=:@{}").path).to eq "/~.-_!$&()*,;=:@{}"
63
+ end
64
+
65
+ it "preserves escape sequences in path" do
66
+ expect(HTTP::URI::NORMALIZER.call("http://example.com/%41").path).to eq "/%41"
67
+ end
68
+ end
69
+
70
+ describe "query" do
71
+ it "allows no query" do
72
+ expect(HTTP::URI::NORMALIZER.call("http://example.com").query).to be_nil
73
+ end
74
+
75
+ it "percent-encodes control characters in query" do
76
+ expect(HTTP::URI::NORMALIZER.call("http://example.com/?\x00\x7F\n").query).to eq "%00%7F%0A"
77
+ end
78
+
79
+ it "percent-encodes space in query" do
80
+ expect(HTTP::URI::NORMALIZER.call("http://example.com/?a b").query).to eq "a%20b"
81
+ end
82
+
83
+ it "percent-encodes non-ASCII characters in query" do
84
+ expect(HTTP::URI::NORMALIZER.call("http://example.com?キョ").query).to eq "%E3%82%AD%E3%83%A7"
85
+ end
86
+
87
+ it "does not percent-encode non-special characters in query" do
88
+ expect(HTTP::URI::NORMALIZER.call("http://example.com/?~.-_!$&()*,;=:@{}?").query).to eq "~.-_!$&()*,;=:@{}?"
89
+ end
90
+
91
+ it "preserves escape sequences in query" do
92
+ expect(HTTP::URI::NORMALIZER.call("http://example.com/?%41").query).to eq "%41"
93
+ end
94
+ end
95
+ end
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  RSpec.describe HTTP::URI do
4
+ let(:example_ipv6_address) { "2606:2800:220:1:248:1893:25c8:1946" }
5
+
4
6
  let(:example_http_uri_string) { "http://example.com" }
5
7
  let(:example_https_uri_string) { "https://example.com" }
8
+ let(:example_ipv6_uri_string) { "https://[#{example_ipv6_address}]" }
6
9
 
7
10
  subject(:http_uri) { described_class.parse(example_http_uri_string) }
8
11
  subject(:https_uri) { described_class.parse(example_https_uri_string) }
12
+ subject(:ipv6_uri) { described_class.parse(example_ipv6_uri_string) }
9
13
 
10
14
  it "knows URI schemes" do
11
15
  expect(http_uri.scheme).to eq "http"
@@ -20,6 +24,41 @@ RSpec.describe HTTP::URI do
20
24
  expect(https_uri.port).to eq 443
21
25
  end
22
26
 
27
+ describe "#host" do
28
+ it "strips brackets from IPv6 addresses" do
29
+ expect(ipv6_uri.host).to eq("2606:2800:220:1:248:1893:25c8:1946")
30
+ end
31
+ end
32
+
33
+ describe "#normalized_host" do
34
+ it "strips brackets from IPv6 addresses" do
35
+ expect(ipv6_uri.normalized_host).to eq("2606:2800:220:1:248:1893:25c8:1946")
36
+ end
37
+ end
38
+
39
+ describe "#host=" do
40
+ it "updates cached values for #host and #normalized_host" do
41
+ expect(http_uri.host).to eq("example.com")
42
+ expect(http_uri.normalized_host).to eq("example.com")
43
+
44
+ http_uri.host = "[#{example_ipv6_address}]"
45
+
46
+ expect(http_uri.host).to eq(example_ipv6_address)
47
+ expect(http_uri.normalized_host).to eq(example_ipv6_address)
48
+ end
49
+
50
+ it "ensures IPv6 addresses are bracketed in the inner Addressable::URI" do
51
+ expect(http_uri.host).to eq("example.com")
52
+ expect(http_uri.normalized_host).to eq("example.com")
53
+
54
+ http_uri.host = example_ipv6_address
55
+
56
+ expect(http_uri.host).to eq(example_ipv6_address)
57
+ expect(http_uri.normalized_host).to eq(example_ipv6_address)
58
+ expect(http_uri.instance_variable_get(:@uri).host).to eq("[#{example_ipv6_address}]")
59
+ end
60
+ end
61
+
23
62
  describe "#dup" do
24
63
  it "doesn't share internal value between duplicates" do
25
64
  duplicated_uri = http_uri.dup
@@ -460,20 +460,37 @@ RSpec.describe HTTP do
460
460
 
461
461
  context "with :normalize_uri" do
462
462
  it "normalizes URI" do
463
- response = HTTP.get "#{dummy.endpoint}/hello world"
463
+ response = HTTP.get "#{dummy.endpoint}/héllö-wörld"
464
464
  expect(response.to_s).to eq("hello world")
465
465
  end
466
466
 
467
467
  it "uses the custom URI Normalizer method" do
468
468
  client = HTTP.use(:normalize_uri => {:normalizer => :itself.to_proc})
469
- response = client.get("#{dummy.endpoint}/hello world")
469
+ response = client.get("#{dummy.endpoint}/héllö-wörld")
470
470
  expect(response.status).to eq(400)
471
471
  end
472
472
 
473
+ it "raises if custom URI Normalizer returns invalid path" do
474
+ client = HTTP.use(:normalize_uri => {:normalizer => :itself.to_proc})
475
+ expect { client.get("#{dummy.endpoint}/hello\nworld") }.
476
+ to raise_error HTTP::RequestError, 'Invalid request URI: "/hello\nworld"'
477
+ end
478
+
479
+ it "raises if custom URI Normalizer returns invalid host" do
480
+ normalizer = lambda do |uri|
481
+ uri.port = nil
482
+ uri.instance_variable_set(:@host, "example\ncom")
483
+ uri
484
+ end
485
+ client = HTTP.use(:normalize_uri => {:normalizer => normalizer})
486
+ expect { client.get(dummy.endpoint) }.
487
+ to raise_error HTTP::RequestError, 'Invalid host: "example\ncom"'
488
+ end
489
+
473
490
  it "uses the default URI normalizer" do
474
491
  client = HTTP.use :normalize_uri
475
492
  expect(HTTP::URI::NORMALIZER).to receive(:call).and_call_original
476
- response = client.get("#{dummy.endpoint}/hello world")
493
+ response = client.get("#{dummy.endpoint}/héllö-wörld")
477
494
  expect(response.to_s).to eq("hello world")
478
495
  end
479
496
  end
@@ -1,15 +1,17 @@
1
1
  # encoding: UTF-8
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "cgi"
5
+
4
6
  class DummyServer < WEBrick::HTTPServer
5
7
  class Servlet < WEBrick::HTTPServlet::AbstractServlet # rubocop:disable Metrics/ClassLength
6
8
  def self.sockets
7
9
  @sockets ||= []
8
10
  end
9
11
 
10
- def not_found(_req, res)
12
+ def not_found(req, res)
11
13
  res.status = 404
12
- res.body = "Not Found"
14
+ res.body = "#{req.unparsed_uri} not found"
13
15
  end
14
16
 
15
17
  def self.handlers
@@ -25,7 +27,7 @@ class DummyServer < WEBrick::HTTPServer
25
27
  def do_#{method.upcase}(req, res)
26
28
  handler = self.class.handlers["#{method}:\#{req.path}"]
27
29
  return instance_exec(req, res, &handler) if handler
28
- not_found
30
+ not_found(req, res)
29
31
  end
30
32
  RUBY
31
33
  end
@@ -66,7 +68,7 @@ class DummyServer < WEBrick::HTTPServer
66
68
  end
67
69
 
68
70
  get "/params" do |req, res|
69
- next not_found unless "foo=bar" == req.query_string
71
+ next not_found(req, res) unless "foo=bar" == req.query_string
70
72
 
71
73
  res.status = 200
72
74
  res.body = "Params!"
@@ -75,7 +77,7 @@ class DummyServer < WEBrick::HTTPServer
75
77
  get "/multiple-params" do |req, res|
76
78
  params = CGI.parse req.query_string
77
79
 
78
- next not_found unless {"foo" => ["bar"], "baz" => ["quux"]} == params
80
+ next not_found(req, res) unless {"foo" => ["bar"], "baz" => ["quux"]} == params
79
81
 
80
82
  res.status = 200
81
83
  res.body = "More Params!"
@@ -147,7 +149,7 @@ class DummyServer < WEBrick::HTTPServer
147
149
  res.body = req.body
148
150
  end
149
151
 
150
- get "/hello world" do |_req, res|
152
+ get "/héllö-wörld".b do |_req, res|
151
153
  res.status = 200
152
154
  res.body = "hello world"
153
155
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: http
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.1.0
4
+ version: 5.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tony Arcieri
@@ -11,7 +11,7 @@ authors:
11
11
  autorequire:
12
12
  bindir: bin
13
13
  cert_chain: []
14
- date: 2022-06-17 00:00:00.000000000 Z
14
+ date: 2024-02-05 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: addressable
@@ -27,6 +27,20 @@ dependencies:
27
27
  - - "~>"
28
28
  - !ruby/object:Gem::Version
29
29
  version: '2.8'
30
+ - !ruby/object:Gem::Dependency
31
+ name: base64
32
+ requirement: !ruby/object:Gem::Requirement
33
+ requirements:
34
+ - - "~>"
35
+ - !ruby/object:Gem::Version
36
+ version: '0.1'
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - "~>"
42
+ - !ruby/object:Gem::Version
43
+ version: '0.1'
30
44
  - !ruby/object:Gem::Dependency
31
45
  name: http-cookie
32
46
  requirement: !ruby/object:Gem::Requirement
@@ -61,14 +75,14 @@ dependencies:
61
75
  requirements:
62
76
  - - "~>"
63
77
  - !ruby/object:Gem::Version
64
- version: 0.4.0
78
+ version: 0.5.0
65
79
  type: :runtime
66
80
  prerelease: false
67
81
  version_requirements: !ruby/object:Gem::Requirement
68
82
  requirements:
69
83
  - - "~>"
70
84
  - !ruby/object:Gem::Version
71
- version: 0.4.0
85
+ version: 0.5.0
72
86
  - !ruby/object:Gem::Dependency
73
87
  name: bundler
74
88
  requirement: !ruby/object:Gem::Requirement
@@ -96,10 +110,12 @@ files:
96
110
  - ".rspec"
97
111
  - ".rubocop.yml"
98
112
  - ".rubocop/layout.yml"
113
+ - ".rubocop/metrics.yml"
99
114
  - ".rubocop/style.yml"
100
115
  - ".rubocop_todo.yml"
101
116
  - ".yardopts"
102
- - CHANGES.md
117
+ - CHANGELOG.md
118
+ - CHANGES_OLD.md
103
119
  - CONTRIBUTING.md
104
120
  - Gemfile
105
121
  - Guardfile
@@ -169,6 +185,7 @@ files:
169
185
  - spec/lib/http/response/parser_spec.rb
170
186
  - spec/lib/http/response/status_spec.rb
171
187
  - spec/lib/http/response_spec.rb
188
+ - spec/lib/http/uri/normalizer_spec.rb
172
189
  - spec/lib/http/uri_spec.rb
173
190
  - spec/lib/http_spec.rb
174
191
  - spec/regression_specs.rb
@@ -192,7 +209,7 @@ metadata:
192
209
  source_code_uri: https://github.com/httprb/http
193
210
  wiki_uri: https://github.com/httprb/http/wiki
194
211
  bug_tracker_uri: https://github.com/httprb/http/issues
195
- changelog_uri: https://github.com/httprb/http/blob/v5.1.0/CHANGES.md
212
+ changelog_uri: https://github.com/httprb/http/blob/v5.2.0/CHANGELOG.md
196
213
  rubygems_mfa_required: 'true'
197
214
  post_install_message:
198
215
  rdoc_options: []
@@ -209,7 +226,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
209
226
  - !ruby/object:Gem::Version
210
227
  version: '0'
211
228
  requirements: []
212
- rubygems_version: 3.1.6
229
+ rubygems_version: 3.5.4
213
230
  signing_key:
214
231
  specification_version: 4
215
232
  summary: HTTP should be easy
@@ -240,6 +257,7 @@ test_files:
240
257
  - spec/lib/http/response/parser_spec.rb
241
258
  - spec/lib/http/response/status_spec.rb
242
259
  - spec/lib/http/response_spec.rb
260
+ - spec/lib/http/uri/normalizer_spec.rb
243
261
  - spec/lib/http/uri_spec.rb
244
262
  - spec/lib/http_spec.rb
245
263
  - spec/regression_specs.rb