Skip to content

Commit ab61883

Browse files
committed
More consistent implementation of Reference.
1 parent 6b70786 commit ab61883

File tree

14 files changed

+352
-69
lines changed

14 files changed

+352
-69
lines changed

guides/getting-started/readme.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Additionally, the {ruby Protocol::URL::Path} module provides low-level utilities
2525
Parse complete URLs with scheme and authority:
2626

2727
``` ruby
28-
require 'protocol/url'
28+
require "protocol/url"
2929

3030
# Parse an absolute URL:
3131
url = Protocol::URL["https://api.example.com:8080/v1/users?page=2#results"]
@@ -170,7 +170,7 @@ updated.to_s # => "/api/users?status=inactive#results"
170170
The library handles URL encoding automatically for path components:
171171

172172
``` ruby
173-
require 'protocol/url/encoding'
173+
require "protocol/url/encoding"
174174

175175
# Escape path components (preserves slashes):
176176
escaped = Protocol::URL::Encoding.escape_path("/path/with spaces/file.html")

guides/working-with-references/readme.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ You can create references in several ways:
1313
### Constructing from Components
1414

1515
``` ruby
16-
require 'protocol/url'
16+
require "protocol/url"
1717

1818
# Reference with path only:
1919
reference = Protocol::URL::Reference.new("/api/users")
@@ -125,9 +125,9 @@ You can update multiple components at once:
125125
base = Protocol::URL::Reference.new("/api/users")
126126

127127
modified = base.with(
128-
path: "posts",
129-
query: "author=john&status=published",
130-
fragment: "top"
128+
path: "posts",
129+
query: "author=john&status=published",
130+
fragment: "top"
131131
)
132132
modified.to_s # => "/api/posts?author=john&status=published#top"
133133
```

lib/protocol/url.rb

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,20 @@
1010
require_relative "url/absolute"
1111

1212
module Protocol
13-
# Helpers for working with URLs.
1413
module URL
1514
# RFC 3986 URI pattern with named capture groups.
1615
# Matches: [scheme:][//authority][path][?query][#fragment]
16+
# Rejects strings containing whitespace or control characters (matching standard URI behavior).
1717
PATTERN = %r{
1818
\A
1919
(?:(?<scheme>[a-z][a-z0-9+.-]*):)? # scheme (optional)
20-
(?<authority>//[^/?#]*)? # authority with // (optional)
21-
(?<path>[^?#]*) # path
22-
(?:\?(?<query>[^#]*))? # query (optional)
23-
(?:\#(?<fragment>.*))? # fragment (optional)
20+
(?://(?<authority>[^/?#\s]*))? # authority (optional, without //, no whitespace)
21+
(?<path>[^?#\s]*) # path (no whitespace)
22+
(?:\?(?<query>[^#\s]*))? # query (optional, no whitespace)
23+
(?:\#(?<fragment>[^\s]*))? # fragment (optional, no whitespace)
2424
\z
2525
}ix
26+
private_constant :PATTERN
2627

2728
# Coerce a value into an appropriate URL type (Absolute or Relative).
2829
#
@@ -38,12 +39,6 @@ def self.[](value)
3839
query = match[:query]
3940
fragment = match[:fragment]
4041

41-
# Strip the "//" prefix from authority
42-
authority = authority[2..-1] if authority
43-
44-
# Decode the fragment if present
45-
fragment = Encoding.unescape(fragment) if fragment
46-
4742
# If we have a scheme or authority, it's an absolute URL
4843
if scheme || authority
4944
Absolute.new(scheme, authority, path, query, fragment)
@@ -52,7 +47,7 @@ def self.[](value)
5247
Relative.new(path, query, fragment)
5348
end
5449
else
55-
raise ArgumentError, "Invalid URL: #{value}"
50+
raise ArgumentError, "Invalid URL (contains whitespace or control characters): #{value.inspect}"
5651
end
5752
when Relative
5853
value

lib/protocol/url/absolute.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ def initialize(scheme, authority, path = "/", query = nil, fragment = nil)
2121
attr :scheme
2222
attr :authority
2323

24+
def scheme?
25+
@scheme and !@scheme.empty?
26+
end
27+
28+
def authority?
29+
@authority and !@authority.empty?
30+
end
31+
2432
# Combine this absolute URL with a relative reference according to RFC 3986 Section 5.
2533
#
2634
# @parameter other [String, Relative, Reference, Absolute] The reference to resolve.
@@ -73,6 +81,29 @@ def append(buffer = String.new)
7381
super(buffer)
7482
end
7583

84+
UNSPECIFIED = Object.new
85+
86+
# Create a new Absolute URL with modified components.
87+
#
88+
# @parameter scheme [String, nil] The scheme to use (nil to remove scheme).
89+
# @parameter authority [String, nil] The authority to use (nil to remove authority).
90+
# @parameter path [String, nil] The path to merge with the current path.
91+
# @parameter query [String, nil] The query string to use.
92+
# @parameter fragment [String, nil] The fragment to use.
93+
# @parameter pop [Boolean] Whether to pop the last path component before merging.
94+
# @returns [Absolute] A new Absolute URL with the modified components.
95+
def with(scheme: @scheme, authority: @authority, path: nil, query: @query, fragment: @fragment, pop: true)
96+
self.class.new(scheme, authority, Path.expand(@path, path, pop), query, fragment)
97+
end
98+
99+
def to_ary
100+
[@scheme, @authority, @path, @query, @fragment]
101+
end
102+
103+
def <=>(other)
104+
to_ary <=> other.to_ary
105+
end
106+
76107
def to_s
77108
append
78109
end

lib/protocol/url/path.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def self.simplify(components)
6363
# @parameter pop [Boolean] whether to remove the last path component of the base path, to conform to URI merging behaviour, as defined by RFC2396.
6464
def self.expand(base, relative, pop = true)
6565
# Empty relative path means no change:
66-
return base if relative.empty?
66+
return base if relative.nil? || relative.empty?
6767

6868
components = split(base)
6969

lib/protocol/url/reference.rb

Lines changed: 38 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,33 @@ module URL
1414
class Reference < Relative
1515
include Comparable
1616

17-
# Generate a reference from a path and user parameters. The path may contain a `#fragment` or `?query=parameters`.
18-
def self.parse(value, parameters = nil)
19-
base, fragment = value.split("#", 2)
20-
path, query = base.split("?", 2)
21-
22-
self.new(path, query, fragment, parameters)
17+
def self.[](value, parameters = nil)
18+
case value
19+
when String
20+
if match = value.match(PATTERN)
21+
path = match[:path]
22+
query = match[:query]
23+
fragment = match[:fragment]
24+
25+
# Unescape path and fragment for user-friendly internal storage
26+
# Query strings are kept as-is since they contain = and & syntax
27+
path = Encoding.unescape(path) if path && !path.empty?
28+
fragment = Encoding.unescape(fragment) if fragment
29+
30+
self.new(path, query, fragment, parameters)
31+
else
32+
raise ArgumentError, "Invalid URL (contains whitespace or control characters): #{value.inspect}"
33+
end
34+
when Relative
35+
self.new(value.path, value.query, value.fragment, parameters)
36+
when nil
37+
nil
38+
else
39+
raise ArgumentError, "Cannot coerce #{value.inspect} to Reference!"
40+
end
41+
end # Generate a reference from a path and user parameters. The path may contain a `#fragment` or `?query=parameters`.
42+
def self.parse(value = "/", parameters = nil)
43+
self.[](value, parameters)
2344
end
2445

2546
# Initialize the reference.
@@ -59,18 +80,6 @@ def <=> other
5980
to_ary <=> other.to_ary
6081
end
6182

62-
# Type-cast a reference.
63-
#
64-
# @parameter reference [Reference | String] The reference to type-cast.
65-
# @returns [Reference] The type-casted reference.
66-
def self.[] reference
67-
if reference.is_a? self
68-
return reference
69-
else
70-
return self.parse(reference)
71-
end
72-
end
73-
7483
# @returns [Boolean] Whether the reference has parameters.
7584
def parameters?
7685
@parameters and !@parameters.empty?
@@ -108,14 +117,22 @@ def fragment?
108117
end
109118

110119
# Append the reference to the given buffer.
111-
private def append_query(buffer = String.new)
120+
# Encodes the path and fragment which are stored unescaped internally.
121+
# Query strings are passed through as-is (they contain = and & which are valid syntax).
122+
def append(buffer = String.new)
123+
buffer << Encoding.escape_path(@path)
124+
112125
if @query and !@query.empty?
113126
buffer << "?" << @query
114127
buffer << "&" << Encoding.encode(@parameters) if parameters?
115128
elsif parameters?
116129
buffer << "?" << Encoding.encode(@parameters)
117130
end
118131

132+
if @fragment and !@fragment.empty?
133+
buffer << "#" << Encoding.escape_fragment(@fragment)
134+
end
135+
119136
return buffer
120137
end
121138

@@ -143,7 +160,7 @@ def base
143160
# @parameter fragment [String] Set the fragment to this value.
144161
# @parameter pop [Boolean] If the path contains a trailing filename, pop the last component of the path before appending the new path.
145162
# @parameter merge [Boolean] If the parameters are specified, merge them with the existing parameters, otherwise replace them (including query string).
146-
def with(path: nil, parameters: false, fragment: @fragment, pop: false, merge: true)
163+
def with(path: nil, query: @query, fragment: @fragment, parameters: false, pop: false, merge: true)
147164
if merge
148165
# Merge mode: combine new parameters with existing, keep query:
149166
# parameters = (@parameters || {}).merge(parameters || {})
@@ -156,26 +173,18 @@ def with(path: nil, parameters: false, fragment: @fragment, pop: false, merge: t
156173
elsif !parameters
157174
parameters = @parameters
158175
end
159-
160-
query = @query
161176
else
162177
# Replace mode: use new parameters if provided, clear query when replacing:
163178
if parameters == false
164179
# No new parameters provided, keep existing:
165180
parameters = @parameters
166-
query = @query
167181
else
168182
# New parameters provided, replace and clear query:
169-
# parameters = parameters
170183
query = nil
171184
end
172185
end
173186

174-
if path
175-
path = Path.expand(@path, path, pop)
176-
else
177-
path = @path
178-
end
187+
path = Path.expand(@path, path, pop)
179188

180189
self.class.new(path, query, fragment, parameters)
181190
end

lib/protocol/url/relative.rb

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ module Protocol
1010
module URL
1111
# Represents a relative URL, which does not include a scheme or authority.
1212
class Relative
13+
include Comparable
14+
1315
def initialize(path, query = nil, fragment = nil)
1416
@path = path.to_s
1517
@query = query
@@ -20,6 +22,16 @@ def initialize(path, query = nil, fragment = nil)
2022
attr :query
2123
attr :fragment
2224

25+
# @returns [Boolean] If there is a query string.
26+
def query?
27+
@query and !@query.empty?
28+
end
29+
30+
# @returns [Boolean] If there is a fragment.
31+
def fragment?
32+
@fragment and !@fragment.empty?
33+
end
34+
2335
# Combine this relative URL with another URL or path.
2436
#
2537
# @parameter other [String, Absolute, Relative] The URL or path to combine.
@@ -45,26 +57,41 @@ def +(other)
4557
end
4658
end
4759

48-
private def append_query(buffer = String.new)
49-
if @query and !@query.empty?
50-
buffer << "?" << @query
51-
end
52-
return buffer
60+
# Create a new Relative URL with modified components.
61+
#
62+
# @parameter path [String, nil] The path to merge with the current path.
63+
# @parameter query [String, nil] The query string to use.
64+
# @parameter fragment [String, nil] The fragment to use.
65+
# @parameter pop [Boolean] Whether to pop the last path component before merging.
66+
# @returns [Relative] A new Relative URL with the modified components.
67+
def with(path: nil, query: @query, fragment: @fragment, pop: true)
68+
self.class.new(Path.expand(@path, path, pop), query, fragment)
5369
end
5470

5571
# Append the relative URL to the given buffer.
72+
# The path, query, and fragment are expected to already be properly encoded.
5673
def append(buffer = String.new)
57-
buffer << Encoding.escape_path(@path)
74+
buffer << @path
5875

59-
append_query(buffer)
76+
if @query and !@query.empty?
77+
buffer << "?" << @query
78+
end
6079

6180
if @fragment and !@fragment.empty?
62-
buffer << "#" << Encoding.escape_fragment(@fragment)
81+
buffer << "#" << @fragment
6382
end
6483

6584
return buffer
6685
end
6786

87+
def to_ary
88+
[@path, @query, @fragment]
89+
end
90+
91+
def <=>(other)
92+
to_ary <=> other.to_ary
93+
end
94+
6895
def to_s
6996
append
7097
end

lib/protocol/url/version.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
# Released under the MIT License.
44
# Copyright, 2025, by Samuel Williams.
55

6+
# @namespace
67
module Protocol
8+
# @namespace
79
module URL
810
VERSION = "0.0.0"
911
end

test/protocol/url.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,22 @@
4040
expect(url).to be_equal(relative)
4141
end
4242

43-
it "raises error for invalid input" do
43+
it "raises error for invalid input type" do
4444
expect do
4545
Protocol::URL[123]
4646
end.to raise_exception(ArgumentError, message: be =~ /Cannot coerce/)
4747
end
48+
49+
it "rejects strings with whitespace" do
50+
expect do
51+
Protocol::URL[" "]
52+
end.to raise_exception(ArgumentError, message: be =~ /Invalid URL.*whitespace/)
53+
end
54+
55+
it "rejects strings with control characters" do
56+
expect do
57+
Protocol::URL["\r\n"]
58+
end.to raise_exception(ArgumentError, message: be =~ /Invalid URL.*control characters/)
59+
end
4860
end
4961
end

0 commit comments

Comments
 (0)