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 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,3 @@
1
+ ��8>w�,��܍�Pj��Ջ�f�ԙ��P;J�j�!\�l��y��
2
+ P�N� �J�>w=E6��W`�W�iJ(:1lACΞ�}�%�ئ�4�B S����߽��Y�U(%qZ)�l[u�%
3
+ �f��6�'��0Q?�Cq��ڐ흝h �c�]:V�Y����_�0%�R��{��0fr"�Q4���T��`Bغ����8R��q4u�ə 6��*���?-�$�ߣI6��&y
@@ -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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ # @namespace
7
+ module Protocol
8
+ # @namespace
9
+ module URL
10
+ VERSION = "0.1.0"
11
+ end
12
+ end
@@ -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
+ [![Development Status](https://github.com/socketry/protocol-url/workflows/Test/badge.svg)](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
@@ -0,0 +1,5 @@
1
+ # Releases
2
+
3
+ ## v0.1.0
4
+
5
+ - Initial implementation.
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