protocol-url 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +3 -0
- data/lib/protocol/url/absolute.rb +134 -0
- data/lib/protocol/url/encoding.rb +238 -0
- data/lib/protocol/url/path.rb +155 -0
- data/lib/protocol/url/reference.rb +238 -0
- data/lib/protocol/url/relative.rb +151 -0
- data/lib/protocol/url/version.rb +12 -0
- data/lib/protocol/url.rb +61 -0
- data/license.md +21 -0
- data/readme.md +43 -0
- data/releases.md +5 -0
- data.tar.gz.sig +0 -0
- metadata +77 -0
- metadata.gz.sig +0 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 85a50d979d385fbef9e8defa4e94f98832aca156dc86f79352a4b62673f30e2e
|
|
4
|
+
data.tar.gz: 71ea54c2460fc94773c13c8cc334f66fef89974efd71bd89537487f0833b844e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: '0903cee8e33682b465b633a7bbdb2b34e52800b476cf550a06c2fec5cc328532cfd1dac820cfafe31a76500982f92cbbd566af9168ede9cd1e6ccdd9d9b72887'
|
|
7
|
+
data.tar.gz: 7c12517bafaa34bae712abacfe299231a858a12d9f9b37dd188f3275e20008bf116fab85bb53843a55834978866365444d210b93d51ea2e05b7a2d2a9a35ae1a
|
checksums.yaml.gz.sig
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "relative"
|
|
7
|
+
|
|
8
|
+
module Protocol
|
|
9
|
+
module URL
|
|
10
|
+
# Represents an absolute URL with scheme and/or authority.
|
|
11
|
+
# Examples: "https://example.com/path", "//cdn.example.com/lib.js", "http://localhost/"
|
|
12
|
+
class Absolute < Relative
|
|
13
|
+
def initialize(scheme, authority, path = "/", query = nil, fragment = nil)
|
|
14
|
+
@scheme = scheme
|
|
15
|
+
@authority = authority
|
|
16
|
+
|
|
17
|
+
# Initialize the parent Relative class with the path component
|
|
18
|
+
super(path, query, fragment)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr :scheme
|
|
22
|
+
attr :authority
|
|
23
|
+
|
|
24
|
+
def scheme?
|
|
25
|
+
@scheme and !@scheme.empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def authority?
|
|
29
|
+
@authority and !@authority.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Combine this absolute URL with a relative reference according to RFC 3986 Section 5.
|
|
33
|
+
#
|
|
34
|
+
# @parameter other [String, Relative, Reference, Absolute] The reference to resolve.
|
|
35
|
+
# @returns [Absolute, String] The resolved absolute URL.
|
|
36
|
+
#
|
|
37
|
+
# @example Resolve a relative path.
|
|
38
|
+
# base = Absolute.new("https", "example.com", "/documents/reports/")
|
|
39
|
+
# relative = Relative.new("summary.pdf")
|
|
40
|
+
# result = base + relative
|
|
41
|
+
# result.to_s # => "https://example.com/documents/reports/summary.pdf"
|
|
42
|
+
#
|
|
43
|
+
# @example Navigate to parent directory.
|
|
44
|
+
# base = Absolute.new("https", "example.com", "/documents/reports/2024/")
|
|
45
|
+
# relative = Relative.new("../../archive/")
|
|
46
|
+
# result = base + relative
|
|
47
|
+
# result.to_s # => "https://example.com/documents/archive/"
|
|
48
|
+
def +(other)
|
|
49
|
+
case other
|
|
50
|
+
when Absolute
|
|
51
|
+
# If other is already absolute with a scheme, return it as-is:
|
|
52
|
+
return other if other.scheme
|
|
53
|
+
# Protocol-relative URL: inherit scheme from base:
|
|
54
|
+
return Absolute.new(@scheme, other.authority, other.path, other.query, other.fragment)
|
|
55
|
+
when Relative
|
|
56
|
+
# Already a Relative, use directly.
|
|
57
|
+
when String
|
|
58
|
+
other = URL[other]
|
|
59
|
+
# If parsing resulted in an Absolute URL, handle it:
|
|
60
|
+
if other.is_a?(Absolute)
|
|
61
|
+
return other if other.scheme
|
|
62
|
+
# Protocol-relative URL: inherit scheme from base:
|
|
63
|
+
return Absolute.new(@scheme, other.authority, other.path, other.query, other.fragment)
|
|
64
|
+
end
|
|
65
|
+
else
|
|
66
|
+
raise ArgumentError, "Cannot combine Absolute URL with #{other.class}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# RFC 3986 Section 5.3: Component Recomposition
|
|
70
|
+
# At this point, other is a Relative URL
|
|
71
|
+
|
|
72
|
+
# Check for special cases first:
|
|
73
|
+
if other.path.empty?
|
|
74
|
+
# Empty path - could be query-only or fragment-only reference:
|
|
75
|
+
if other.query
|
|
76
|
+
# Query replacement: use base path with new query:
|
|
77
|
+
Absolute.new(@scheme, @authority, @path, other.query, other.fragment)
|
|
78
|
+
else
|
|
79
|
+
# Fragment-only: keep everything from base, just change fragment:
|
|
80
|
+
Absolute.new(@scheme, @authority, @path, @query, other.fragment || @fragment)
|
|
81
|
+
end
|
|
82
|
+
else
|
|
83
|
+
# Relative path: merge with base path:
|
|
84
|
+
path = Path.expand(@path, other.path)
|
|
85
|
+
Absolute.new(@scheme, @authority, path, other.query, other.fragment)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Append the absolute URL to the given buffer.
|
|
90
|
+
def append(buffer = String.new)
|
|
91
|
+
buffer << @scheme << ":" if @scheme
|
|
92
|
+
buffer << "//" << @authority if @authority
|
|
93
|
+
super(buffer)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
UNSPECIFIED = Object.new
|
|
97
|
+
|
|
98
|
+
# Create a new Absolute URL with modified components.
|
|
99
|
+
#
|
|
100
|
+
# @parameter scheme [String, nil] The scheme to use (nil to remove scheme).
|
|
101
|
+
# @parameter authority [String, nil] The authority to use (nil to remove authority).
|
|
102
|
+
# @parameter path [String, nil] The path to merge with the current path.
|
|
103
|
+
# @parameter query [String, nil] The query string to use.
|
|
104
|
+
# @parameter fragment [String, nil] The fragment to use.
|
|
105
|
+
# @parameter pop [Boolean] Whether to pop the last path component before merging.
|
|
106
|
+
# @returns [Absolute] A new Absolute URL with the modified components.
|
|
107
|
+
#
|
|
108
|
+
# @example Change the scheme.
|
|
109
|
+
# url = Absolute.new("http", "example.com", "/page")
|
|
110
|
+
# secure = url.with(scheme: "https")
|
|
111
|
+
# secure.to_s # => "https://example.com/page"
|
|
112
|
+
#
|
|
113
|
+
# @example Update the query string.
|
|
114
|
+
# url = Absolute.new("https", "example.com", "/search", "query=ruby")
|
|
115
|
+
# updated = url.with(query: "query=python")
|
|
116
|
+
# updated.to_s # => "https://example.com/search?query=python"
|
|
117
|
+
def with(scheme: @scheme, authority: @authority, path: nil, query: @query, fragment: @fragment, pop: true)
|
|
118
|
+
self.class.new(scheme, authority, Path.expand(@path, path, pop), query, fragment)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def to_ary
|
|
122
|
+
[@scheme, @authority, @path, @query, @fragment]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def <=>(other)
|
|
126
|
+
to_ary <=> other.to_ary
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def to_s
|
|
130
|
+
append
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
module Protocol
|
|
7
|
+
module URL
|
|
8
|
+
# Helpers for encoding and decoding URL components.
|
|
9
|
+
module Encoding
|
|
10
|
+
# Escapes a string using percent encoding, e.g. `a b` -> `a%20b`.
|
|
11
|
+
#
|
|
12
|
+
# @parameter string [String] The string to escape.
|
|
13
|
+
# @returns [String] The escaped string.
|
|
14
|
+
#
|
|
15
|
+
# @example Escape spaces and special characters.
|
|
16
|
+
# Encoding.escape("hello world!")
|
|
17
|
+
# # => "hello%20world%21"
|
|
18
|
+
#
|
|
19
|
+
# @example Escape unicode characters.
|
|
20
|
+
# Encoding.escape("café")
|
|
21
|
+
# # => "caf%C3%A9"
|
|
22
|
+
def self.escape(string, encoding = string.encoding)
|
|
23
|
+
string.b.gsub(/([^a-zA-Z0-9_.\-]+)/) do |m|
|
|
24
|
+
"%" + m.unpack("H2" * m.bytesize).join("%").upcase
|
|
25
|
+
end.force_encoding(encoding)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Unescapes a percent encoded string, e.g. `a%20b` -> `a b`.
|
|
29
|
+
#
|
|
30
|
+
# @parameter string [String] The string to unescape.
|
|
31
|
+
# @returns [String] The unescaped string.
|
|
32
|
+
#
|
|
33
|
+
# @example Unescape spaces and special characters.
|
|
34
|
+
# Encoding.unescape("hello%20world%21")
|
|
35
|
+
# # => "hello world!"
|
|
36
|
+
#
|
|
37
|
+
# @example Unescape unicode characters.
|
|
38
|
+
# Encoding.unescape("caf%C3%A9")
|
|
39
|
+
# # => "café"
|
|
40
|
+
def self.unescape(string, encoding = string.encoding)
|
|
41
|
+
string.b.gsub(/%(\h\h)/) do |hex|
|
|
42
|
+
Integer($1, 16).chr
|
|
43
|
+
end.force_encoding(encoding)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Unescapes a percent encoded path component, preserving encoded path separators.
|
|
47
|
+
#
|
|
48
|
+
# This method unescapes percent-encoded characters except for path separators
|
|
49
|
+
# (forward slash `/` and backslash `\`). This prevents encoded separators like
|
|
50
|
+
# `%2F` or `%5C` from being decoded into actual path separators, which could
|
|
51
|
+
# allow bypassing path component boundaries.
|
|
52
|
+
#
|
|
53
|
+
# @parameter string [String] The path component to unescape.
|
|
54
|
+
# @returns [String] The unescaped string with separators still encoded.
|
|
55
|
+
#
|
|
56
|
+
# @example
|
|
57
|
+
# Encoding.unescape_path("hello%20world") # => "hello world"
|
|
58
|
+
# Encoding.unescape_path("safe%2Fname") # => "safe%2Fname" (%2F not decoded)
|
|
59
|
+
# Encoding.unescape_path("name%5Cfile") # => "name%5Cfile" (%5C not decoded)
|
|
60
|
+
def self.unescape_path(string, encoding = string.encoding)
|
|
61
|
+
string.b.gsub(/%(\h\h)/) do |hex|
|
|
62
|
+
byte = Integer($1, 16)
|
|
63
|
+
char = byte.chr
|
|
64
|
+
|
|
65
|
+
# Don't decode forward slash (0x2F) or backslash (0x5C)
|
|
66
|
+
if byte == 0x2F || byte == 0x5C
|
|
67
|
+
hex # Keep as %2F or %5C
|
|
68
|
+
else
|
|
69
|
+
char
|
|
70
|
+
end
|
|
71
|
+
end.force_encoding(encoding)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Matches characters that are not allowed in a URI path segment. According to RFC 3986 Section 3.3 (https://tools.ietf.org/html/rfc3986#section-3.3), a valid path segment consists of "pchar" characters. This pattern identifies characters that must be percent-encoded when included in a URI path segment.
|
|
75
|
+
NON_PATH_CHARACTER_PATTERN = /([^a-zA-Z0-9_\-\.~!$&'()*+,;=:@\/]+)/.freeze
|
|
76
|
+
|
|
77
|
+
# Matches characters that are not allowed in a URI fragment. According to RFC 3986 Section 3.5, a valid fragment consists of pchar / "/" / "?" characters.
|
|
78
|
+
NON_FRAGMENT_CHARACTER_PATTERN = /([^a-zA-Z0-9_\-\.~!$&'()*+,;=:@\/\?]+)/.freeze
|
|
79
|
+
|
|
80
|
+
# Escapes non-path characters using percent encoding. In other words, this method escapes characters that are not allowed in a URI path segment. According to RFC 3986 Section 3.3 (https://tools.ietf.org/html/rfc3986#section-3.3), a valid path segment consists of "pchar" characters. This method percent-encodes characters that are not "pchar" characters.
|
|
81
|
+
#
|
|
82
|
+
# @parameter path [String] The path to escape.
|
|
83
|
+
# @returns [String] The escaped path.
|
|
84
|
+
#
|
|
85
|
+
# @example Escape spaces while preserving path separators.
|
|
86
|
+
# Encoding.escape_path("/documents/my reports/summary.pdf")
|
|
87
|
+
# # => "/documents/my%20reports/summary.pdf"
|
|
88
|
+
def self.escape_path(path)
|
|
89
|
+
encoding = path.encoding
|
|
90
|
+
path.b.gsub(NON_PATH_CHARACTER_PATTERN) do |m|
|
|
91
|
+
"%" + m.unpack("H2" * m.bytesize).join("%").upcase
|
|
92
|
+
end.force_encoding(encoding)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Escapes non-fragment characters using percent encoding. According to RFC 3986 Section 3.5, fragments can contain pchar / "/" / "?" characters.
|
|
96
|
+
#
|
|
97
|
+
# @parameter fragment [String] The fragment to escape.
|
|
98
|
+
# @returns [String] The escaped fragment.
|
|
99
|
+
def self.escape_fragment(fragment)
|
|
100
|
+
encoding = fragment.encoding
|
|
101
|
+
fragment.b.gsub(NON_FRAGMENT_CHARACTER_PATTERN) do |m|
|
|
102
|
+
"%" + m.unpack("H2" * m.bytesize).join("%").upcase
|
|
103
|
+
end.force_encoding(encoding)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Encodes a hash or array into a query string. This method is used to encode query parameters in a URL. For example, `{"a" => 1, "b" => 2}` is encoded as `a=1&b=2`.
|
|
107
|
+
#
|
|
108
|
+
# @parameter value [Hash | Array | Nil] The value to encode.
|
|
109
|
+
# @parameter prefix [String] The prefix to use for keys.
|
|
110
|
+
#
|
|
111
|
+
# @example Encode simple parameters.
|
|
112
|
+
# Encoding.encode({"name" => "Alice", "age" => "30"})
|
|
113
|
+
# # => "name=Alice&age=30"
|
|
114
|
+
#
|
|
115
|
+
# @example Encode nested parameters.
|
|
116
|
+
# Encoding.encode({"user" => {"name" => "Alice", "role" => "admin"}})
|
|
117
|
+
# # => "user[name]=Alice&user[role]=admin"
|
|
118
|
+
def self.encode(value, prefix = nil)
|
|
119
|
+
case value
|
|
120
|
+
when Array
|
|
121
|
+
return value.map {|v|
|
|
122
|
+
self.encode(v, "#{prefix}[]")
|
|
123
|
+
}.join("&")
|
|
124
|
+
when Hash
|
|
125
|
+
return value.map {|k, v|
|
|
126
|
+
self.encode(v, prefix ? "#{prefix}[#{escape(k.to_s)}]" : escape(k.to_s))
|
|
127
|
+
}.reject(&:empty?).join("&")
|
|
128
|
+
when nil
|
|
129
|
+
return prefix
|
|
130
|
+
else
|
|
131
|
+
raise ArgumentError, "value must be a Hash" if prefix.nil?
|
|
132
|
+
|
|
133
|
+
return "#{prefix}=#{escape(value.to_s)}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Scan a string for URL-encoded key/value pairs.
|
|
138
|
+
# @yields {|key, value| ...}
|
|
139
|
+
# @parameter key [String] The unescaped key.
|
|
140
|
+
# @parameter value [String] The unescaped key.
|
|
141
|
+
def self.scan(string)
|
|
142
|
+
string.split("&") do |assignment|
|
|
143
|
+
next if assignment.empty?
|
|
144
|
+
|
|
145
|
+
key, value = assignment.split("=", 2)
|
|
146
|
+
|
|
147
|
+
yield unescape(key), value.nil? ? value : unescape(value)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Split a key into parts, e.g. `a[b][c]` -> `["a", "b", "c"]`.
|
|
152
|
+
#
|
|
153
|
+
# @parameter name [String] The key to split.
|
|
154
|
+
# @returns [Array(String)] The parts of the key.
|
|
155
|
+
def self.split(name)
|
|
156
|
+
name.scan(/([^\[]+)|(?:\[(.*?)\])/)&.tap do |parts|
|
|
157
|
+
parts.flatten!
|
|
158
|
+
parts.compact!
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Assign a value to a nested hash.
|
|
163
|
+
#
|
|
164
|
+
# This method handles building nested data structures from query string parameters, including arrays of objects. When processing array elements (empty key like `[]`), it intelligently decides whether to add to the last array element or create a new one.
|
|
165
|
+
#
|
|
166
|
+
# @parameter keys [Array(String)] The parts of the key.
|
|
167
|
+
# @parameter value [Object] The value to assign.
|
|
168
|
+
# @parameter parent [Hash] The parent hash.
|
|
169
|
+
#
|
|
170
|
+
# @example Building an array of objects.
|
|
171
|
+
# # Query: items[][name]=a&items[][value]=1&items[][name]=b&items[][value]=2
|
|
172
|
+
# # When "name" appears again, it creates a new array element
|
|
173
|
+
# # Result: {"items" => [{"name" => "a", "value" => "1"}, {"name" => "b", "value" => "2"}]}
|
|
174
|
+
def self.assign(keys, value, parent)
|
|
175
|
+
top, *middle = keys
|
|
176
|
+
|
|
177
|
+
middle.each_with_index do |key, index|
|
|
178
|
+
if key.nil? or key.empty?
|
|
179
|
+
# Array element (e.g., items[]):
|
|
180
|
+
parent = (parent[top] ||= Array.new)
|
|
181
|
+
top = parent.size
|
|
182
|
+
|
|
183
|
+
# Check if we should reuse the last array element or create a new one. If there's a nested key coming next, and the last array element already has that key, then we need a new array element. Otherwise, add to the existing one.
|
|
184
|
+
if nested = middle[index+1] and last = parent.last
|
|
185
|
+
# If the last element doesn't include the nested key, reuse it (decrement index).
|
|
186
|
+
# If it does include the key, keep current index (creates new element).
|
|
187
|
+
top -= 1 unless last.include?(nested)
|
|
188
|
+
end
|
|
189
|
+
else
|
|
190
|
+
# Hash key (e.g., user[name]):
|
|
191
|
+
parent = (parent[top] ||= Hash.new)
|
|
192
|
+
top = key
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
parent[top] = value
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Decode a URL-encoded query string into a hash.
|
|
200
|
+
#
|
|
201
|
+
# @parameter string [String] The query string to decode.
|
|
202
|
+
# @parameter maximum [Integer] The maximum number of keys in a path.
|
|
203
|
+
# @parameter symbolize_keys [Boolean] Whether to symbolize keys.
|
|
204
|
+
# @returns [Hash] The decoded query string.
|
|
205
|
+
#
|
|
206
|
+
# @example Decode simple parameters.
|
|
207
|
+
# Encoding.decode("name=Alice&age=30")
|
|
208
|
+
# # => {"name" => "Alice", "age" => "30"}
|
|
209
|
+
#
|
|
210
|
+
# @example Decode nested parameters.
|
|
211
|
+
# Encoding.decode("user[name]=Alice&user[role]=admin")
|
|
212
|
+
# # => {"user" => {"name" => "Alice", "role" => "admin"}}
|
|
213
|
+
def self.decode(string, maximum = 8, symbolize_keys: false)
|
|
214
|
+
parameters = {}
|
|
215
|
+
|
|
216
|
+
self.scan(string) do |name, value|
|
|
217
|
+
keys = self.split(name)
|
|
218
|
+
|
|
219
|
+
if keys.empty?
|
|
220
|
+
raise ArgumentError, "Invalid key path: #{name.inspect}!"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
if keys.size > maximum
|
|
224
|
+
raise ArgumentError, "Key length exceeded limit!"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
if symbolize_keys
|
|
228
|
+
keys.collect!{|key| key.empty? ? nil : key.to_sym}
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
self.assign(keys, value, parameters)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
return parameters
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "encoding"
|
|
7
|
+
|
|
8
|
+
module Protocol
|
|
9
|
+
module URL
|
|
10
|
+
# Represents a relative URL, which does not include a scheme or authority.
|
|
11
|
+
module Path
|
|
12
|
+
# Split the given path into its components.
|
|
13
|
+
#
|
|
14
|
+
# - `split("")` => `[]`
|
|
15
|
+
# - `split("/")` => `["", ""]`
|
|
16
|
+
# - `split("/a/b/c")` => `["", "a", "b", "c"]`
|
|
17
|
+
# - `split("a/b/c/")` => `["a", "b", "c", ""]`
|
|
18
|
+
#
|
|
19
|
+
# @parameter path [String] The path to split.
|
|
20
|
+
# @returns [Array(String)] The path components.
|
|
21
|
+
#
|
|
22
|
+
# @example Split an absolute path.
|
|
23
|
+
# Path.split("/documents/report.pdf")
|
|
24
|
+
# # => ["", "documents", "report.pdf"]
|
|
25
|
+
#
|
|
26
|
+
# @example Split a relative path.
|
|
27
|
+
# Path.split("images/logo.png")
|
|
28
|
+
# # => ["images", "logo.png"]
|
|
29
|
+
def self.split(path)
|
|
30
|
+
return path.split("/", -1)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Join the given path components into a single path.
|
|
34
|
+
#
|
|
35
|
+
# @parameter components [Array(String)] The path components to join.
|
|
36
|
+
# @returns [String] The joined path.
|
|
37
|
+
#
|
|
38
|
+
# @example Join absolute path components.
|
|
39
|
+
# Path.join(["", "documents", "report.pdf"])
|
|
40
|
+
# # => "/documents/report.pdf"
|
|
41
|
+
#
|
|
42
|
+
# @example Join relative path components.
|
|
43
|
+
# Path.join(["images", "logo.png"])
|
|
44
|
+
# # => "images/logo.png"
|
|
45
|
+
def self.join(components)
|
|
46
|
+
return components.join("/")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Simplify the given path components by resolving "." and "..".
|
|
50
|
+
#
|
|
51
|
+
# @parameter components [Array(String)] The path components to simplify.
|
|
52
|
+
# @returns [Array(String)] The simplified path components.
|
|
53
|
+
#
|
|
54
|
+
# @example Resolve parent directory references.
|
|
55
|
+
# Path.simplify(["documents", "reports", "..", "invoices", "2024.pdf"])
|
|
56
|
+
# # => ["documents", "invoices", "2024.pdf"]
|
|
57
|
+
#
|
|
58
|
+
# @example Remove current directory references.
|
|
59
|
+
# Path.simplify(["documents", ".", "report.pdf"])
|
|
60
|
+
# # => ["documents", "report.pdf"]
|
|
61
|
+
def self.simplify(components)
|
|
62
|
+
output = []
|
|
63
|
+
|
|
64
|
+
components.each_with_index do |component, index|
|
|
65
|
+
if index == 0 && component == ""
|
|
66
|
+
# Preserve leading slash:
|
|
67
|
+
output << ""
|
|
68
|
+
elsif component == "."
|
|
69
|
+
# Handle current directory - trailing . means directory, preserve trailing slash:
|
|
70
|
+
output << "" if index == components.size - 1
|
|
71
|
+
elsif component == "" && index != components.size - 1
|
|
72
|
+
# Ignore empty segments (multiple slashes) except at end - no-op.
|
|
73
|
+
elsif component == ".." && output.last && output.last != ".."
|
|
74
|
+
# Handle parent directory: go up one level if not at root:
|
|
75
|
+
output.pop if output.last != ""
|
|
76
|
+
# Trailing .. means directory, preserve trailing slash:
|
|
77
|
+
output << "" if index == components.size - 1
|
|
78
|
+
else
|
|
79
|
+
# Regular path component:
|
|
80
|
+
output << component
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
return output
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @parameter pop [Boolean] whether to remove the last path component of the base path, to conform to URI merging behaviour, as defined by RFC2396.
|
|
88
|
+
#
|
|
89
|
+
# @example Expand a relative path against a base path.
|
|
90
|
+
# Path.expand("/documents/reports/", "invoices/2024.pdf")
|
|
91
|
+
# # => "/documents/reports/invoices/2024.pdf"
|
|
92
|
+
#
|
|
93
|
+
# @example Navigate to parent directory.
|
|
94
|
+
# Path.expand("/documents/reports/2024/", "../summary.pdf")
|
|
95
|
+
# # => "/documents/reports/summary.pdf"
|
|
96
|
+
def self.expand(base, relative, pop = true)
|
|
97
|
+
# Empty relative path means no change:
|
|
98
|
+
return base if relative.nil? || relative.empty?
|
|
99
|
+
|
|
100
|
+
components = split(base)
|
|
101
|
+
|
|
102
|
+
# RFC2396 Section 5.2:
|
|
103
|
+
# 6) a) All but the last segment of the base URI's path component is
|
|
104
|
+
# copied to the buffer. In other words, any characters after the
|
|
105
|
+
# last (right-most) slash character, if any, are excluded.
|
|
106
|
+
if pop and components.last != ".."
|
|
107
|
+
components.pop
|
|
108
|
+
elsif components.last == ""
|
|
109
|
+
components.pop
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
relative = relative.split("/", -1)
|
|
113
|
+
if relative.first == ""
|
|
114
|
+
components = relative
|
|
115
|
+
else
|
|
116
|
+
components.concat(relative)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
return join(simplify(components))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Convert a URL path to a local file system path using the platform's file separator.
|
|
123
|
+
#
|
|
124
|
+
# This method splits the URL path on `/` characters, unescapes each component using
|
|
125
|
+
# {Encoding.unescape_path} (which preserves encoded separators), then joins the
|
|
126
|
+
# components using `File.join`.
|
|
127
|
+
#
|
|
128
|
+
# Percent-encoded path separators (`%2F` for `/` and `%5C` for `\`) are NOT decoded,
|
|
129
|
+
# preventing them from being interpreted as directory boundaries. This ensures that
|
|
130
|
+
# URL path components map directly to file system path components.
|
|
131
|
+
#
|
|
132
|
+
# @parameter path [String] The URL path to convert (should be percent-encoded).
|
|
133
|
+
# @returns [String] The local file system path.
|
|
134
|
+
#
|
|
135
|
+
# @example Generating local paths.
|
|
136
|
+
# Path.to_local_path("/documents/report.pdf") # => "/documents/report.pdf"
|
|
137
|
+
# Path.to_local_path("/files/My%20Document.txt") # => "/files/My Document.txt"
|
|
138
|
+
#
|
|
139
|
+
# @example Preserves encoded separators.
|
|
140
|
+
# Path.to_local_path("/folder/safe%2Fname/file.txt")
|
|
141
|
+
# # => "/folder/safe%2Fname/file.txt"
|
|
142
|
+
# # %2F is NOT decoded to prevent creating additional path components
|
|
143
|
+
def self.to_local_path(path)
|
|
144
|
+
components = split(path)
|
|
145
|
+
|
|
146
|
+
# Unescape each component, preserving encoded path separators
|
|
147
|
+
components.map! do |component|
|
|
148
|
+
Encoding.unescape_path(component)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
return File.join(*components)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "encoding"
|
|
7
|
+
require_relative "relative"
|
|
8
|
+
|
|
9
|
+
module Protocol
|
|
10
|
+
module URL
|
|
11
|
+
# Represents a "Hypertext Reference", which may include a path, query string, fragment, and user parameters.
|
|
12
|
+
#
|
|
13
|
+
# This class is designed to be easy to manipulate and combine URL references, following the rules specified in RFC2396, while supporting standard URL encoded parameters.
|
|
14
|
+
#
|
|
15
|
+
# Use {parse} for external/untrusted data, and {new} for constructing references from known good values.
|
|
16
|
+
class Reference < Relative
|
|
17
|
+
include Comparable
|
|
18
|
+
|
|
19
|
+
def self.[](value, parameters = nil)
|
|
20
|
+
case value
|
|
21
|
+
when String
|
|
22
|
+
if match = value.match(PATTERN)
|
|
23
|
+
path = match[:path]
|
|
24
|
+
query = match[:query]
|
|
25
|
+
fragment = match[:fragment]
|
|
26
|
+
|
|
27
|
+
# Unescape path and fragment for user-friendly internal storage
|
|
28
|
+
# Query strings are kept as-is since they contain = and & syntax
|
|
29
|
+
path = Encoding.unescape(path) if path && !path.empty?
|
|
30
|
+
fragment = Encoding.unescape(fragment) if fragment
|
|
31
|
+
|
|
32
|
+
self.new(path, query, fragment, parameters)
|
|
33
|
+
else
|
|
34
|
+
raise ArgumentError, "Invalid URL (contains whitespace or control characters): #{value.inspect}"
|
|
35
|
+
end
|
|
36
|
+
when Relative
|
|
37
|
+
# Relative stores encoded values, so we need to unescape them for Reference
|
|
38
|
+
path = value.path
|
|
39
|
+
fragment = value.fragment
|
|
40
|
+
|
|
41
|
+
path = Encoding.unescape(path) if path && !path.empty?
|
|
42
|
+
fragment = Encoding.unescape(fragment) if fragment
|
|
43
|
+
|
|
44
|
+
self.new(path, value.query, fragment, parameters)
|
|
45
|
+
when nil
|
|
46
|
+
nil
|
|
47
|
+
else
|
|
48
|
+
raise ArgumentError, "Cannot coerce #{value.inspect} to Reference!"
|
|
49
|
+
end
|
|
50
|
+
end # Generate a reference from a path and user parameters. The path may contain a `#fragment` or `?query=parameters`.
|
|
51
|
+
#
|
|
52
|
+
# @example Parse a path with query and fragment.
|
|
53
|
+
# reference = Reference.parse("/search?query=ruby#results")
|
|
54
|
+
# reference.path # => "/search"
|
|
55
|
+
# reference.query # => "query=ruby"
|
|
56
|
+
# reference.fragment # => "results"
|
|
57
|
+
def self.parse(value = "/", parameters = nil)
|
|
58
|
+
self.[](value, parameters)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Initialize the reference with raw, unescaped values.
|
|
62
|
+
#
|
|
63
|
+
# @parameter path [String] The unescaped path.
|
|
64
|
+
# @parameter query [String | Nil] An already-formatted query string.
|
|
65
|
+
# @parameter fragment [String | Nil] The unescaped fragment.
|
|
66
|
+
# @parameter parameters [Hash | Nil] User supplied parameters that will be safely encoded.
|
|
67
|
+
#
|
|
68
|
+
# @example Create a reference with parameters.
|
|
69
|
+
# reference = Reference.new("/search", nil, nil, {"query" => "ruby", "limit" => "10"})
|
|
70
|
+
# reference.to_s # => "/search?query=ruby&limit=10"
|
|
71
|
+
def initialize(path = "/", query = nil, fragment = nil, parameters = nil)
|
|
72
|
+
super(path, query, fragment)
|
|
73
|
+
@parameters = parameters
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @attribute [Hash] User supplied parameters that will be appended to the query part.
|
|
77
|
+
attr :parameters
|
|
78
|
+
|
|
79
|
+
# Freeze the reference.
|
|
80
|
+
#
|
|
81
|
+
# @returns [Reference] The frozen reference.
|
|
82
|
+
def freeze
|
|
83
|
+
return self if frozen?
|
|
84
|
+
|
|
85
|
+
@parameters.freeze
|
|
86
|
+
|
|
87
|
+
super
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Implicit conversion to an array.
|
|
91
|
+
#
|
|
92
|
+
# @returns [Array] The reference as an array, `[path, query, fragment, parameters]`.
|
|
93
|
+
def to_ary
|
|
94
|
+
[@path, @query, @fragment, @parameters]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Compare two references.
|
|
98
|
+
#
|
|
99
|
+
# @parameter other [Reference] The other reference to compare.
|
|
100
|
+
# @returns [Integer] -1, 0, 1 if the reference is less than, equal to, or greater than the other reference.
|
|
101
|
+
def <=> other
|
|
102
|
+
to_ary <=> other.to_ary
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @returns [Boolean] Whether the reference has parameters.
|
|
106
|
+
def parameters?
|
|
107
|
+
@parameters and !@parameters.empty?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Parse the query string into parameters and merge with existing parameters.
|
|
111
|
+
#
|
|
112
|
+
# Afterwards, the `query` attribute will be cleared.
|
|
113
|
+
#
|
|
114
|
+
# @returns [Hash] The merged parameters.
|
|
115
|
+
def parse_query!(encoding = Encoding)
|
|
116
|
+
if @query and !@query.empty?
|
|
117
|
+
parsed = encoding.decode(@query)
|
|
118
|
+
|
|
119
|
+
if @parameters
|
|
120
|
+
@parameters = @parameters.merge(parsed)
|
|
121
|
+
else
|
|
122
|
+
@parameters = parsed
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
@query = nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
return @parameters
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @returns [Boolean] Whether the reference has a query string.
|
|
132
|
+
def query?
|
|
133
|
+
@query and !@query.empty?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @returns [Boolean] Whether the reference has a fragment.
|
|
137
|
+
def fragment?
|
|
138
|
+
@fragment and !@fragment.empty?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Append the reference to the given buffer.
|
|
142
|
+
# Encodes the path and fragment which are stored unescaped internally.
|
|
143
|
+
# Query strings are passed through as-is (they contain = and & which are valid syntax).
|
|
144
|
+
def append(buffer = String.new)
|
|
145
|
+
buffer << Encoding.escape_path(@path)
|
|
146
|
+
|
|
147
|
+
if @query and !@query.empty?
|
|
148
|
+
buffer << "?" << @query
|
|
149
|
+
buffer << "&" << Encoding.encode(@parameters) if parameters?
|
|
150
|
+
elsif parameters?
|
|
151
|
+
buffer << "?" << Encoding.encode(@parameters)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
if @fragment and !@fragment.empty?
|
|
155
|
+
buffer << "#" << Encoding.escape_fragment(@fragment)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
return buffer
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Merges two references as specified by RFC2396, similar to `URI.join`.
|
|
162
|
+
def + other
|
|
163
|
+
other = self.class[other]
|
|
164
|
+
|
|
165
|
+
self.class.new(
|
|
166
|
+
Path.expand(self.path, other.path, true),
|
|
167
|
+
other.query,
|
|
168
|
+
other.fragment,
|
|
169
|
+
other.parameters,
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Just the base path, without any query string, parameters or fragment.
|
|
174
|
+
def base
|
|
175
|
+
self.class.new(@path, nil, nil, nil)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Update the reference with the given path, query, fragment, and parameters.
|
|
179
|
+
#
|
|
180
|
+
# @parameter path [String] Append the string to this reference similar to `File.join`.
|
|
181
|
+
# @parameter query [String | Nil] Replace the query string. Defaults to keeping the existing query if not specified.
|
|
182
|
+
# @parameter fragment [String | Nil] Replace the fragment. Defaults to keeping the existing fragment if not specified.
|
|
183
|
+
# @parameter parameters [Hash | false] Parameters to merge or replace. Pass `false` (default) to keep existing parameters.
|
|
184
|
+
# @parameter pop [Boolean] If the path contains a trailing filename, pop the last component of the path before appending the new path.
|
|
185
|
+
# @parameter merge [Boolean] Controls how parameters are handled. When `true` (default), new parameters are merged with existing ones and query is kept. When `false` and new parameters are provided, parameters replace existing ones and query is cleared. Explicitly passing `query:` always overrides this behavior.
|
|
186
|
+
#
|
|
187
|
+
# @example Merge parameters.
|
|
188
|
+
# reference = Reference.new("/search", nil, nil, {"query" => "ruby"})
|
|
189
|
+
# updated = reference.with(parameters: {"limit" => "10"})
|
|
190
|
+
# updated.to_s # => "/search?query=ruby&limit=10"
|
|
191
|
+
#
|
|
192
|
+
# @example Replace parameters.
|
|
193
|
+
# reference = Reference.new("/search", nil, nil, {"query" => "ruby"})
|
|
194
|
+
# updated = reference.with(parameters: {"query" => "python"}, merge: false)
|
|
195
|
+
# updated.to_s # => "/search?query=python"
|
|
196
|
+
def with(path: nil, query: false, fragment: @fragment, parameters: false, pop: false, merge: true)
|
|
197
|
+
if merge
|
|
198
|
+
# If merging, we keep existing query unless explicitly overridden:
|
|
199
|
+
if query == false
|
|
200
|
+
query = @query
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Merge mode: combine new parameters with existing, keep query:
|
|
204
|
+
# parameters = (@parameters || {}).merge(parameters || {})
|
|
205
|
+
if @parameters
|
|
206
|
+
if parameters
|
|
207
|
+
parameters = @parameters.merge(parameters)
|
|
208
|
+
else
|
|
209
|
+
parameters = @parameters
|
|
210
|
+
end
|
|
211
|
+
elsif !parameters
|
|
212
|
+
parameters = @parameters
|
|
213
|
+
end
|
|
214
|
+
else
|
|
215
|
+
# Replace mode: use new parameters if provided, clear query when replacing:
|
|
216
|
+
if parameters == false
|
|
217
|
+
# No new parameters provided, keep existing:
|
|
218
|
+
parameters = @parameters
|
|
219
|
+
|
|
220
|
+
# Also keep query if not explicitly specified:
|
|
221
|
+
if query == false
|
|
222
|
+
query = @query
|
|
223
|
+
end
|
|
224
|
+
else
|
|
225
|
+
# New parameters provided, clear query unless explicitly specified:
|
|
226
|
+
if query == false
|
|
227
|
+
query = nil
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
path = Path.expand(@path, path, pop)
|
|
233
|
+
|
|
234
|
+
self.class.new(path, query, fragment, parameters)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "encoding"
|
|
7
|
+
require_relative "path"
|
|
8
|
+
|
|
9
|
+
module Protocol
|
|
10
|
+
module URL
|
|
11
|
+
# Represents a relative URL, which does not include a scheme or authority.
|
|
12
|
+
class Relative
|
|
13
|
+
include Comparable
|
|
14
|
+
|
|
15
|
+
def initialize(path, query = nil, fragment = nil)
|
|
16
|
+
@path = path.to_s
|
|
17
|
+
@query = query
|
|
18
|
+
@fragment = fragment
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr :path
|
|
22
|
+
attr :query
|
|
23
|
+
attr :fragment
|
|
24
|
+
|
|
25
|
+
def to_local_path
|
|
26
|
+
Path.to_local_path(@path)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @returns [Boolean] If there is a query string.
|
|
30
|
+
def query?
|
|
31
|
+
@query and !@query.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @returns [Boolean] If there is a fragment.
|
|
35
|
+
def fragment?
|
|
36
|
+
@fragment and !@fragment.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Combine this relative URL with another URL or path.
|
|
40
|
+
#
|
|
41
|
+
# @parameter other [String, Absolute, Relative] The URL or path to combine.
|
|
42
|
+
# @returns [Absolute, Relative] The combined URL.
|
|
43
|
+
#
|
|
44
|
+
# @example Combine two relative paths.
|
|
45
|
+
# base = Relative.new("/documents/reports/")
|
|
46
|
+
# other = Relative.new("invoices/2024.pdf")
|
|
47
|
+
# result = base + other
|
|
48
|
+
# result.path # => "/documents/reports/invoices/2024.pdf"
|
|
49
|
+
#
|
|
50
|
+
# @example Navigate to parent directory.
|
|
51
|
+
# base = Relative.new("/documents/reports/archive/")
|
|
52
|
+
# other = Relative.new("../../summary.pdf")
|
|
53
|
+
# result = base + other
|
|
54
|
+
# result.path # => "/documents/summary.pdf"
|
|
55
|
+
def +(other)
|
|
56
|
+
case other
|
|
57
|
+
when Absolute
|
|
58
|
+
# Relative + Absolute: the absolute URL takes precedence
|
|
59
|
+
# You can't apply relative navigation to an absolute URL
|
|
60
|
+
other
|
|
61
|
+
when Relative
|
|
62
|
+
# Relative + Relative: merge paths directly
|
|
63
|
+
self.class.new(
|
|
64
|
+
Path.expand(self.path, other.path, true),
|
|
65
|
+
other.query,
|
|
66
|
+
other.fragment
|
|
67
|
+
)
|
|
68
|
+
when String
|
|
69
|
+
# Relative + String: parse and combine
|
|
70
|
+
self + URL[other]
|
|
71
|
+
else
|
|
72
|
+
raise ArgumentError, "Cannot combine Relative URL with #{other.class}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Create a new Relative URL with modified components.
|
|
77
|
+
#
|
|
78
|
+
# @parameter path [String, nil] The path to merge with the current path.
|
|
79
|
+
# @parameter query [String, nil] The query string to use.
|
|
80
|
+
# @parameter fragment [String, nil] The fragment to use.
|
|
81
|
+
# @parameter pop [Boolean] Whether to pop the last path component before merging.
|
|
82
|
+
# @returns [Relative] A new Relative URL with the modified components.
|
|
83
|
+
#
|
|
84
|
+
# @example Update the query string.
|
|
85
|
+
# url = Relative.new("/search", "query=ruby")
|
|
86
|
+
# updated = url.with(query: "query=python")
|
|
87
|
+
# updated.to_s # => "/search?query=python"
|
|
88
|
+
#
|
|
89
|
+
# @example Append to the path.
|
|
90
|
+
# url = Relative.new("/documents/")
|
|
91
|
+
# updated = url.with(path: "report.pdf", pop: false)
|
|
92
|
+
# updated.to_s # => "/documents/report.pdf"
|
|
93
|
+
def with(path: nil, query: @query, fragment: @fragment, pop: true)
|
|
94
|
+
self.class.new(Path.expand(@path, path, pop), query, fragment)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Normalize the path by resolving "." and ".." segments and removing duplicate slashes.
|
|
98
|
+
#
|
|
99
|
+
# This modifies the URL in-place by simplifying the path component:
|
|
100
|
+
# - Removes "." segments (current directory)
|
|
101
|
+
# - Resolves ".." segments (parent directory)
|
|
102
|
+
# - Collapses multiple consecutive slashes to single slashes (except at start)
|
|
103
|
+
#
|
|
104
|
+
# @returns [self] The normalized URL.
|
|
105
|
+
#
|
|
106
|
+
# @example Basic normalization
|
|
107
|
+
# url = Relative.new("/foo//bar/./baz/../qux")
|
|
108
|
+
# url.normalize!
|
|
109
|
+
# url.path # => "/foo/bar/qux"
|
|
110
|
+
def normalize!
|
|
111
|
+
components = Path.split(@path)
|
|
112
|
+
normalized = Path.simplify(components)
|
|
113
|
+
@path = Path.join(normalized)
|
|
114
|
+
|
|
115
|
+
return self
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Append the relative URL to the given buffer.
|
|
119
|
+
# The path, query, and fragment are expected to already be properly encoded.
|
|
120
|
+
def append(buffer = String.new)
|
|
121
|
+
buffer << @path
|
|
122
|
+
|
|
123
|
+
if @query and !@query.empty?
|
|
124
|
+
buffer << "?" << @query
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
if @fragment and !@fragment.empty?
|
|
128
|
+
buffer << "#" << @fragment
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
return buffer
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def to_ary
|
|
135
|
+
[@path, @query, @fragment]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def <=>(other)
|
|
139
|
+
to_ary <=> other.to_ary
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def to_s
|
|
143
|
+
append
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def inspect
|
|
147
|
+
"#<#{self.class} #{to_s}>"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
data/lib/protocol/url.rb
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "url/version"
|
|
7
|
+
require_relative "url/encoding"
|
|
8
|
+
require_relative "url/reference"
|
|
9
|
+
require_relative "url/relative"
|
|
10
|
+
require_relative "url/absolute"
|
|
11
|
+
|
|
12
|
+
module Protocol
|
|
13
|
+
module URL
|
|
14
|
+
# RFC 3986 URI pattern with named capture groups.
|
|
15
|
+
# Matches: [scheme:][//authority][path][?query][#fragment]
|
|
16
|
+
# Rejects strings containing whitespace or control characters (matching standard URI behavior).
|
|
17
|
+
PATTERN = %r{
|
|
18
|
+
\A
|
|
19
|
+
(?:(?<scheme>[a-z][a-z0-9+.-]*):)? # scheme (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)
|
|
24
|
+
\z
|
|
25
|
+
}ix
|
|
26
|
+
private_constant :PATTERN
|
|
27
|
+
|
|
28
|
+
# Coerce a value into an appropriate URL type (Absolute or Relative).
|
|
29
|
+
#
|
|
30
|
+
# @parameter value [String, Absolute, Relative, nil] The value to coerce.
|
|
31
|
+
# @returns [Absolute, Relative, nil] The coerced URL.
|
|
32
|
+
def self.[](value)
|
|
33
|
+
case value
|
|
34
|
+
when String
|
|
35
|
+
if match = value.match(PATTERN)
|
|
36
|
+
scheme = match[:scheme]
|
|
37
|
+
authority = match[:authority]
|
|
38
|
+
path = match[:path]
|
|
39
|
+
query = match[:query]
|
|
40
|
+
fragment = match[:fragment]
|
|
41
|
+
|
|
42
|
+
# If we have a scheme or authority, it's an absolute URL
|
|
43
|
+
if scheme || authority
|
|
44
|
+
Absolute.new(scheme, authority, path, query, fragment)
|
|
45
|
+
else
|
|
46
|
+
# No scheme or authority, treat as relative:
|
|
47
|
+
Relative.new(path, query, fragment)
|
|
48
|
+
end
|
|
49
|
+
else
|
|
50
|
+
raise ArgumentError, "Invalid URL (contains whitespace or control characters): #{value.inspect}"
|
|
51
|
+
end
|
|
52
|
+
when Relative
|
|
53
|
+
value
|
|
54
|
+
when nil
|
|
55
|
+
nil
|
|
56
|
+
else
|
|
57
|
+
raise ArgumentError, "Cannot coerce #{value.inspect} to URL!"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/license.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright, 2025, by Samuel Williams.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/readme.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Protocol::URL
|
|
2
|
+
|
|
3
|
+
Provides abstractions for working with URLs.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/socketry/protocol-url/actions?workflow=Test)
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
Please see the [project documentation](https://github.com/socketry/protocol-url) for more details.
|
|
10
|
+
|
|
11
|
+
- [Getting Started](https://github.com/socketry/protocol-urlguides/getting-started/index) - This guide explains how to get started with `protocol-url` for parsing, manipulating, and constructing URLs in Ruby.
|
|
12
|
+
|
|
13
|
+
- [Working with References](https://github.com/socketry/protocol-urlguides/working-with-references/index) - This guide explains how to use <code class="language-ruby">Protocol::URL::Reference</code> for managing URLs with query parameters and fragments.
|
|
14
|
+
|
|
15
|
+
## Contributing
|
|
16
|
+
|
|
17
|
+
We welcome contributions to this project.
|
|
18
|
+
|
|
19
|
+
1. Fork it.
|
|
20
|
+
2. Create your feature branch (`git checkout -b my-new-feature`).
|
|
21
|
+
3. Commit your changes (`git commit -am 'Add some feature'`).
|
|
22
|
+
4. Push to the branch (`git push origin my-new-feature`).
|
|
23
|
+
5. Create new Pull Request.
|
|
24
|
+
|
|
25
|
+
### Developer Certificate of Origin
|
|
26
|
+
|
|
27
|
+
In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
|
|
28
|
+
|
|
29
|
+
### Community Guidelines
|
|
30
|
+
|
|
31
|
+
This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers.
|
|
32
|
+
|
|
33
|
+
## Releases
|
|
34
|
+
|
|
35
|
+
Please see the [project releases](https://github.com/socketry/protocol-urlreleases/index) for all releases.
|
|
36
|
+
|
|
37
|
+
### v0.1.0
|
|
38
|
+
|
|
39
|
+
- Initial implementation.
|
|
40
|
+
|
|
41
|
+
## See Also
|
|
42
|
+
|
|
43
|
+
- [protocol-http](https://github.com/socketry/protocol-http) — HTTP protocol implementation and abstractions.
|
data/releases.md
ADDED
data.tar.gz.sig
ADDED
|
Binary file
|
metadata
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: protocol-url
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Samuel Williams
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain:
|
|
10
|
+
- |
|
|
11
|
+
-----BEGIN CERTIFICATE-----
|
|
12
|
+
MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11
|
|
13
|
+
ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK
|
|
14
|
+
CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz
|
|
15
|
+
MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd
|
|
16
|
+
MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj
|
|
17
|
+
bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB
|
|
18
|
+
igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2
|
|
19
|
+
9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW
|
|
20
|
+
sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE
|
|
21
|
+
e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN
|
|
22
|
+
XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss
|
|
23
|
+
RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn
|
|
24
|
+
tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM
|
|
25
|
+
zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW
|
|
26
|
+
xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O
|
|
27
|
+
BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs
|
|
28
|
+
aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs
|
|
29
|
+
aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE
|
|
30
|
+
cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl
|
|
31
|
+
xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/
|
|
32
|
+
c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp
|
|
33
|
+
8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws
|
|
34
|
+
JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP
|
|
35
|
+
eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt
|
|
36
|
+
Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8
|
|
37
|
+
voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg=
|
|
38
|
+
-----END CERTIFICATE-----
|
|
39
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
40
|
+
dependencies: []
|
|
41
|
+
executables: []
|
|
42
|
+
extensions: []
|
|
43
|
+
extra_rdoc_files: []
|
|
44
|
+
files:
|
|
45
|
+
- lib/protocol/url.rb
|
|
46
|
+
- lib/protocol/url/absolute.rb
|
|
47
|
+
- lib/protocol/url/encoding.rb
|
|
48
|
+
- lib/protocol/url/path.rb
|
|
49
|
+
- lib/protocol/url/reference.rb
|
|
50
|
+
- lib/protocol/url/relative.rb
|
|
51
|
+
- lib/protocol/url/version.rb
|
|
52
|
+
- license.md
|
|
53
|
+
- readme.md
|
|
54
|
+
- releases.md
|
|
55
|
+
homepage: https://github.com/socketry/protocol-url
|
|
56
|
+
licenses:
|
|
57
|
+
- MIT
|
|
58
|
+
metadata:
|
|
59
|
+
source_code_uri: https://github.com/socketry/protocol-url.git
|
|
60
|
+
rdoc_options: []
|
|
61
|
+
require_paths:
|
|
62
|
+
- lib
|
|
63
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.2'
|
|
68
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '0'
|
|
73
|
+
requirements: []
|
|
74
|
+
rubygems_version: 3.7.2
|
|
75
|
+
specification_version: 4
|
|
76
|
+
summary: Provides abstractions for working with URLs.
|
|
77
|
+
test_files: []
|
metadata.gz.sig
ADDED
|
Binary file
|