protocol-url 0.10.0 → 0.12.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.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "pattern"
7
7
  require_relative "encoding"
@@ -21,8 +21,8 @@ module Protocol
21
21
  #
22
22
  # This method provides flexible conversion from various types into a {Reference}.
23
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.
24
+ # and preserves the path as a {Path}. When given a {Relative}, it preserves the
25
+ # existing path and its component boundaries.
26
26
  #
27
27
  # @parameter value [String | Relative | Nil] The value to coerce.
28
28
  # @parameter parameters [Hash | Nil] Optional user-supplied parameters to append to the query string.
@@ -34,7 +34,7 @@ module Protocol
34
34
  #
35
35
  # @example Coerce a string with path, query, and fragment.
36
36
  # reference = Reference["/search?q=ruby#results"]
37
- # reference.path # => "/search"
37
+ # reference.path.to_s # => "/search"
38
38
  # reference.query # => "q=ruby"
39
39
  # reference.fragment # => "results"
40
40
  #
@@ -45,7 +45,7 @@ module Protocol
45
45
  # @example Coerce a Relative instance.
46
46
  # relative = Relative.new("/path%20with%20spaces", nil, "top")
47
47
  # reference = Reference[relative]
48
- # reference.path # => "/path with spaces"
48
+ # reference.path.components # => ["", "path with spaces"]
49
49
  def self.[](value, parameters = nil)
50
50
  case value
51
51
  when String
@@ -54,9 +54,8 @@ module Protocol
54
54
  query = match[:query]
55
55
  fragment = match[:fragment]
56
56
 
57
- # Unescape path and fragment for user-friendly internal storage:
58
- # Query strings are kept as-is since they contain = and & syntax
59
- path = Encoding.unescape(path) if path && !path.empty?
57
+ # Paths retain their encoded structure while exposing decoded components.
58
+ path = Path[path]
60
59
  fragment = Encoding.unescape(fragment) if fragment
61
60
 
62
61
  self.new(path, query, fragment, parameters)
@@ -64,11 +63,9 @@ module Protocol
64
63
  raise ArgumentError, "Invalid URL (contains whitespace or control characters): #{value.inspect}"
65
64
  end
66
65
  when Relative
67
- # Relative stores encoded values, so we need to unescape them for Reference:
66
+ # Relative stores an encoded path; preserve its component boundaries.
68
67
  path = value.path
69
68
  fragment = value.fragment
70
-
71
- path = Encoding.unescape(path) if path && !path.empty?
72
69
  fragment = Encoding.unescape(fragment) if fragment
73
70
 
74
71
  self.new(path, value.query, fragment, parameters)
@@ -77,20 +74,22 @@ module Protocol
77
74
  else
78
75
  raise ArgumentError, "Cannot coerce #{value.inspect} to Reference!"
79
76
  end
80
- end # Generate a reference from a path and user parameters. The path may contain a `#fragment` or `?query=parameters`.
77
+ end
78
+
79
+ # Generate a reference from a path and user parameters. The path may contain a `#fragment` or `?query=parameters`.
81
80
  #
82
81
  # @example Parse a path with query and fragment.
83
82
  # reference = Reference.parse("/search?query=ruby#results")
84
- # reference.path # => "/search"
83
+ # reference.path.to_s # => "/search"
85
84
  # reference.query # => "query=ruby"
86
85
  # reference.fragment # => "results"
87
86
  def self.parse(value = "/", parameters = nil)
88
87
  self.[](value, parameters)
89
88
  end
90
89
 
91
- # Initialize the reference with raw, unescaped values.
90
+ # Initialize the reference from an encoded path and reference values.
92
91
  #
93
- # @parameter path [String] The unescaped path.
92
+ # @parameter path [String | Path] The encoded path string, or an existing path.
94
93
  # @parameter query [String | Nil] An already-formatted query string.
95
94
  # @parameter fragment [String | Nil] The unescaped fragment.
96
95
  # @parameter parameters [Hash | Nil] User supplied parameters that will be safely encoded.
@@ -103,8 +102,8 @@ module Protocol
103
102
  @parameters = parameters
104
103
  end
105
104
 
106
- # @attribute [Hash] User supplied parameters that will be appended to the query part.
107
- attr :parameters
105
+ # @attribute [Hash | Nil] User supplied parameters that will be appended to the query part.
106
+ attr_accessor :parameters
108
107
 
109
108
  # Freeze the reference.
110
109
  #
@@ -161,10 +160,10 @@ module Protocol
161
160
  end
162
161
 
163
162
  # Append the reference to the given buffer.
164
- # Encodes the path and fragment which are stored unescaped internally.
163
+ # Encodes the fragment; the path already retains its encoded structure.
165
164
  # Query strings are passed through as-is (they contain = and & which are valid syntax).
166
165
  def append(buffer = String.new)
167
- buffer << Encoding.escape_path(@path)
166
+ buffer << @path.encoded
168
167
 
169
168
  if @query and !@query.empty?
170
169
  buffer << "?" << @query
@@ -185,7 +184,7 @@ module Protocol
185
184
  other = self.class[other]
186
185
 
187
186
  self.class.new(
188
- Path.expand(self.path, other.path, true),
187
+ @path.join(other.path),
189
188
  other.query,
190
189
  other.fragment,
191
190
  other.parameters,
@@ -199,7 +198,7 @@ module Protocol
199
198
 
200
199
  # Update the reference with the given path, query, fragment, and parameters.
201
200
  #
202
- # @parameter path [String] Append the string to this reference similar to `File.join`.
201
+ # @parameter path [String | Path] Append the encoded path to this reference similar to `File.join`.
203
202
  # @parameter query [String | Nil] Replace the query string. Defaults to keeping the existing query if not specified.
204
203
  # @parameter fragment [String | Nil] Replace the fragment. Defaults to keeping the existing fragment if not specified.
205
204
  # @parameter parameters [Hash | false] Parameters to merge or replace. Pass `false` (default) to keep existing parameters.
@@ -251,7 +250,11 @@ module Protocol
251
250
  end
252
251
  end
253
252
 
254
- path = Path.expand(@path, path, pop)
253
+ if path.nil?
254
+ path = @path
255
+ else
256
+ path = @path.join(path, pop: pop)
257
+ end
255
258
 
256
259
  self.class.new(path, query, fragment, parameters)
257
260
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "encoding"
7
7
  require_relative "path"
@@ -14,31 +14,54 @@ module Protocol
14
14
 
15
15
  # Initialize a new relative URL.
16
16
  #
17
- # @parameter path [String] The path component.
18
- # @parameter query [String, nil] The query string.
19
- # @parameter fragment [String, nil] The fragment identifier.
17
+ # @parameter path [String | Path] The encoded path component.
18
+ # @parameter query [String | Nil] The query string.
19
+ # @parameter fragment [String | Nil] The fragment identifier.
20
20
  def initialize(path, query = nil, fragment = nil)
21
- @path = path.to_s
21
+ @path = Path[path]
22
22
  @query = query
23
23
  @fragment = fragment
24
24
  end
25
25
 
26
- # @attribute [String] The path component of the URL.
26
+ # Freeze the URL and its direct components.
27
+ # @returns [Relative] The frozen URL.
28
+ def freeze
29
+ return self if frozen?
30
+
31
+ @path.freeze
32
+ @query.freeze
33
+ @fragment.freeze
34
+
35
+ return super
36
+ end
37
+
38
+ # @attribute [Path] The path component of the URL.
27
39
  attr :path
28
40
 
29
- # @attribute [String, nil] The query string component.
30
- attr :query
41
+ # Replace the path component of this URL.
42
+ # @parameter path [String | Path] The encoded path component.
43
+ # @returns [Path] The assigned path component.
44
+ def path=(path)
45
+ @path = Path[path]
46
+ end
31
47
 
32
- # @attribute [String, nil] The fragment identifier.
33
- attr :fragment
48
+ # @attribute [String | Nil] The query string component.
49
+ attr_accessor :query
34
50
 
35
- # Convert the URL path to a local filesystem path.
51
+ # @attribute [String | Nil] The fragment identifier.
52
+ attr_accessor :fragment
53
+
54
+ # Resolve the URL path beneath a local filesystem root.
36
55
  #
37
- # @returns [String] The local filesystem path.
38
- def to_local_path
39
- Path.to_local_path(@path)
56
+ # @parameter root [String] The filesystem root beneath which to resolve the URL path.
57
+ # @returns [String] The expanded local filesystem path.
58
+ # @raises [ArgumentError] If a URL segment is invalid or the path escapes the specified root.
59
+ def local_path(root)
60
+ @path.local_path(root)
40
61
  end
41
62
 
63
+ alias to_local_path local_path
64
+
42
65
  # @returns [Boolean] If there is a query string.
43
66
  def query?
44
67
  @query and !@query.empty?
@@ -58,13 +81,13 @@ module Protocol
58
81
  # base = Relative.new("/documents/reports/")
59
82
  # other = Relative.new("invoices/2024.pdf")
60
83
  # result = base + other
61
- # result.path # => "/documents/reports/invoices/2024.pdf"
84
+ # result.path.to_s # => "/documents/reports/invoices/2024.pdf"
62
85
  #
63
86
  # @example Navigate to parent directory.
64
87
  # base = Relative.new("/documents/reports/archive/")
65
88
  # other = Relative.new("../../summary.pdf")
66
89
  # result = base + other
67
- # result.path # => "/documents/summary.pdf"
90
+ # result.path.to_s # => "/documents/summary.pdf"
68
91
  def +(other)
69
92
  case other
70
93
  when Absolute
@@ -74,7 +97,7 @@ module Protocol
74
97
  when Relative
75
98
  # Relative + Relative: merge paths directly
76
99
  self.class.new(
77
- Path.expand(self.path, other.path, true),
100
+ @path.join(other.path),
78
101
  other.query,
79
102
  other.fragment
80
103
  )
@@ -88,9 +111,9 @@ module Protocol
88
111
 
89
112
  # Create a new Relative URL with modified components.
90
113
  #
91
- # @parameter path [String, nil] The path to merge with the current path.
92
- # @parameter query [String, nil] The query string to use.
93
- # @parameter fragment [String, nil] The fragment to use.
114
+ # @parameter path [String | Nil] The path to merge with the current path.
115
+ # @parameter query [String | Nil] The query string to use.
116
+ # @parameter fragment [String | Nil] The fragment to use.
94
117
  # @parameter pop [Boolean] Whether to pop the last path component before merging.
95
118
  # @returns [Relative] A new Relative URL with the modified components.
96
119
  #
@@ -104,7 +127,9 @@ module Protocol
104
127
  # updated = url.with(path: "report.pdf", pop: false)
105
128
  # updated.to_s # => "/documents/report.pdf"
106
129
  def with(path: nil, query: @query, fragment: @fragment, pop: true)
107
- self.class.new(Path.expand(@path, path, pop), query, fragment)
130
+ path = @path.join(path, pop: pop) unless path.nil?
131
+
132
+ self.class.new(path || @path, query, fragment)
108
133
  end
109
134
 
110
135
  # Normalize the path by resolving "." and ".." segments and removing duplicate slashes.
@@ -119,11 +144,9 @@ module Protocol
119
144
  # @example Basic normalization
120
145
  # url = Relative.new("/foo//bar/./baz/../qux")
121
146
  # url.normalize!
122
- # url.path # => "/foo/bar/qux"
147
+ # url.path.to_s # => "/foo/bar/qux"
123
148
  def normalize!
124
- components = Path.split(@path)
125
- normalized = Path.simplify(components)
126
- @path = Path.join(normalized)
149
+ @path = @path.simplify
127
150
 
128
151
  return self
129
152
  end
@@ -131,7 +154,7 @@ module Protocol
131
154
  # Append the relative URL to the given buffer.
132
155
  # The path, query, and fragment are expected to already be properly encoded.
133
156
  def append(buffer = String.new)
134
- buffer << @path
157
+ buffer << @path.encoded
135
158
 
136
159
  if @query and !@query.empty?
137
160
  buffer << "?" << @query
@@ -217,6 +240,7 @@ module Protocol
217
240
  def inspect
218
241
  "#<#{self.class} #{to_s}>"
219
242
  end
243
+
220
244
  end
221
245
  end
222
246
  end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  # @namespace
7
7
  module Protocol
8
8
  # @namespace
9
9
  module URL
10
- VERSION = "0.10.0"
10
+ VERSION = "0.12.0"
11
11
  end
12
12
  end
data/lib/protocol/url.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "url/version"
7
7
  require_relative "url/error"
data/license.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MIT License
2
2
 
3
- Copyright, 2025, by Samuel Williams.
3
+ Copyright, 2025-2026, by Samuel Williams.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/notes.md ADDED
@@ -0,0 +1,17 @@
1
+ # Follow-up Notes
2
+
3
+ ## Update `low-rb/low_loop`
4
+
5
+ File a follow-up PR against [`low-rb/low_loop`](https://github.com/low-rb/low_loop). Its file server currently uses the removed `Protocol::URL::Path.to_local_path` API in `lib/servers/file_server.rb`:
6
+
7
+ ```ruby
8
+ filepath = Protocol::URL::Path.to_local_path(Protocol::URL[event.request.path].path)
9
+ ```
10
+
11
+ Update it to use the `Path` instance returned by `URL#path`:
12
+
13
+ ```ruby
14
+ filepath = Protocol::URL[event.request.path].path.local_path(web_root)
15
+ ```
16
+
17
+ This call is security-sensitive because it converts an untrusted request path into a filesystem path. Confirm that the updated code passes `web_root` directly to `local_path`, handles traversal errors, and has an explicit policy for symlinks beneath the served root.
data/readme.md CHANGED
@@ -16,11 +16,121 @@ Please see the [project documentation](https://socketry.github.io/protocol-url/)
16
16
 
17
17
  We welcome contributions to this project.
18
18
 
19
- 1. Fork it.
19
+ 1. Fork the repository.
20
20
  2. Create your feature branch (`git checkout -b my-new-feature`).
21
- 3. Commit your changes (`git commit -am 'Add some feature'`).
21
+ 3. Commit your changes (`git commit -am 'Add some feature.'`).
22
22
  4. Push to the branch (`git push origin my-new-feature`).
23
- 5. Create new Pull Request.
23
+ 5. Create a new pull request.
24
+
25
+ ### Running Tests
26
+
27
+ To run the test suite:
28
+
29
+ ``` shell
30
+ bundle exec sus
31
+ ```
32
+
33
+ ### Making Releases
34
+
35
+ Please see the [project releases](https://socketry.github.io/protocol-url/releases/index) for all releases.
36
+
37
+ ### v0.12.0
38
+
39
+ - Allow unfrozen relative and absolute URLs to replace their components.
40
+
41
+ ### v0.10.0
42
+
43
+ - Rename `Protocol::URL::FormData::Parser::CONTENT_TYPE` to `MEDIA_TYPE`.
44
+
45
+ ### v0.9.0
46
+
47
+ - Add `Protocol::URL::LimitError` for configured processing limits.
48
+
49
+ ### v0.8.0
50
+
51
+ - Use consistent limit naming for form data parser constraints.
52
+
53
+ ### v0.7.0
54
+
55
+ - Allow `Protocol::URL::FormData::Parser#parse` to populate a supplied result object.
56
+
57
+ ### v0.6.0
58
+
59
+ - Add `Protocol::URL::FormData::Parser` for incremental, limited parsing of `application/x-www-form-urlencoded` form data.
60
+ - Add `Protocol::URL::FormData::Nested` for consistently building nested form data while preserving absent and empty values.
61
+
62
+ ### v0.5.0
63
+
64
+ - Add `Protocol::URL::Encoding.decode_www_form` for decoding HTML form data where `+` represents a space.
65
+
66
+ ### v0.4.0
67
+
68
+ - Add comparison methods to `Protocol::URL::Relative` (and by inheritance to `Protocol::URL::Absolute`):
69
+ - `#==` for structural equality comparison (compares path, query, fragment components).
70
+ - `#===` for string equality comparison (enables case statement matching).
71
+ - `#<=>` for ordering and sorting.
72
+ - `#hash` for hash key support.
73
+ - `#equal?` for component-based equality checking.
74
+ - Add JSON serialization support to `Protocol::URL::Relative`:
75
+ - `#as_json` returns the string representation.
76
+ - `#to_json` returns a JSON-encoded string.
77
+
78
+ ### v0.3.0
79
+
80
+ - Add `relative(target, from)` for computing relative paths between URLs.
81
+
82
+ ### v0.2.0
83
+
84
+ - Move `Protocol::URL::PATTERN` to `protocol/url/pattern.rb` so it can be shared more easily.
85
+
86
+ ### v0.10.0
87
+
88
+ - Rename `Protocol::URL::FormData::Parser::CONTENT_TYPE` to `MEDIA_TYPE`.
89
+
90
+ ### v0.9.0
91
+
92
+ - Add `Protocol::URL::LimitError` for configured processing limits.
93
+
94
+ ### v0.8.0
95
+
96
+ - Use consistent limit naming for form data parser constraints.
97
+
98
+ ### v0.7.0
99
+
100
+ - Allow `Protocol::URL::FormData::Parser#parse` to populate a supplied result object.
101
+
102
+ ### v0.6.0
103
+
104
+ - Add `Protocol::URL::FormData::Parser` for incremental, limited parsing of `application/x-www-form-urlencoded` form data.
105
+ - Add `Protocol::URL::FormData::Nested` for consistently building nested form data while preserving absent and empty values.
106
+
107
+ ### v0.5.0
108
+
109
+ - Add `Protocol::URL::Encoding.decode_www_form` for decoding HTML form data where `+` represents a space.
110
+
111
+ ### v0.4.0
112
+
113
+ - Add comparison methods to `Protocol::URL::Relative` (and by inheritance to `Protocol::URL::Absolute`):
114
+ - `#==` for structural equality comparison (compares path, query, fragment components).
115
+ - `#===` for string equality comparison (enables case statement matching).
116
+ - `#<=>` for ordering and sorting.
117
+ - `#hash` for hash key support.
118
+ - `#equal?` for component-based equality checking.
119
+ - Add JSON serialization support to `Protocol::URL::Relative`:
120
+ - `#as_json` returns the string representation.
121
+ - `#to_json` returns a JSON-encoded string.
122
+
123
+ ### v0.3.0
124
+
125
+ - Add `relative(target, from)` for computing relative paths between URLs.
126
+
127
+ ### v0.2.0
128
+
129
+ - Move `Protocol::URL::PATTERN` to `protocol/url/pattern.rb` so it can be shared more easily.
130
+
131
+ ### v0.1.0
132
+
133
+ - Initial implementation.
24
134
 
25
135
  ### Developer Certificate of Origin
26
136
 
data/releases.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Releases
2
2
 
3
+ ## v0.12.0
4
+
5
+ - Allow unfrozen relative and absolute URLs to replace their components.
6
+
3
7
  ## v0.10.0
4
8
 
5
9
  - Rename `Protocol::URL::FormData::Parser::CONTENT_TYPE` to `MEDIA_TYPE`.
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.10.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -54,14 +54,17 @@ files:
54
54
  - lib/protocol/url/relative.rb
55
55
  - lib/protocol/url/version.rb
56
56
  - license.md
57
+ - notes.md
57
58
  - readme.md
58
59
  - releases.md
59
60
  homepage: https://github.com/socketry/protocol-url
60
61
  licenses:
61
62
  - MIT
62
63
  metadata:
63
- source_code_uri: https://github.com/socketry/protocol-url.git
64
+ bug_tracker_uri: https://github.com/socketry/protocol-url/issues
65
+ changelog_uri: https://github.com/socketry/protocol-url/blob/main/releases.md
64
66
  documentation_uri: https://socketry.github.io/protocol-url/
67
+ source_code_uri: https://github.com/socketry/protocol-url.git
65
68
  rdoc_options: []
66
69
  require_paths:
67
70
  - lib
@@ -69,7 +72,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
69
72
  requirements:
70
73
  - - ">="
71
74
  - !ruby/object:Gem::Version
72
- version: '3.2'
75
+ version: '3.3'
73
76
  required_rubygems_version: !ruby/object:Gem::Requirement
74
77
  requirements:
75
78
  - - ">="
metadata.gz.sig CHANGED
Binary file