Skip to content

Commit 9b44544

Browse files
committed
Make addressable a soft dependency, lazy-loaded for non-ASCII URIs
Move addressable from a runtime dependency to a development/optional dependency. It is now lazy-loaded via require_addressable only when parsing non-ASCII (IRI) URI strings or encoding internationalized hostnames via IDNA. ASCII-only URIs use Ruby's stdlib URI parser exclusively, with no addressable require at load time.
1 parent f5f8d9a commit 9b44544

7 files changed

Lines changed: 56 additions & 26 deletions

File tree

.mutant.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,5 @@ matcher:
2929
- HTTP::Base64*
3030
ignore:
3131
- HTTP::URI.parse_with_addressable
32+
- HTTP::URI.require_addressable
33+
- HTTP::URI.idna_to_ascii

CHANGELOG.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
`Response.new`, `Redirector.new`, `Retriable::Performer.new`,
1919
`Retriable::DelayCalculator.new`, and `Timeout::Null.new` (and subclasses).
2020
`HTTP::URI.new` also no longer accepts `Addressable::URI` objects.
21-
- Reimplement `HTTP::URI` with native component storage instead of delegating
22-
to `Addressable::URI`. ASCII URIs are now parsed with Ruby's stdlib `URI`,
23-
with Addressable used only as a fallback for non-ASCII (IRI) strings. This
24-
removes most of the runtime dependency on the `addressable` gem while
25-
preserving full URI functionality.
21+
- **BREAKING** `addressable` is no longer a runtime dependency. It is now
22+
lazy-loaded only when parsing non-ASCII (IRI) URIs or normalizing
23+
internationalized hostnames. Install the `addressable` gem if you need
24+
non-ASCII URI support. ASCII-only URIs use Ruby's stdlib `URI` parser
25+
exclusively.
2626
- **BREAKING** Extract request building into `HTTP::Request::Builder`. The
2727
`build_request` method has been removed from `Client`, `Session`, and the
2828
top-level `HTTP` module. Use `HTTP::Request::Builder.new(options).build(verb, uri)`

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ group :development do
1212
end
1313

1414
group :test do
15+
gem "addressable", "~> 2.8"
1516
gem "logger"
1617

1718
gem "rubocop", "~> 1.85"

http.gemspec

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ Gem::Specification.new do |spec|
3636

3737
spec.required_ruby_version = ">= 3.2"
3838

39-
spec.add_dependency "addressable", "~> 2.8"
4039
spec.add_dependency "http-cookie", "~> 1.0"
4140
spec.add_dependency "http-form_data", "~> 2.2"
4241

lib/http/uri.rb

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# frozen_string_literal: true
22

3-
require "addressable/uri"
43
require "uri"
54

65
module HTTP
@@ -260,7 +259,7 @@ def port
260259
# @api public
261260
# @return [Integer, nil] default port or nil for unknown schemes
262261
def default_port
263-
DEFAULT_PORTS[@scheme&.downcase] # steep:ignore
262+
DEFAULT_PORTS[@scheme&.downcase]
264263
end
265264

266265
# The origin (scheme + host + port) per RFC 6454
@@ -387,14 +386,14 @@ def dup
387386
def to_s # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
388387
str = +""
389388
str << "#{@scheme}:" if @scheme
390-
if @raw_host
389+
if (raw_host = @raw_host)
391390
str << "//"
392-
if @user
393-
str << @user # steep:ignore
391+
if (user = @user)
392+
str << user
394393
str << ":#{@password}" if @password
395394
str << "@"
396395
end
397-
str << @raw_host # steep:ignore
396+
str << raw_host
398397
str << ":#{@port}" if @port
399398
end
400399
str << @path
@@ -429,6 +428,30 @@ def deconstruct_keys(keys)
429428
keys ? hash.slice(*keys) : hash
430429
end
431430

431+
# Loads the addressable gem on first use
432+
#
433+
# @api private
434+
# @return [void]
435+
# @raise [LoadError] if addressable gem is not installed
436+
def self.require_addressable
437+
return if defined?(@addressable_loaded)
438+
439+
require "addressable/uri"
440+
@addressable_loaded = true
441+
end
442+
443+
# Convert a hostname to ASCII via IDNA (requires addressable)
444+
#
445+
# @param [String] host hostname to encode
446+
# @api private
447+
# @return [String] ASCII-encoded hostname
448+
def self.idna_to_ascii(host)
449+
return host if host.ascii_only?
450+
451+
require_addressable
452+
Addressable::IDNA.to_ascii(host) # steep:ignore
453+
end
454+
432455
private
433456

434457
# Adds or removes IPv6 brackets from a host
@@ -463,10 +486,10 @@ def process_ipv6_brackets(raw_host, brackets: false)
463486
def normalize_host(host)
464487
return nil unless host
465488

466-
h = host.gsub(/%(\h{2})/) { Regexp.last_match(1).to_i(16).chr } # steep:ignore
489+
h = host.gsub(/%\h{2}/) { |match| match.delete_prefix("%").to_i(16).chr }
467490
h = h.delete_suffix(".")
468491
h = h.downcase
469-
Addressable::IDNA.to_ascii(h) # steep:ignore
492+
self.class.idna_to_ascii(h)
470493
end
471494

472495
# Parse a URI string into component parts
@@ -507,11 +530,12 @@ def normalize_host(host)
507530
# @api private
508531
# @return [Hash] URI components
509532
private_class_method def self.parse_with_addressable(uri_string)
510-
parsed = Addressable::URI.parse(uri_string)
533+
require_addressable
534+
parsed = Addressable::URI.parse(uri_string) # steep:ignore
511535
{ scheme: parsed.scheme, user: parsed.user, password: parsed.password,
512536
host: parsed.host, port: parsed.port, path: parsed.path,
513537
query: parsed.query, fragment: parsed.fragment }
514-
rescue Addressable::URI::InvalidURIError
538+
rescue Addressable::URI::InvalidURIError # steep:ignore
515539
raise InvalidError, "invalid URI: #{uri_string.inspect}"
516540
end
517541
end

sig/http.rbs

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ module HTTP
5656
?body: untyped,
5757
?follow: bool | Hash[Symbol, untyped],
5858
?retriable: bool | Hash[Symbol, untyped],
59-
?base_uri: String | URI | nil,
59+
?base_uri: String | URI?,
6060
?persistent: String?,
6161
?ssl_context: OpenSSL::SSL::SSLContext?
6262
) -> Response
@@ -118,7 +118,7 @@ module HTTP
118118
?body: untyped,
119119
?follow: bool | Hash[Symbol, untyped],
120120
?retriable: bool | Hash[Symbol, untyped],
121-
?base_uri: String | URI | nil,
121+
?base_uri: String | URI?,
122122
?persistent: String?,
123123
?ssl_context: OpenSSL::SSL::SSLContext?
124124
) -> Response
@@ -167,7 +167,7 @@ module HTTP
167167
?body: untyped,
168168
?follow: bool | Hash[Symbol, untyped],
169169
?retriable: bool | Hash[Symbol, untyped],
170-
?base_uri: String | URI | nil,
170+
?base_uri: String | URI?,
171171
?persistent: String?,
172172
?ssl_context: OpenSSL::SSL::SSLContext?
173173
) -> Response
@@ -222,11 +222,11 @@ module HTTP
222222
def delete: (String | Symbol name) -> void
223223
def add: (String | Symbol name, String | Array[String] value) -> void
224224
def get: (String | Symbol name) -> Array[String]
225-
def []: (String | Symbol name) -> (String | Array[String] | nil)
225+
def []: (String | Symbol name) -> (String | Array[String]?)
226226
def include?: (String | Symbol name) -> bool
227-
def to_h: () -> Hash[String, String | Array[String] | nil]
227+
def to_h: () -> Hash[String, String | Array[String]?]
228228
alias to_hash to_h
229-
def deconstruct_keys: (Array[Symbol]? keys) -> Hash[Symbol, String | Array[String] | nil]
229+
def deconstruct_keys: (Array[Symbol]? keys) -> Hash[Symbol, String | Array[String]?]
230230
def to_a: () -> Array[Array[String]]
231231
def inspect: () -> String
232232
def keys: () -> Array[String]
@@ -491,7 +491,7 @@ module HTTP
491491
?body: untyped,
492492
?follow: bool | Hash[Symbol, untyped],
493493
?retriable: bool | Hash[Symbol, untyped],
494-
?base_uri: String | URI | nil,
494+
?base_uri: String | URI?,
495495
?persistent: String?,
496496
?ssl_context: OpenSSL::SSL::SSLContext?
497497
) -> void
@@ -501,7 +501,7 @@ module HTTP
501501

502502
def follow=: (bool value) -> void
503503
def retriable=: (bool value) -> void
504-
def base_uri=: (String | URI | nil value) -> void
504+
def base_uri=: (String | URI? value) -> void
505505
def base_uri?: () -> bool
506506
def persistent=: (String? value) -> String?
507507
def persistent?: () -> bool
@@ -569,6 +569,7 @@ module HTTP
569569

570570
@raw_host: String?
571571
@hash: Integer
572+
self.@addressable_loaded: bool
572573
@scheme: String?
573574
@user: String?
574575
@password: String?
@@ -592,7 +593,7 @@ module HTTP
592593
HTTPS_SCHEME: String
593594
PERCENT_ENCODE: Regexp
594595
NEEDS_ADDRESSABLE: Regexp
595-
DEFAULT_PORTS: Hash[String, Integer]
596+
DEFAULT_PORTS: Hash[String?, Integer]
596597
NORMALIZER: ^(String | URI) -> URI
597598

598599
DOT_SEGMENTS: Array[String]
@@ -632,6 +633,8 @@ module HTTP
632633
def self.parse_components: (String uri_string) -> Hash[Symbol, untyped]
633634
def self.parse_with_stdlib: (String uri_string) -> Hash[Symbol, untyped]?
634635
def self.parse_with_addressable: (String uri_string) -> Hash[Symbol, untyped]
636+
def self.require_addressable: () -> void
637+
def self.idna_to_ascii: (String host) -> String
635638
end
636639

637640
# Supported HTTP method verbs
@@ -802,7 +805,7 @@ module HTTP
802805
def readpartial: (?Integer size, ?String? outbuf) -> String
803806
def connection: () -> Connection?
804807
def uri: () -> URI
805-
def to_a: () -> [Integer, Hash[String, String | Array[String] | nil], String]
808+
def to_a: () -> [Integer, Hash[String, String | Array[String]?], String]
806809
alias deconstruct to_a
807810
def deconstruct_keys: (Array[Symbol]? keys) -> Hash[Symbol, Status | String | Headers | Body | Request?]
808811
def flush: () -> Response

test/http/uri_test.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "test_helper"
4+
require "addressable/uri"
45

56
describe HTTP::URI do
67
cover "HTTP::URI*"

0 commit comments

Comments
 (0)