Skip to content

Commit e8d1b82

Browse files
authored
Move Async::HTTP::RelativeLocation to Async::HTTP::Middleware::LocationRedirector. (#174)
1 parent 11b9d5d commit e8d1b82

File tree

3 files changed

+152
-134
lines changed

3 files changed

+152
-134
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2018-2023, by Samuel Williams.
5+
# Copyright, 2019-2020, by Brian Morearty.
6+
7+
require_relative '../reference'
8+
9+
require 'protocol/http/middleware'
10+
require 'protocol/http/body/rewindable'
11+
12+
module Async
13+
module HTTP
14+
module Middleware
15+
# A client wrapper which transparently handles redirects to a given maximum number of hops.
16+
#
17+
# The default implementation will only follow relative locations (i.e. those without a scheme) and will switch to GET if the original request was not a GET.
18+
#
19+
# The best reference for these semantics is defined by the [Fetch specification](https://fetch.spec.whatwg.org/#http-redirect-fetch).
20+
#
21+
# | Redirect using GET | Permanent | Temporary |
22+
# |:-----------------------------------------:|:---------:|:---------:|
23+
# | Allowed | 301 | 302 |
24+
# | Preserve original method | 308 | 307 |
25+
#
26+
# For the specific details of the redirect handling, see:
27+
# - <https://datatracker.ietf.org/doc/html/rfc7231#section-6-4-2> 301 Moved Permanently.
28+
# - <https://datatracker.ietf.org/doc/html/rfc7231#section-6-4-3> 302 Found.
29+
# - <https://datatracker.ietf.org/doc/html/rfc7538 308 Permanent Redirect.
30+
# - <https://datatracker.ietf.org/doc/html/rfc7231#section-6-4-7> 307 Temporary Redirect.
31+
#
32+
class LocationRedirector < ::Protocol::HTTP::Middleware
33+
class TooManyRedirects < StandardError
34+
end
35+
36+
# Header keys which should be deleted when changing a request from a POST to a GET as defined by <https://fetch.spec.whatwg.org/#request-body-header-name>.
37+
PROHIBITED_GET_HEADERS = [
38+
'content-encoding',
39+
'content-language',
40+
'content-location',
41+
'content-type',
42+
]
43+
44+
# maximum_hops is the max number of redirects. Set to 0 to allow 1 request with no redirects.
45+
def initialize(app, maximum_hops = 3)
46+
super(app)
47+
48+
@maximum_hops = maximum_hops
49+
end
50+
51+
# The maximum number of hops which will limit the number of redirects until an error is thrown.
52+
attr :maximum_hops
53+
54+
def redirect_with_get?(request, response)
55+
# We only want to switch to GET if the request method is something other than get, e.g. POST.
56+
if request.method != GET
57+
# According to the RFC, we should only switch to GET if the response is a 301 or 302:
58+
return response.status == 301 || response.status == 302
59+
end
60+
end
61+
62+
# Handle a redirect to a relative location.
63+
#
64+
# @parameter request [Protocol::HTTP::Request] The original request, which you can modify if you want to handle the redirect.
65+
# @parameter location [String] The relative location to redirect to.
66+
# @returns [Boolean] True if the redirect was handled, false if it was not.
67+
def handle_redirect(request, location)
68+
uri = URI.parse(location)
69+
70+
if uri.absolute?
71+
return false
72+
end
73+
74+
# Update the path of the request:
75+
request.path = Reference[request.path] + location
76+
77+
# Follow the redirect:
78+
return true
79+
end
80+
81+
def call(request)
82+
# We don't want to follow redirects for HEAD requests:
83+
return super if request.head?
84+
85+
if body = request.body
86+
if body.respond_to?(:rewind)
87+
# The request body was already rewindable, so use it as is:
88+
body = request.body
89+
else
90+
# The request body was not rewindable, and we might need to resubmit it if we get a response status of 307 or 308, so make it rewindable:
91+
body = ::Protocol::HTTP::Body::Rewindable.new(body)
92+
request.body = body
93+
end
94+
end
95+
96+
hops = 0
97+
98+
while hops <= @maximum_hops
99+
response = super(request)
100+
101+
if response.redirection?
102+
hops += 1
103+
104+
# Get the redirect location:
105+
unless location = response.headers['location']
106+
return response
107+
end
108+
109+
response.finish
110+
111+
unless handle_redirect(request, location)
112+
return response
113+
end
114+
115+
# Ensure the request (body) is finished and set to nil before we manipulate the request:
116+
request.finish
117+
118+
if request.method == GET or response.preserve_method?
119+
# We (might) need to rewind the body so that it can be submitted again:
120+
body&.rewind
121+
request.body = body
122+
else
123+
# We are changing the method to GET:
124+
request.method = GET
125+
126+
# We will no longer be submitting the body:
127+
body = nil
128+
129+
# Remove any headers which are not allowed in a GET request:
130+
PROHIBITED_GET_HEADERS.each do |header|
131+
request.headers.delete(header)
132+
end
133+
end
134+
else
135+
return response
136+
end
137+
end
138+
139+
raise TooManyRedirects, "Redirected #{hops} times, exceeded maximum!"
140+
end
141+
end
142+
end
143+
end
144+
end

lib/async/http/relative_location.rb

+5-131
Original file line numberDiff line numberDiff line change
@@ -4,141 +4,15 @@
44
# Copyright, 2018-2023, by Samuel Williams.
55
# Copyright, 2019-2020, by Brian Morearty.
66

7-
require_relative 'client'
8-
require_relative 'endpoint'
9-
require_relative 'reference'
7+
require_relative 'middleware/location_redirector'
108

11-
require 'protocol/http/middleware'
12-
require 'protocol/http/body/rewindable'
9+
warn "`Async::HTTP::RelativeLocation` is deprecated and will be removed in the next release. Please use `Async::HTTP::Middleware::LocationRedirector` instead.", uplevel: 1
1310

1411
module Async
1512
module HTTP
16-
class TooManyRedirects < StandardError
17-
end
18-
19-
# A client wrapper which transparently handles redirects to a given maximum number of hops.
20-
#
21-
# The default implementation will only follow relative locations (i.e. those without a scheme) and will switch to GET if the original request was not a GET.
22-
#
23-
# The best reference for these semantics is defined by the [Fetch specification](https://fetch.spec.whatwg.org/#http-redirect-fetch).
24-
#
25-
# | Redirect using GET | Permanent | Temporary |
26-
# |:-----------------------------------------:|:---------:|:---------:|
27-
# | Allowed | 301 | 302 |
28-
# | Preserve original method | 308 | 307 |
29-
#
30-
# For the specific details of the redirect handling, see:
31-
# - <https://datatracker.ietf.org/doc/html/rfc7231#section-6-4-2> 301 Moved Permanently.
32-
# - <https://datatracker.ietf.org/doc/html/rfc7231#section-6-4-3> 302 Found.
33-
# - <https://datatracker.ietf.org/doc/html/rfc7538 308 Permanent Redirect.
34-
# - <https://datatracker.ietf.org/doc/html/rfc7231#section-6-4-7> 307 Temporary Redirect.
35-
#
36-
class RelativeLocation < ::Protocol::HTTP::Middleware
37-
# Header keys which should be deleted when changing a request from a POST to a GET as defined by <https://fetch.spec.whatwg.org/#request-body-header-name>.
38-
PROHIBITED_GET_HEADERS = [
39-
'content-encoding',
40-
'content-language',
41-
'content-location',
42-
'content-type',
43-
]
44-
45-
# maximum_hops is the max number of redirects. Set to 0 to allow 1 request with no redirects.
46-
def initialize(app, maximum_hops = 3)
47-
super(app)
48-
49-
@maximum_hops = maximum_hops
50-
end
51-
52-
# The maximum number of hops which will limit the number of redirects until an error is thrown.
53-
attr :maximum_hops
54-
55-
def redirect_with_get?(request, response)
56-
# We only want to switch to GET if the request method is something other than get, e.g. POST.
57-
if request.method != GET
58-
# According to the RFC, we should only switch to GET if the response is a 301 or 302:
59-
return response.status == 301 || response.status == 302
60-
end
61-
end
62-
63-
# Handle a redirect to a relative location.
64-
#
65-
# @parameter request [Protocol::HTTP::Request] The original request, which you can modify if you want to handle the redirect.
66-
# @parameter location [String] The relative location to redirect to.
67-
# @returns [Boolean] True if the redirect was handled, false if it was not.
68-
def handle_redirect(request, location)
69-
uri = URI.parse(location)
70-
71-
if uri.absolute?
72-
return false
73-
end
74-
75-
# Update the path of the request:
76-
request.path = Reference[request.path] + location
77-
78-
# Follow the redirect:
79-
return true
80-
end
81-
82-
def call(request)
83-
# We don't want to follow redirects for HEAD requests:
84-
return super if request.head?
85-
86-
if body = request.body
87-
if body.respond_to?(:rewind)
88-
# The request body was already rewindable, so use it as is:
89-
body = request.body
90-
else
91-
# The request body was not rewindable, and we might need to resubmit it if we get a response status of 307 or 308, so make it rewindable:
92-
body = ::Protocol::HTTP::Body::Rewindable.new(body)
93-
request.body = body
94-
end
95-
end
96-
97-
hops = 0
98-
99-
while hops <= @maximum_hops
100-
response = super(request)
101-
102-
if response.redirection?
103-
hops += 1
104-
105-
# Get the redirect location:
106-
unless location = response.headers['location']
107-
return response
108-
end
109-
110-
response.finish
111-
112-
unless handle_redirect(request, location)
113-
return response
114-
end
115-
116-
# Ensure the request (body) is finished and set to nil before we manipulate the request:
117-
request.finish
118-
119-
if request.method == GET or response.preserve_method?
120-
# We (might) need to rewind the body so that it can be submitted again:
121-
body&.rewind
122-
request.body = body
123-
else
124-
# We are changing the method to GET:
125-
request.method = GET
126-
127-
# We will no longer be submitting the body:
128-
body = nil
129-
130-
# Remove any headers which are not allowed in a GET request:
131-
PROHIBITED_GET_HEADERS.each do |header|
132-
request.headers.delete(header)
133-
end
134-
end
135-
else
136-
return response
137-
end
138-
end
139-
140-
raise TooManyRedirects, "Redirected #{hops} times, exceeded maximum!"
141-
end
13+
module Middleware
14+
RelativeLocation = Middleware::LocationRedirector
15+
TooManyRedirects = RelativeLocation::TooManyRedirects
14216
end
14317
end
14418
end

test/async/http/relative_location.rb renamed to test/async/http/middleware/location_redirector.rb

+3-3
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
# Copyright, 2018-2023, by Samuel Williams.
55
# Copyright, 2019-2020, by Brian Morearty.
66

7-
require 'async/http/relative_location'
7+
require 'async/http/middleware/location_redirector'
88
require 'async/http/server'
99

1010
require 'sus/fixtures/async/http'
1111

12-
describe Async::HTTP::RelativeLocation do
12+
describe Async::HTTP::Middleware::LocationRedirector do
1313
include Sus::Fixtures::Async::HTTP::ServerContext
1414

1515
let(:relative_location) {subject.new(@client, 1)}
@@ -49,7 +49,7 @@
4949
it 'should fail with maximum redirects' do
5050
expect do
5151
response = relative_location.get('/home')
52-
end.to raise_exception(Async::HTTP::TooManyRedirects, message: be =~ /maximum/)
52+
end.to raise_exception(subject::TooManyRedirects, message: be =~ /maximum/)
5353
end
5454
end
5555

0 commit comments

Comments
 (0)