protocol-url 0.4.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff6accb882e5c993ee454290f6d282d67df6b4d13ee01bdd24b042ea764ee03c
4
- data.tar.gz: abc0495ee011bccd902d3f60bebcf4d8231e3559b14305fe5594d62df285eeae
3
+ metadata.gz: 10d18a95d6f20f5ca2457a52a2defa8d961ecf26eb372d45bea1f2d339f3a5c6
4
+ data.tar.gz: f15a84b24bd971e6262306b84e271196b5c20cde76983eba4625af28b0895ddd
5
5
  SHA512:
6
- metadata.gz: 1a1852872d62c65b0d78f8cd698deaaca69ceea65b79fb2e9d04b0b1b347cf6555ba41c27b0c836e2b963981f0c9be9a8feeec55622cdede7018a01321d7bca6
7
- data.tar.gz: 4c6a01694dda0e059d944e5155f365c27c00c244e8e5ea7310288b875f2f307e4ef0c610dd8ea906e30fd5338d13f33fb1d2b83dcd5c076d57e34a60eb515b3b
6
+ metadata.gz: e1493486247a9e36b696bf62225a33888905d7e4a5a83b31357dc67d227b3a6f481844e2423befc5cc21f4c0a14aebc4ea507faa68a06a50490619e455396950
7
+ data.tar.gz: ee45f80e209b66b2a30d6b155188138a513e9a2c632bf1a4edc0322823e9bef209d2515babc955030c43c26c9da9ea687303a2aa91fa9704a2ac0594405d8cda
checksums.yaml.gz.sig CHANGED
Binary file
@@ -10,6 +10,13 @@ module Protocol
10
10
  # Represents an absolute URL with scheme and/or authority.
11
11
  # Examples: "https://example.com/path", "//cdn.example.com/lib.js", "http://localhost/"
12
12
  class Absolute < Relative
13
+ # Initialize a new absolute URL.
14
+ #
15
+ # @parameter scheme [String] The URL scheme (e.g., "https", "http").
16
+ # @parameter authority [String] The authority component (e.g., "example.com", "user@host:port").
17
+ # @parameter path [String] The path component (defaults to "/").
18
+ # @parameter query [String, nil] The query string.
19
+ # @parameter fragment [String, nil] The fragment identifier.
13
20
  def initialize(scheme, authority, path = "/", query = nil, fragment = nil)
14
21
  @scheme = scheme
15
22
  @authority = authority
@@ -18,13 +25,22 @@ module Protocol
18
25
  super(path, query, fragment)
19
26
  end
20
27
 
28
+ # @attribute [String] The URL scheme.
21
29
  attr :scheme
30
+
31
+ # @attribute [String] The authority component.
22
32
  attr :authority
23
33
 
34
+ # Check if the URL has a non-empty scheme.
35
+ #
36
+ # @returns [Boolean] True if a scheme is present and non-empty.
24
37
  def scheme?
25
38
  @scheme and !@scheme.empty?
26
39
  end
27
40
 
41
+ # Check if the URL has a non-empty authority.
42
+ #
43
+ # @returns [Boolean] True if an authority is present and non-empty.
28
44
  def authority?
29
45
  @authority and !@authority.empty?
30
46
  end
@@ -93,8 +109,6 @@ module Protocol
93
109
  super(buffer)
94
110
  end
95
111
 
96
- UNSPECIFIED = Object.new
97
-
98
112
  # Create a new Absolute URL with modified components.
99
113
  #
100
114
  # @parameter scheme [String, nil] The scheme to use (nil to remove scheme).
@@ -118,14 +132,24 @@ module Protocol
118
132
  self.class.new(scheme, authority, Path.expand(@path, path, pop), query, fragment)
119
133
  end
120
134
 
135
+ # Convert the URL to an array representation.
136
+ #
137
+ # @returns [Array] An array of `[scheme, authority, path, query, fragment]`.
121
138
  def to_ary
122
139
  [@scheme, @authority, @path, @query, @fragment]
123
140
  end
124
141
 
142
+ # Compare this URL with another for sorting purposes.
143
+ #
144
+ # @parameter other [Absolute] The URL to compare with.
145
+ # @returns [Integer] -1, 0, or 1 based on component-wise comparison.
125
146
  def <=>(other)
126
147
  to_ary <=> other.to_ary
127
148
  end
128
149
 
150
+ # Convert the URL to its string representation.
151
+ #
152
+ # @returns [String] The formatted absolute URL string.
129
153
  def to_s
130
154
  append
131
155
  end
@@ -233,6 +233,17 @@ module Protocol
233
233
 
234
234
  return parameters
235
235
  end
236
+
237
+ # Decode an `application/x-www-form-urlencoded` string into a hash.
238
+ # In addition to percent encoding, this format represents spaces using `+`.
239
+ #
240
+ # @parameter string [String] The form-encoded string to decode.
241
+ # @parameter maximum [Integer] The maximum number of keys in a path.
242
+ # @parameter symbolize_keys [Boolean] Whether to symbolize keys.
243
+ # @returns [Hash] The decoded form values.
244
+ def self.decode_www_form(string, maximum = 8, symbolize_keys: false)
245
+ return self.decode(string.gsub("+", "%20"), maximum, symbolize_keys: symbolize_keys)
246
+ end
236
247
  end
237
248
  end
238
249
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "../encoding"
7
+
8
+ module Protocol
9
+ module URL
10
+ module FormData
11
+ # Builds nested form data from names and values.
12
+ class Nested
13
+ # The default maximum depth of a bracketed form name.
14
+ MAXIMUM_DEPTH = 8
15
+
16
+ # Initialize the nested form data.
17
+ # @parameter maximum_depth [Integer | Nil] The maximum depth of a bracketed form name.
18
+ def initialize(maximum_depth: MAXIMUM_DEPTH)
19
+ @maximum_depth = maximum_depth
20
+ @root = {}
21
+ end
22
+
23
+ # Add a form data value using its bracketed name.
24
+ # @parameter name [String] The form data name.
25
+ # @parameter value [Object] The form data value.
26
+ # @returns [Nested] The receiver.
27
+ def add(name, value)
28
+ keys = Encoding.split(name)
29
+
30
+ if keys.empty?
31
+ raise ArgumentError, "Invalid form data name: #{name.inspect}!"
32
+ end
33
+
34
+ if @maximum_depth and keys.size > @maximum_depth
35
+ raise RangeError, "Form data depth exceeded limit of #{@maximum_depth}!"
36
+ end
37
+
38
+ Encoding.assign(keys, value, @root)
39
+
40
+ return self
41
+ end
42
+
43
+ # Convert the arguments to a nested hash.
44
+ # @returns [Hash] The nested arguments.
45
+ def to_h
46
+ return @root
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "nested"
7
+
8
+ module Protocol
9
+ module URL
10
+ # @namespace
11
+ module FormData
12
+ # Incrementally parses `application/x-www-form-urlencoded` form data.
13
+ class Parser
14
+ CONTENT_TYPE = "application/x-www-form-urlencoded"
15
+
16
+ # The default maximum encoded body size.
17
+ MAXIMUM_TOTAL_SIZE = 2 * 1024 * 1024
18
+
19
+ # The default maximum number of form pairs.
20
+ MAXIMUM_PAIR_COUNT = 1024
21
+
22
+ # Initialize the form data parser.
23
+ # @parameter maximum_total_size [Integer | Nil] The maximum encoded body size.
24
+ # @parameter maximum_pair_count [Integer | Nil] The maximum number of form pairs.
25
+ # @parameter maximum_depth [Integer | Nil] The maximum depth of a bracketed form name.
26
+ def initialize(maximum_total_size: MAXIMUM_TOTAL_SIZE, maximum_pair_count: MAXIMUM_PAIR_COUNT, maximum_depth: Nested::MAXIMUM_DEPTH)
27
+ @maximum_total_size = maximum_total_size
28
+ @maximum_pair_count = maximum_pair_count
29
+ @maximum_depth = maximum_depth
30
+ end
31
+
32
+ # Parse URL-encoded form data into a nested hash.
33
+ #
34
+ # When a block is given, each decoded value is passed through the block before assignment. The value returned by the block is assigned to the result.
35
+ #
36
+ # @parameter body [Object] A readable body which yields chunks from `#read`.
37
+ # @yields {|name, value| ...} Each decoded form pair before assignment.
38
+ # @returns [Hash] The nested form data.
39
+ def parse(body)
40
+ nested = Nested.new(maximum_depth: @maximum_depth)
41
+
42
+ each(body) do |name, value|
43
+ value = yield(name, value) if block_given?
44
+ nested.add(name, value)
45
+ end
46
+
47
+ return nested.to_h
48
+ end
49
+
50
+ # Incrementally enumerate URL-encoded form data as ordered name/value pairs.
51
+ # @parameter body [Object] A readable body which yields chunks from `#read`.
52
+ # @yields {|name, value| ...} Each decoded form pair.
53
+ # @returns [Enumerator | Boolean] An enumerator without a block, or true when complete.
54
+ def each(body)
55
+ return to_enum(__method__, body) unless block_given?
56
+
57
+ buffer = String.new.b
58
+ total_size = 0
59
+ pair_count = 0
60
+
61
+ while chunk = body.read
62
+ break if chunk.empty?
63
+
64
+ total_size += chunk.bytesize
65
+ check_limit(:total_size, total_size, @maximum_total_size)
66
+ buffer << chunk
67
+
68
+ while separator = buffer.index("&")
69
+ assignment = buffer.slice!(0, separator + 1)
70
+ assignment.chop!
71
+
72
+ unless assignment.empty?
73
+ pair_count += 1
74
+ check_limit(:pair_count, pair_count, @maximum_pair_count)
75
+ yield_pair(assignment) {|name, value| yield name, value}
76
+ end
77
+ end
78
+ end
79
+
80
+ unless buffer.empty?
81
+ pair_count += 1
82
+ check_limit(:pair_count, pair_count, @maximum_pair_count)
83
+ yield_pair(buffer) {|name, value| yield name, value}
84
+ end
85
+
86
+ return true
87
+ end
88
+
89
+ private
90
+
91
+ def yield_pair(assignment)
92
+ name, value = assignment.split("=", 2)
93
+
94
+ if value
95
+ value = decode_component(value)
96
+ end
97
+
98
+ yield decode_component(name), value
99
+ end
100
+
101
+ def decode_component(component)
102
+ return Encoding.unescape(component.tr("+", " "))
103
+ end
104
+
105
+ def check_limit(name, value, maximum)
106
+ if maximum and value > maximum
107
+ raise RangeError, "Form data #{name} exceeded limit of #{maximum}!"
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -17,6 +17,35 @@ module Protocol
17
17
  class Reference < Relative
18
18
  include Comparable
19
19
 
20
+ # Coerce a value into a {Reference} instance.
21
+ #
22
+ # This method provides flexible conversion from various types into a {Reference}.
23
+ # When given a {String}, it parses the URL-encoded path, query, and fragment components
24
+ # and unescapes them for internal storage. When given a {Relative}, it converts the
25
+ # encoded values to unescaped form suitable for {Reference} instances.
26
+ #
27
+ # @parameter value [String | Relative | Nil] The value to coerce.
28
+ # @parameter parameters [Hash | Nil] Optional user-supplied parameters to append to the query string.
29
+ #
30
+ # @returns [Reference | Nil] A new reference instance, or `nil` if the input is `nil`.
31
+ #
32
+ # @raises [ArgumentError] If the string contains whitespace or control characters.
33
+ # @raises [ArgumentError] If the value cannot be coerced to a {Reference}.
34
+ #
35
+ # @example Coerce a string with path, query, and fragment.
36
+ # reference = Reference["/search?q=ruby#results"]
37
+ # reference.path # => "/search"
38
+ # reference.query # => "q=ruby"
39
+ # reference.fragment # => "results"
40
+ #
41
+ # @example Coerce with additional parameters.
42
+ # reference = Reference["/search", {"limit" => "10"}]
43
+ # reference.to_s # => "/search?limit=10"
44
+ #
45
+ # @example Coerce a Relative instance.
46
+ # relative = Relative.new("/path%20with%20spaces", nil, "top")
47
+ # reference = Reference[relative]
48
+ # reference.path # => "/path with spaces"
20
49
  def self.[](value, parameters = nil)
21
50
  case value
22
51
  when String
@@ -25,7 +54,7 @@ module Protocol
25
54
  query = match[:query]
26
55
  fragment = match[:fragment]
27
56
 
28
- # Unescape path and fragment for user-friendly internal storage
57
+ # Unescape path and fragment for user-friendly internal storage:
29
58
  # Query strings are kept as-is since they contain = and & syntax
30
59
  path = Encoding.unescape(path) if path && !path.empty?
31
60
  fragment = Encoding.unescape(fragment) if fragment
@@ -35,7 +64,7 @@ module Protocol
35
64
  raise ArgumentError, "Invalid URL (contains whitespace or control characters): #{value.inspect}"
36
65
  end
37
66
  when Relative
38
- # Relative stores encoded values, so we need to unescape them for Reference
67
+ # Relative stores encoded values, so we need to unescape them for Reference:
39
68
  path = value.path
40
69
  fragment = value.fragment
41
70
 
@@ -12,16 +12,29 @@ module Protocol
12
12
  class Relative
13
13
  include Comparable
14
14
 
15
+ # Initialize a new relative URL.
16
+ #
17
+ # @parameter path [String] The path component.
18
+ # @parameter query [String, nil] The query string.
19
+ # @parameter fragment [String, nil] The fragment identifier.
15
20
  def initialize(path, query = nil, fragment = nil)
16
21
  @path = path.to_s
17
22
  @query = query
18
23
  @fragment = fragment
19
24
  end
20
25
 
26
+ # @attribute [String] The path component of the URL.
21
27
  attr :path
28
+
29
+ # @attribute [String, nil] The query string component.
22
30
  attr :query
31
+
32
+ # @attribute [String, nil] The fragment identifier.
23
33
  attr :fragment
24
34
 
35
+ # Convert the URL path to a local filesystem path.
36
+ #
37
+ # @returns [String] The local filesystem path.
25
38
  def to_local_path
26
39
  Path.to_local_path(@path)
27
40
  end
@@ -131,42 +144,76 @@ module Protocol
131
144
  return buffer
132
145
  end
133
146
 
147
+ # Convert the URL to an array representation.
148
+ #
149
+ # @returns [Array] An array of `[path, query, fragment]`.
134
150
  def to_ary
135
151
  [@path, @query, @fragment]
136
152
  end
137
153
 
154
+ # Compute a hash value for the URL based on its components.
155
+ #
156
+ # @returns [Integer] The hash value.
138
157
  def hash
139
158
  to_ary.hash
140
159
  end
141
160
 
161
+ # Check if this URL is equal to another URL by comparing components.
162
+ #
163
+ # @parameter other [Relative] The URL to compare with.
164
+ # @returns [Boolean] True if the URLs have identical components.
142
165
  def equal?(other)
143
166
  to_ary == other.to_ary
144
167
  end
145
168
 
169
+ # Compare this URL with another for sorting purposes.
170
+ #
171
+ # @parameter other [Relative] The URL to compare with.
172
+ # @returns [Integer] -1, 0, or 1 based on component-wise comparison.
146
173
  def <=>(other)
147
174
  to_ary <=> other.to_ary
148
175
  end
149
176
 
177
+ # Check structural equality by comparing components.
178
+ #
179
+ # @parameter other [Relative] The URL to compare with.
180
+ # @returns [Boolean] True if the URLs have identical components.
150
181
  def ==(other)
151
182
  to_ary == other.to_ary
152
183
  end
153
184
 
185
+ # Check string equality, useful for case statements.
186
+ #
187
+ # @parameter other [String, Relative] The value to compare with.
188
+ # @returns [Boolean] True if the string representations match.
154
189
  def ===(other)
155
190
  to_s === other
156
191
  end
157
192
 
193
+ # Convert the URL to its string representation.
194
+ #
195
+ # @returns [String] The formatted URL string.
158
196
  def to_s
159
197
  append
160
198
  end
161
199
 
200
+ # Convert the URL to a JSON-compatible representation.
201
+ #
202
+ # @returns [String] The URL as a string.
162
203
  def as_json(...)
163
204
  to_s
164
205
  end
165
206
 
207
+ # Convert the URL to JSON.
208
+ #
209
+ # @returns [String] The JSON-encoded URL.
166
210
  def to_json(...)
167
211
  as_json.to_json(...)
168
212
  end
169
213
 
214
+ # Generate a human-readable representation for debugging.
215
+ #
216
+ # @returns [String] A string like `#<Protocol::URL::Relative /path?query#fragment>`.
170
217
  def inspect
171
218
  "#<#{self.class} #{to_s}>"
172
219
  end
@@ -7,6 +7,6 @@
7
7
  module Protocol
8
8
  # @namespace
9
9
  module URL
10
- VERSION = "0.4.0"
10
+ VERSION = "0.6.0"
11
11
  end
12
12
  end
data/lib/protocol/url.rb CHANGED
@@ -6,6 +6,8 @@
6
6
  require_relative "url/version"
7
7
  require_relative "url/pattern"
8
8
  require_relative "url/encoding"
9
+ require_relative "url/form_data/nested"
10
+ require_relative "url/form_data/parser"
9
11
  require_relative "url/reference"
10
12
  require_relative "url/relative"
11
13
  require_relative "url/absolute"
data/readme.md CHANGED
@@ -6,11 +6,11 @@ Provides abstractions for working with URLs.
6
6
 
7
7
  ## Usage
8
8
 
9
- Please see the [project documentation](https://github.com/socketry/protocol-url) for more details.
9
+ Please see the [project documentation](https://socketry.github.io/protocol-url/) for more details.
10
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.
11
+ - [Getting Started](https://socketry.github.io/protocol-url/guides/getting-started/index) - This guide explains how to get started with `protocol-url` for parsing, manipulating, and constructing URLs in Ruby.
12
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.
13
+ - [Working with References](https://socketry.github.io/protocol-url/guides/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
14
 
15
15
  ## Contributing
16
16
 
@@ -32,7 +32,16 @@ This project is best served by a collaborative and respectful environment. Treat
32
32
 
33
33
  ## Releases
34
34
 
35
- Please see the [project releases](https://github.com/socketry/protocol-urlreleases/index) for all releases.
35
+ Please see the [project releases](https://socketry.github.io/protocol-url/releases/index) for all releases.
36
+
37
+ ### v0.6.0
38
+
39
+ - Add `Protocol::URL::FormData::Parser` for incremental, limited parsing of `application/x-www-form-urlencoded` form data.
40
+ - Add `Protocol::URL::FormData::Nested` for consistently building nested form data while preserving absent and empty values.
41
+
42
+ ### v0.5.0
43
+
44
+ - Add `Protocol::URL::Encoding.decode_www_form` for decoding HTML form data where `+` represents a space.
36
45
 
37
46
  ### v0.4.0
38
47
 
data/releases.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Releases
2
2
 
3
+ ## v0.6.0
4
+
5
+ - Add `Protocol::URL::FormData::Parser` for incremental, limited parsing of `application/x-www-form-urlencoded` form data.
6
+ - Add `Protocol::URL::FormData::Nested` for consistently building nested form data while preserving absent and empty values.
7
+
8
+ ## v0.5.0
9
+
10
+ - Add `Protocol::URL::Encoding.decode_www_form` for decoding HTML form data where `+` represents a space.
11
+
3
12
  ## v0.4.0
4
13
 
5
14
  - Add comparison methods to `Protocol::URL::Relative` (and by inheritance to `Protocol::URL::Absolute`):
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: protocol-url
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -45,6 +45,8 @@ files:
45
45
  - lib/protocol/url.rb
46
46
  - lib/protocol/url/absolute.rb
47
47
  - lib/protocol/url/encoding.rb
48
+ - lib/protocol/url/form_data/nested.rb
49
+ - lib/protocol/url/form_data/parser.rb
48
50
  - lib/protocol/url/path.rb
49
51
  - lib/protocol/url/pattern.rb
50
52
  - lib/protocol/url/reference.rb
@@ -58,6 +60,7 @@ licenses:
58
60
  - MIT
59
61
  metadata:
60
62
  source_code_uri: https://github.com/socketry/protocol-url.git
63
+ documentation_uri: https://socketry.github.io/protocol-url/
61
64
  rdoc_options: []
62
65
  require_paths:
63
66
  - lib
@@ -72,7 +75,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
72
75
  - !ruby/object:Gem::Version
73
76
  version: '0'
74
77
  requirements: []
75
- rubygems_version: 3.7.2
78
+ rubygems_version: 4.0.10
76
79
  specification_version: 4
77
80
  summary: Provides abstractions for working with URLs.
78
81
  test_files: []
metadata.gz.sig CHANGED
Binary file