Skip to content

Commit 99f93d7

Browse files
committed
Extract request building into HTTP::Request::Builder
Decouple request construction from Client by moving build logic into a standalone Request::Builder class. This eliminates the awkward pattern of creating a Client just to build a request (e.g. session.build_request delegating to make_client(...).build_request), and makes the builder usable independently without any connection state. The builder exposes two public methods: * #build(verb, uri) — constructs a Request with feature wrapping * #wrap(request) — applies feature middleware (used for redirects)
1 parent ee1edba commit 99f93d7

10 files changed

Lines changed: 206 additions & 209 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- **BREAKING** Extract request building into `HTTP::Request::Builder`. The
13+
`build_request` method has been removed from `Client`, `Session`, and the
14+
top-level `HTTP` module. Use `HTTP::Request::Builder.new(options).build(verb, uri)`
15+
to construct requests without executing them.
16+
1017
### Added
1118

1219
- `Feature#on_request` and `Feature#around_request` lifecycle hooks, called

lib/http/chainable.rb

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,6 @@ def request(verb, uri, opts = {})
2323
make_client(default_options).request(verb, uri, opts)
2424
end
2525

26-
# Prepare an HTTP request with the given verb
27-
#
28-
# @example
29-
# HTTP.build_request(:get, "http://example.com")
30-
#
31-
# @param (see Client#build_request)
32-
# @return [HTTP::Request]
33-
# @api public
34-
def build_request(verb, uri, opts = {})
35-
make_client(default_options).build_request(verb, uri, opts)
36-
end
37-
3826
# Set timeout on the request
3927
#
4028
# @example

lib/http/client.rb

Lines changed: 6 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,21 @@
22

33
require "forwardable"
44

5-
require "http/client/request_builder"
65
require "http/form_data"
76
require "http/retriable/performer"
87
require "http/options"
98
require "http/feature"
109
require "http/headers"
1110
require "http/connection"
1211
require "http/redirector"
12+
require "http/request/builder"
1313
require "http/uri"
1414

1515
module HTTP
1616
# Clients make requests and receive responses
1717
class Client
1818
extend Forwardable
1919
include Chainable
20-
include RequestBuilder
21-
22-
# Pattern matching HTTP or HTTPS URI schemes
23-
HTTP_OR_HTTPS_RE = %r{^https?://}i
2420

2521
# Initialize a new HTTP Client
2622
#
@@ -48,44 +44,17 @@ def initialize(default_options = nil, **)
4844
# @return [HTTP::Response] the response
4945
# @api public
5046
def request(verb, uri, opts = {})
51-
opts = @default_options.merge(opts)
52-
req = build_request(verb, uri, opts)
53-
res = perform(req, opts)
47+
opts = @default_options.merge(opts)
48+
builder = Request::Builder.new(opts)
49+
req = builder.build(verb, uri)
50+
res = perform(req, opts)
5451
return res unless opts.follow
5552

5653
Redirector.new(opts.follow).perform(req, res) do |request|
57-
perform(wrap_request(request, opts), opts)
54+
perform(builder.wrap(request), opts)
5855
end
5956
end
6057

61-
# Prepare an HTTP request
62-
#
63-
# @example
64-
# client.build_request(:get, "https://example.com")
65-
#
66-
# @param verb [Symbol] the HTTP method
67-
# @param uri [#to_s] the URI to request
68-
# @param opts [Hash] request options
69-
# @return [HTTP::Request] the built request object
70-
# @api public
71-
def build_request(verb, uri, opts = {})
72-
opts = @default_options.merge(opts)
73-
uri = make_request_uri(uri, opts)
74-
headers = make_request_headers(opts)
75-
body = make_request_body(opts, headers)
76-
77-
req = HTTP::Request.new({
78-
verb: verb,
79-
uri: uri,
80-
uri_normalizer: opts.feature(:normalize_uri)&.normalizer,
81-
proxy: opts.proxy,
82-
headers: headers,
83-
body: body
84-
})
85-
86-
wrap_request(req, opts)
87-
end
88-
8958
# @!method persistent?
9059
# Indicate whether the client has persistent connections
9160
#
@@ -215,15 +184,6 @@ def around_request(request, options, &block)
215184
end.call(request)
216185
end
217186

218-
# Wrap request through feature middleware
219-
# @return [HTTP::Request] the wrapped request
220-
# @api private
221-
def wrap_request(req, opts)
222-
opts.features.inject(req) do |request, (_name, feature)|
223-
feature.wrap_request(request)
224-
end
225-
end
226-
227187
# Build a response from the current connection
228188
# @return [HTTP::Response] the built response
229189
# @api private

lib/http/client/request_builder.rb

Lines changed: 0 additions & 91 deletions
This file was deleted.

lib/http/request/builder.rb

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# frozen_string_literal: true
2+
3+
require "http/form_data"
4+
require "http/headers"
5+
require "http/connection"
6+
require "http/uri"
7+
8+
module HTTP
9+
class Request
10+
# Builds HTTP::Request objects from resolved options
11+
#
12+
# @example Build a request from options
13+
# options = HTTP::Options.new(headers: {"Accept" => "application/json"})
14+
# builder = HTTP::Request::Builder.new(options)
15+
# request = builder.build(:get, "https://example.com")
16+
#
17+
# @see Options
18+
class Builder
19+
# Pattern matching HTTP or HTTPS URI schemes
20+
HTTP_OR_HTTPS_RE = %r{^https?://}i
21+
22+
# Initialize a new Request Builder
23+
#
24+
# @example
25+
# HTTP::Request::Builder.new(HTTP::Options.new)
26+
#
27+
# @param options [HTTP::Options] resolved request options
28+
# @return [HTTP::Request::Builder]
29+
# @api public
30+
def initialize(options)
31+
@options = options
32+
end
33+
34+
# Build an HTTP request
35+
#
36+
# @example
37+
# builder.build(:get, "https://example.com")
38+
#
39+
# @param verb [Symbol] the HTTP method
40+
# @param uri [#to_s] the URI to request
41+
# @return [HTTP::Request] the built request object
42+
# @api public
43+
def build(verb, uri)
44+
uri = make_request_uri(uri)
45+
headers = make_request_headers
46+
body = make_request_body(headers)
47+
48+
req = HTTP::Request.new(
49+
verb: verb,
50+
uri: uri,
51+
uri_normalizer: @options.feature(:normalize_uri)&.normalizer,
52+
proxy: @options.proxy,
53+
headers: headers,
54+
body: body
55+
)
56+
57+
wrap(req)
58+
end
59+
60+
# Wrap a request through feature middleware
61+
#
62+
# @example
63+
# builder.wrap(redirect_request)
64+
#
65+
# @param request [HTTP::Request] the request to wrap
66+
# @return [HTTP::Request] the wrapped request
67+
# @api public
68+
def wrap(request)
69+
@options.features.inject(request) do |req, (_name, feature)|
70+
feature.wrap_request(req)
71+
end
72+
end
73+
74+
private
75+
76+
# Merges query params if needed
77+
#
78+
# @param uri [#to_s] the URI to process
79+
# @return [HTTP::URI] the constructed URI
80+
# @api private
81+
def make_request_uri(uri)
82+
uri = uri.to_s
83+
84+
uri = "#{@options.persistent}#{uri}" if @options.persistent? && uri !~ HTTP_OR_HTTPS_RE
85+
86+
uri = HTTP::URI.parse uri
87+
88+
merge_query_params!(uri)
89+
90+
# Some proxies (seen on WEBrick) fail if URL has
91+
# empty path (e.g. `http://example.com`) while it's RFC-compliant:
92+
# http://tools.ietf.org/html/rfc1738#section-3.1
93+
uri.path = "/" if uri.path.empty?
94+
95+
uri
96+
end
97+
98+
# Merge query parameters into URI
99+
#
100+
# @return [void]
101+
# @api private
102+
def merge_query_params!(uri)
103+
return unless @options.params && !@options.params.empty?
104+
105+
uri.query_values = uri.query_values(Array).to_a.concat(@options.params.to_a)
106+
end
107+
108+
# Creates request headers
109+
#
110+
# @return [HTTP::Headers] the constructed headers
111+
# @api private
112+
def make_request_headers
113+
headers = @options.headers
114+
115+
# Tell the server to keep the conn open
116+
headers[Headers::CONNECTION] = @options.persistent? ? Connection::KEEP_ALIVE : Connection::CLOSE
117+
118+
headers
119+
end
120+
121+
# Create the request body object to send
122+
#
123+
# @return [String, HTTP::FormData, nil] the request body
124+
# @api private
125+
def make_request_body(headers)
126+
if @options.body
127+
@options.body
128+
elsif @options.form
129+
form = make_form_data(@options.form)
130+
headers[Headers::CONTENT_TYPE] ||= form.content_type
131+
form
132+
elsif @options.json
133+
make_json_body(@options.json, headers)
134+
end
135+
end
136+
137+
# Encode JSON body and set content type header
138+
# @return [String] the encoded JSON body
139+
# @api private
140+
def make_json_body(data, headers)
141+
body = MimeType[:json].encode data
142+
headers[Headers::CONTENT_TYPE] ||= "application/json; charset=#{body.encoding.name.downcase}"
143+
body
144+
end
145+
146+
# Coerce form data into an HTTP::FormData object
147+
# @return [HTTP::FormData::Multipart, HTTP::FormData::Urlencoded] form data
148+
# @api private
149+
def make_form_data(form)
150+
return form if form.is_a? HTTP::FormData::Multipart
151+
return form if form.is_a? HTTP::FormData::Urlencoded
152+
153+
HTTP::FormData.create(form)
154+
end
155+
end
156+
end
157+
end

lib/http/retriable/performer.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
require "openssl"
77

88
module HTTP
9+
# Retry logic for failed HTTP requests
910
module Retriable
1011
# Request performing watchdog.
1112
# @api private

0 commit comments

Comments
 (0)