Skip to content

Commit e782c86

Browse files
committed
Add HTTP.base_uri
Introduce a chainable `base_uri` option that resolves relative request paths against a configured base URI. Closes #519.
1 parent bac4366 commit e782c86

12 files changed

Lines changed: 412 additions & 7 deletions

File tree

CHANGELOG.md

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

2424
### Added
2525

26+
- `HTTP.base_uri` for setting a base URI that resolves relative request paths
27+
per RFC 3986. Supports chaining (`HTTP.base_uri("https://api.example.com/v1")
28+
.get("users")`), and integrates with `persistent` connections by deriving the
29+
host when omitted (#519, #512, #493)
2630
- `Feature#on_request` and `Feature#around_request` lifecycle hooks, called
2731
before/around each request attempt (including retries), for per-attempt side
2832
effects like instrumentation spans and circuit breakers (#826)

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,26 @@ end
126126
Pattern matching is also supported on `HTTP::Response::Status`, `HTTP::Headers`,
127127
`HTTP::ContentType`, and `HTTP::URI`.
128128

129+
### Base URI
130+
131+
Set a base URI to avoid repeating the scheme and host in every request:
132+
133+
```ruby
134+
api = HTTP.base_uri("https://api.example.com/v1")
135+
api.get("users") # GET https://api.example.com/v1/users
136+
api.get("users/1") # GET https://api.example.com/v1/users/1
137+
```
138+
139+
Relative paths are resolved per [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-5).
140+
Combine with `persistent` to reuse the connection:
141+
142+
```ruby
143+
HTTP.base_uri("https://api.example.com/v1").persistent do |http|
144+
http.get("users")
145+
http.get("posts")
146+
end
147+
```
148+
129149
### Thread Safety
130150

131151
Configured sessions are safe to share across threads:

lib/http/chainable.rb

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,18 +57,45 @@ def timeout(options)
5757
)
5858
end
5959

60+
# Set a base URI for resolving relative request paths
61+
#
62+
# The first call must use an absolute URI that includes a scheme
63+
# (e.g. "https://example.com"). Once a base URI is set, subsequent chained
64+
# calls may use relative paths that are resolved against the existing base.
65+
#
66+
# @example
67+
# HTTP.base_uri("https://example.com/api/v1").get("users")
68+
#
69+
# @example Chaining base URIs
70+
# HTTP.base_uri("https://example.com").base_uri("api/v1").get("users")
71+
#
72+
# @param [String, HTTP::URI] uri the base URI (absolute with scheme when
73+
# no base is set; may be relative when chaining)
74+
# @return [HTTP::Session]
75+
# @raise [HTTP::Error] if no base URI is set and the given URI has no scheme
76+
# @api public
77+
def base_uri(uri)
78+
branch default_options.with_base_uri(uri)
79+
end
80+
6081
# Open a persistent connection to a host
6182
#
83+
# When no host is given, the origin is derived from the configured base URI.
84+
#
6285
# @example
6386
# HTTP.persistent("http://example.com").get("/")
6487
#
65-
# @overload persistent(host, timeout: 5)
88+
# @example Derive host from base URI
89+
# HTTP.base_uri("https://example.com/api").persistent.get("users")
90+
#
91+
# @overload persistent(host = nil, timeout: 5)
6692
# Flags as persistent
67-
# @param [String] host
93+
# @param [String, nil] host connection origin (derived from base URI when nil)
6894
# @option [Integer] timeout Keep alive timeout
95+
# @raise [ArgumentError] if host is nil and no base URI is set
6996
# @raise [Request::Error] if Host is invalid
7097
# @return [HTTP::Client] Persistent client
71-
# @overload persistent(host, timeout: 5, &block)
98+
# @overload persistent(host = nil, timeout: 5, &block)
7299
# Executes given block with persistent client and automatically closes
73100
# connection at the end of execution.
74101
#
@@ -94,7 +121,10 @@ def timeout(options)
94121
# @return [Object] result of last expression in the block
95122
# @return [HTTP::Client, Object]
96123
# @api public
97-
def persistent(host, timeout: 5)
124+
def persistent(host = nil, timeout: 5)
125+
host ||= default_options.base_uri&.origin
126+
raise ArgumentError, "host is required for persistent connections" unless host
127+
98128
options = default_options.merge(keep_alive_timeout: timeout).with_persistent(host)
99129
p_client = make_client(options)
100130
return p_client unless block_given?

lib/http/options.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ def initialize(
150150
body: nil,
151151
follow: nil,
152152
retriable: nil,
153+
base_uri: nil,
153154
persistent: nil,
154155
ssl_context: nil
155156
)

lib/http/options/definitions.rb

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,30 @@ def retriable=(value)
9090
end
9191
end
9292

93+
def_option :base_uri, reader_only: true
94+
95+
# Sets the base URI for resolving relative request paths
96+
#
97+
# @param [String, HTTP::URI, nil] value
98+
# @api private
99+
# @return [HTTP::URI, nil]
100+
def base_uri=(value)
101+
@base_uri = value ? parse_base_uri(value) : nil
102+
validate_base_uri_and_persistent!
103+
end
104+
105+
# Checks whether a base URI is set
106+
#
107+
# @example
108+
# opts = HTTP::Options.new(base_uri: "https://example.com")
109+
# opts.base_uri?
110+
#
111+
# @api public
112+
# @return [Boolean]
113+
def base_uri?
114+
!base_uri.nil?
115+
end
116+
93117
def_option :persistent, reader_only: true
94118

95119
# Sets persistent connection origin
@@ -99,6 +123,7 @@ def retriable=(value)
99123
# @return [String, nil]
100124
def persistent=(value)
101125
@persistent = value ? HTTP::URI.parse(value).origin : nil
126+
validate_base_uri_and_persistent!
102127
end
103128

104129
# Checks whether persistent connection is enabled
@@ -112,5 +137,53 @@ def persistent=(value)
112137
def persistent?
113138
!persistent.nil?
114139
end
140+
141+
private
142+
143+
# Parses and validates a base URI value
144+
#
145+
# @param [String, HTTP::URI] value the base URI to parse
146+
# @api private
147+
# @return [HTTP::URI]
148+
def parse_base_uri(value)
149+
uri = HTTP::URI.parse(value)
150+
151+
base = @base_uri
152+
return resolve_base_uri(base, uri) if base
153+
154+
argument_error!(format("Invalid base URI: %s", value)) unless uri.scheme
155+
uri
156+
end
157+
158+
# Resolves a relative URI against an existing base URI
159+
#
160+
# @param [HTTP::URI] base the existing base URI
161+
# @param [HTTP::URI] relative the URI to join
162+
# @api private
163+
# @return [HTTP::URI]
164+
def resolve_base_uri(base, relative)
165+
unless base.path.end_with?("/")
166+
base = base.dup
167+
base.path = "#{base.path}/"
168+
end
169+
170+
HTTP::URI.parse(base.join(relative))
171+
end
172+
173+
# Validates that base URI and persistent origin are compatible
174+
#
175+
# @api private
176+
# @return [void]
177+
def validate_base_uri_and_persistent!
178+
base = @base_uri
179+
persistent = @persistent
180+
return unless base && persistent
181+
return if base.origin == persistent
182+
183+
argument_error!(
184+
format("Persistence origin (%s) conflicts with base URI origin (%s)",
185+
persistent, base.origin)
186+
)
187+
end
115188
end
116189
end

lib/http/request/builder.rb

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,11 @@ def wrap(request)
8181
def make_request_uri(uri)
8282
uri = uri.to_s
8383

84-
uri = "#{@options.persistent}#{uri}" if @options.persistent? && uri !~ HTTP_OR_HTTPS_RE
84+
if @options.base_uri? && uri !~ HTTP_OR_HTTPS_RE
85+
uri = resolve_against_base(uri)
86+
elsif @options.persistent? && uri !~ HTTP_OR_HTTPS_RE
87+
uri = "#{@options.persistent}#{uri}"
88+
end
8589

8690
uri = HTTP::URI.parse uri
8791

@@ -95,6 +99,26 @@ def make_request_uri(uri)
9599
uri
96100
end
97101

102+
# Resolve a relative URI against the configured base URI
103+
#
104+
# Ensures the base URI path has a trailing slash so that relative
105+
# paths are appended rather than replacing the last path segment,
106+
# per the convention described in RFC 3986 Section 5.
107+
#
108+
# @param uri [String] the relative URI to resolve
109+
# @return [String] the resolved absolute URI
110+
# @api private
111+
def resolve_against_base(uri)
112+
base = @options.base_uri or raise Error, "base_uri is not set"
113+
114+
unless base.path.end_with?("/")
115+
base = base.dup
116+
base.path = "#{base.path}/"
117+
end
118+
119+
String(base.join(uri))
120+
end
121+
98122
# Merge query parameters into URI
99123
#
100124
# @return [void]

sig/http.rbs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,14 @@ module HTTP
5656
?body: untyped,
5757
?follow: bool,
5858
?retriable: bool,
59+
?base_uri: String | URI | nil,
5960
?persistent: String?,
6061
?ssl_context: OpenSSL::SSL::SSLContext?
6162
) -> Response
6263
def timeout: (Numeric | Hash[Symbol, Numeric] | :null options) -> Session
63-
def persistent: (String host, ?timeout: Integer) -> Client
64-
| (String host, ?timeout: Integer) { (Client) -> void } -> void
64+
def base_uri: (String | URI uri) -> Session
65+
def persistent: (?String? host, ?timeout: Integer) -> Client
66+
| (?String? host, ?timeout: Integer) { (Client) -> void } -> void
6567
def via: (*(String | Integer | Hash[String, String]) proxy) -> Session
6668
alias through via
6769
def follow: (?strict: bool, ?max_hops: Integer, ?on_redirect: (^(Response, Request) -> void)?) -> Session
@@ -116,6 +118,7 @@ module HTTP
116118
?body: untyped,
117119
?follow: bool,
118120
?retriable: bool,
121+
?base_uri: String | URI | nil,
119122
?persistent: String?,
120123
?ssl_context: OpenSSL::SSL::SSLContext?
121124
) -> Response
@@ -164,6 +167,7 @@ module HTTP
164167
?body: untyped,
165168
?follow: bool,
166169
?retriable: bool,
170+
?base_uri: String | URI | nil,
167171
?persistent: String?,
168172
?ssl_context: OpenSSL::SSL::SSLContext?
169173
) -> Response
@@ -495,6 +499,7 @@ module HTTP
495499
?body: untyped,
496500
?follow: bool,
497501
?retriable: bool,
502+
?base_uri: String | URI | nil,
498503
?persistent: String?,
499504
?ssl_context: OpenSSL::SSL::SSLContext?
500505
) -> void
@@ -504,6 +509,8 @@ module HTTP
504509

505510
def follow=: (bool value) -> void
506511
def retriable=: (bool value) -> void
512+
def base_uri=: (String | URI | nil value) -> void
513+
def base_uri?: () -> bool
507514
def persistent=: (String? value) -> String?
508515
def persistent?: () -> bool
509516
def merge: (Hash[Symbol, untyped] | Options other) -> Options
@@ -530,13 +537,15 @@ module HTTP
530537
attr_accessor encoding: Encoding?
531538
attr_reader follow: Hash[Symbol, untyped]?
532539
attr_reader retriable: Hash[Symbol, untyped]?
540+
attr_reader base_uri: URI?
533541
attr_reader persistent: String?
534542

535543
def with_headers: (Hash[String | Symbol, untyped] | Headers value) -> Options
536544
def with_encoding: (String | Encoding value) -> Options
537545
def with_features: (Array[Symbol | Hash[Symbol, untyped]] value) -> Options
538546
def with_follow: (Hash[Symbol, untyped] | bool value) -> Options
539547
def with_retriable: (Hash[Symbol, untyped] | bool value) -> Options
548+
def with_base_uri: (String | URI value) -> Options
540549
def with_persistent: (String value) -> Options
541550
def with_proxy: (Hash[Symbol, untyped] value) -> Options
542551
def with_params: (Hash[String | Symbol, untyped] value) -> Options
@@ -557,6 +566,9 @@ module HTTP
557566

558567
def assign_options: (Binding env) -> void
559568
def argument_error!: (String message) -> bot
569+
def parse_base_uri: (String | URI value) -> URI
570+
def resolve_base_uri: (URI base, URI relative) -> URI
571+
def validate_base_uri_and_persistent!: () -> void
560572
end
561573

562574
class URI
@@ -651,6 +663,7 @@ module HTTP
651663
private
652664

653665
def make_request_uri: (String | URI uri) -> URI
666+
def resolve_against_base: (String uri) -> String
654667
def merge_query_params!: (URI uri) -> void
655668
def make_request_headers: () -> Headers
656669
def make_request_body: (Headers headers) -> untyped

test/http/client_test.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,56 @@ def simple_response(body, status = 200)
160160
end
161161
end
162162

163+
describe "base_uri" do
164+
it "resolves relative paths against base URI" do
165+
client = StubbedClient.new(base_uri: "https://example.com/api").stub(
166+
"https://example.com/api/users" => simple_response("OK")
167+
)
168+
169+
assert_equal "OK", client.get("users").to_s
170+
end
171+
172+
it "resolves absolute paths from host root" do
173+
client = StubbedClient.new(base_uri: "https://example.com/api").stub(
174+
"https://example.com/users" => simple_response("OK")
175+
)
176+
177+
assert_equal "OK", client.get("/users").to_s
178+
end
179+
180+
it "ignores base_uri for absolute URLs" do
181+
client = StubbedClient.new(base_uri: "https://example.com/api").stub(
182+
"https://other.com/path" => simple_response("OK")
183+
)
184+
185+
assert_equal "OK", client.get("https://other.com/path").to_s
186+
end
187+
188+
it "handles parent path traversal" do
189+
client = StubbedClient.new(base_uri: "https://example.com/api/v1").stub(
190+
"https://example.com/api/v2" => simple_response("OK")
191+
)
192+
193+
assert_equal "OK", client.get("../v2").to_s
194+
end
195+
196+
it "handles base URI without trailing slash" do
197+
client = StubbedClient.new(base_uri: "https://example.com/api").stub(
198+
"https://example.com/api/users" => simple_response("OK")
199+
)
200+
201+
assert_equal "OK", client.get("users").to_s
202+
end
203+
204+
it "handles base URI with trailing slash" do
205+
client = StubbedClient.new(base_uri: "https://example.com/api/").stub(
206+
"https://example.com/api/users" => simple_response("OK")
207+
)
208+
209+
assert_equal "OK", client.get("users").to_s
210+
end
211+
end
212+
163213
describe "parsing params" do
164214
let(:client) { HTTP::Client.new }
165215

0 commit comments

Comments
 (0)