iri 0.9.0 → 0.11.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.
data/lib/iri.rb CHANGED
@@ -1,121 +1,145 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # (The MIT License)
4
- #
5
- # Copyright (c) 2019-2025 Yegor Bugayenko
6
- #
7
- # Permission is hereby granted, free of charge, to any person obtaining a copy
8
- # of this software and associated documentation files (the 'Software'), to deal
9
- # in the Software without restriction, including without limitation the rights
10
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
- # copies of the Software, and to permit persons to whom the Software is
12
- # furnished to do so, subject to the following conditions:
13
- #
14
- # The above copyright notice and this permission notice shall be included in all
15
- # copies or substantial portions of the Software.
16
- #
17
- # THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
- # SOFTWARE.
3
+ # SPDX-FileCopyrightText: Copyright (c) 2019-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
24
5
 
25
6
  require 'uri'
26
7
  require 'cgi'
27
8
 
28
- # It is a simple URI builder.
9
+ # Iri is a simple, immutable URI builder with a fluent interface.
10
+ #
11
+ # The Iri class provides methods to manipulate different parts of a URI,
12
+ # including the scheme, host, port, path, query parameters, and fragment.
13
+ # Each method returns a new Iri instance, maintaining immutability.
29
14
  #
30
- # require 'iri'
31
- # url = Iri.new('http://google.com/')
32
- # .add(q: 'books about OOP', limit: 50)
33
- # .del(:q) // remove this query parameter
34
- # .del('limit') // remove this one too
35
- # .over(q: 'books about tennis', limit: 10) // replace these params
36
- # .scheme('https')
37
- # .host('localhost')
38
- # .port('443')
39
- # .to_s
15
+ # @example Creating and manipulating a URI
16
+ # require 'iri'
17
+ # url = Iri.new('http://google.com/')
18
+ # .add(q: 'books about OOP', limit: 50)
19
+ # .del(:q) # remove this query parameter
20
+ # .del('limit') # remove this one too
21
+ # .over(q: 'books about tennis', limit: 10) # replace these params
22
+ # .scheme('https')
23
+ # .host('localhost')
24
+ # .port('443')
25
+ # .to_s
40
26
  #
41
- # For more information read
27
+ # @example Using the local option
28
+ # Iri.new('/path?foo=bar', local: true).to_s # => "/path?foo=bar"
29
+ #
30
+ # @example Using the safe mode
31
+ # Iri.new('invalid://uri', safe: true).to_s # => "/" (no exception thrown)
32
+ # Iri.new('invalid://uri', safe: false) # => raises Iri::InvalidURI
33
+ #
34
+ # For more information read the
42
35
  # {README}[https://github.com/yegor256/iri/blob/master/README.md] file.
43
36
  #
44
37
  # Author:: Yegor Bugayenko (yegor256@gmail.com)
45
38
  # Copyright:: Copyright (c) 2019-2025 Yegor Bugayenko
46
39
  # License:: MIT
47
40
  class Iri
48
- # When URI is not valid.
41
+ # Exception raised when a URI is not valid and safe mode is disabled.
49
42
  class InvalidURI < StandardError; end
50
43
 
51
- # When .add(), .over(), or .del() arguments are not valid.
44
+ # Exception raised when arguments to .add(), .over(), or .del() are not valid Hashes.
52
45
  class InvalidArguments < StandardError; end
53
46
 
54
- # Makes a new object.
47
+ # Creates a new Iri object for URI manipulation.
55
48
  #
56
- # You can even ignore the argument, which will produce an empty URI.
49
+ # You can even ignore the argument, which will produce an empty URI ("/").
57
50
  #
58
51
  # By default, this class will never throw any exceptions, even if your URI
59
- # is not valid. It will just assume that the URI is"/". However,
60
- # you can turn this mode off, by specifying safe as FALSE.
52
+ # is not valid. It will just assume that the URI is "/". However,
53
+ # you can turn this safe mode off by specifying safe as FALSE, which will
54
+ # cause InvalidURI to be raised if the URI is malformed.
61
55
  #
62
- # @param [String] uri URI
63
- # @param [Boolean] safe SHould it safe?
64
- def initialize(uri = '', safe: true)
56
+ # The local parameter can be used if you only want to work with the path,
57
+ # query, and fragment portions of a URI, without the scheme, host, and port.
58
+ #
59
+ # @param [String] uri URI string to parse
60
+ # @param [Boolean] local When true, ignores scheme, host and port parts
61
+ # @param [Boolean] safe When true, prevents InvalidURI exceptions
62
+ # @raise [InvalidURI] If the URI is malformed and safe is false
63
+ def initialize(uri = '', local: false, safe: true)
64
+ raise ArgumentError, "The uri can't be nil" if uri.nil?
65
65
  @uri = uri
66
+ @local = local
66
67
  @safe = safe
67
68
  end
68
69
 
69
- # Convert it to a string.
70
+ # Converts the Iri object to a string representation of the URI.
71
+ #
72
+ # When local mode is enabled, only the path, query, and fragment parts are included.
73
+ # Otherwise, the full URI including scheme, host, and port is returned.
70
74
  #
71
- # @return [String] New URI
75
+ # @return [String] String representation of the URI
72
76
  def to_s
73
- @uri.to_s
77
+ u = the_uri
78
+ if @local
79
+ [
80
+ u.path,
81
+ u.query ? "?#{u.query}" : '',
82
+ u.fragment ? "##{u.fragment}" : ''
83
+ ].join
84
+ else
85
+ u.to_s
86
+ end
74
87
  end
75
88
 
76
- # Inspect it, like a string can be inspected.
89
+ # Returns a string representation of the Iri object for inspection purposes.
77
90
  #
78
- # @return [String] Details of it
91
+ # This method is used when the object is displayed in irb/console or with puts/p.
92
+ #
93
+ # @return [String] String representation for inspection
79
94
  def inspect
80
95
  @uri.to_s.inspect
81
96
  end
82
97
 
83
- # Convert it to an object of class +URI+.
98
+ # Converts the Iri object to a Ruby standard library URI object.
84
99
  #
85
- # @return [String] New URI
100
+ # @return [URI] A cloned URI object from the underlying URI
86
101
  def to_uri
87
102
  the_uri.clone
88
103
  end
89
104
 
90
- # Removes the host, the port, and the scheme and returns
91
- # only the local address, for example, converting "https://google.com/foo"
92
- # into "/foo".
105
+ # Creates a new Iri object with only the local parts of the URI.
106
+ #
107
+ # Removes the host, the port, and the scheme, returning only the local address.
108
+ # For example, converting "https://google.com/foo" into "/foo".
109
+ # The path, query string, and fragment are preserved.
93
110
  #
94
- # @return [String] Local part of the URI
111
+ # @return [Iri] A new Iri object with local:true and the same URI
112
+ # @see #initialize
95
113
  def to_local
96
- u = the_uri
97
- [
98
- u.path,
99
- u.query ? "?#{u.query}" : '',
100
- u.fragment ? "##{u.fragment}" : ''
101
- ].join
114
+ Iri.new(@uri, local: true, safe: @safe)
102
115
  end
103
116
 
104
- # Add a few query arguments.
117
+ # Adds query parameters to the URI.
118
+ #
119
+ # This method appends query parameters to existing ones. If a parameter with the same
120
+ # name already exists, both values will be present in the resulting URI.
105
121
  #
106
- # For example:
122
+ # @example Adding query parameters
123
+ # Iri.new('https://google.com').add(q: 'test', limit: 10)
124
+ # # => "https://google.com?q=test&limit=10"
107
125
  #
108
- # Iri.new('https://google.com').add(q: 'test', limit: 10)
126
+ # @example Adding parameters with the same name
127
+ # Iri.new('https://google.com?q=foo').add(q: 'bar')
128
+ # # => "https://google.com?q=foo&q=bar"
109
129
  #
110
- # You can add many of them and they will all be present in the resulting
111
- # URI, even if their names are the same. In order to make sure you have
112
- # only one instance of a query argument, use +del+ first:
130
+ # You can ensure only one instance of a parameter by using +del+ first:
113
131
  #
114
- # Iri.new('https://google.com').del(:q).add(q: 'test')
132
+ # @example Replacing a parameter by deleting it first
133
+ # Iri.new('https://google.com?q=foo').del(:q).add(q: 'test')
134
+ # # => "https://google.com?q=test"
115
135
  #
116
- # @param [Hash] hash Hash of names/values to set into the query part
117
- # @return [Iri] A new iri
136
+ # @param [Hash] hash Hash of parameter names/values to add to the query part
137
+ # @return [Iri] A new Iri instance
138
+ # @raise [InvalidArguments] If the argument is not a Hash
139
+ # @see #del
140
+ # @see #over
118
141
  def add(hash)
142
+ raise ArgumentError, "The hash can't be nil" if hash.nil?
119
143
  raise InvalidArguments unless hash.is_a?(Hash)
120
144
  modify_query do |params|
121
145
  hash.each do |k, v|
@@ -124,15 +148,24 @@ class Iri
124
148
  end
125
149
  end
126
150
  end
151
+ alias with add
127
152
 
128
- # Delete a few query arguments.
153
+ # Deletes query parameters from the URI.
129
154
  #
130
- # For example:
155
+ # This method removes all instances of the specified parameters from the query string.
131
156
  #
132
- # Iri.new('https://google.com?q=test').del(:q)
157
+ # @example Deleting a query parameter
158
+ # Iri.new('https://google.com?q=test&limit=10').del(:q)
159
+ # # => "https://google.com?limit=10"
133
160
  #
134
- # @param [Array] keys List of keys to delete
135
- # @return [Iri] A new iri
161
+ # @example Deleting multiple parameters
162
+ # Iri.new('https://google.com?q=test&limit=10&sort=asc').del(:q, :limit)
163
+ # # => "https://google.com?sort=asc"
164
+ #
165
+ # @param [Array<Symbol, String>] keys List of parameter names to delete
166
+ # @return [Iri] A new Iri instance
167
+ # @see #add
168
+ # @see #over
136
169
  def del(*keys)
137
170
  modify_query do |params|
138
171
  keys.each do |k|
@@ -140,94 +173,164 @@ class Iri
140
173
  end
141
174
  end
142
175
  end
176
+ alias without del
143
177
 
144
- # Replace query argument(s).
178
+ # Replaces query parameters in the URI.
179
+ #
180
+ # Unlike #add, this method replaces any existing parameters with the same name
181
+ # rather than adding additional instances. If a parameter doesn't exist,
182
+ # it will be added.
145
183
  #
146
- # Iri.new('https://google.com?q=test').over(q: 'hey you!')
184
+ # @example Replacing a query parameter
185
+ # Iri.new('https://google.com?q=test').over(q: 'hey you!')
186
+ # # => "https://google.com?q=hey+you%21"
147
187
  #
148
- # @param [Hash] hash Hash of names/values to set into the query part
149
- # @return [Iri] A new iri
188
+ # @example Replacing multiple parameters
189
+ # Iri.new('https://google.com?q=test&limit=5').over(q: 'books', limit: 10)
190
+ # # => "https://google.com?q=books&limit=10"
191
+ #
192
+ # @param [Hash] hash Hash of parameter names/values to replace in the query part
193
+ # @return [Iri] A new Iri instance
194
+ # @raise [InvalidArguments] If the argument is not a Hash
195
+ # @see #add
196
+ # @see #del
150
197
  def over(hash)
198
+ raise ArgumentError, "The hash can't be nil" if hash.nil?
151
199
  raise InvalidArguments unless hash.is_a?(Hash)
152
200
  modify_query do |params|
153
201
  hash.each do |k, v|
154
- params[k.to_s] = [] unless params[k]
202
+ params[k.to_s] = [] unless params[k.to_s]
155
203
  params[k.to_s] = [v]
156
204
  end
157
205
  end
158
206
  end
159
207
 
160
- # Replace the scheme.
208
+ # Replaces the scheme part of the URI.
209
+ #
210
+ # @example Changing the scheme
211
+ # Iri.new('http://google.com').scheme('https')
212
+ # # => "https://google.com"
161
213
  #
162
214
  # @param [String] val New scheme to set, like "https" or "http"
163
- # @return [Iri] A new iri
215
+ # @return [Iri] A new Iri instance
216
+ # @see #host
217
+ # @see #port
164
218
  def scheme(val)
219
+ raise ArgumentError, "The scheme can't be nil" if val.nil?
165
220
  modify do |c|
166
221
  c.scheme = val
167
222
  end
168
223
  end
169
224
 
170
- # Replace the host.
225
+ # Replaces the host part of the URI.
226
+ #
227
+ # @example Changing the host
228
+ # Iri.new('https://google.com').host('example.com')
229
+ # # => "https://example.com"
171
230
  #
172
- # @param [String] val New host to set, like "google.com" or "192.168.0.1"
173
- # @return [Iri] A new iri
231
+ # @param [String] val New host to set, like "example.com" or "192.168.0.1"
232
+ # @return [Iri] A new Iri instance
233
+ # @see #scheme
234
+ # @see #port
174
235
  def host(val)
236
+ raise ArgumentError, "The host can't be nil" if val.nil?
175
237
  modify do |c|
176
238
  c.host = val
177
239
  end
178
240
  end
179
241
 
180
- # Replace the port.
242
+ # Replaces the port part of the URI.
243
+ #
244
+ # @example Changing the port
245
+ # Iri.new('https://example.com').port('8443')
246
+ # # => "https://example.com:8443"
181
247
  #
182
248
  # @param [String] val New TCP port to set, like "8080" or "443"
183
- # @return [Iri] A new iri
249
+ # @return [Iri] A new Iri instance
250
+ # @see #scheme
251
+ # @see #host
184
252
  def port(val)
253
+ raise ArgumentError, "The port can't be nil" if val.nil?
185
254
  modify do |c|
186
255
  c.port = val
187
256
  end
188
257
  end
189
258
 
190
- # Replace the path part of the URI.
259
+ # Replaces the path part of the URI.
260
+ #
261
+ # @example Changing the path
262
+ # Iri.new('https://example.com/foo').path('/bar/baz')
263
+ # # => "https://example.com/bar/baz"
191
264
  #
192
265
  # @param [String] val New path to set, like "/foo/bar"
193
- # @return [Iri] A new iri
266
+ # @return [Iri] A new Iri instance
267
+ # @see #query
268
+ # @see #fragment
194
269
  def path(val)
270
+ raise ArgumentError, "The path can't be nil" if val.nil?
195
271
  modify do |c|
196
272
  c.path = val
197
273
  end
198
274
  end
199
275
 
200
- # Replace the fragment part of the URI.
276
+ # Replaces the fragment part of the URI (the part after #).
201
277
  #
202
- # @param [String] val New fragment to set, like "hello"
203
- # @return [Iri] A new iri
278
+ # @example Setting a fragment
279
+ # Iri.new('https://example.com/page').fragment('section2')
280
+ # # => "https://example.com/page#section2"
281
+ #
282
+ # @param [String] val New fragment to set, like "section2"
283
+ # @return [Iri] A new Iri instance
284
+ # @see #path
285
+ # @see #query
204
286
  def fragment(val)
287
+ raise ArgumentError, "The fragment can't be nil" if val.nil?
205
288
  modify do |c|
206
289
  c.fragment = val.to_s
207
290
  end
208
291
  end
209
292
 
210
- # Replace the query part of the URI.
293
+ # Replaces the entire query part of the URI.
294
+ #
295
+ # Use this method to completely replace the query string. For modifying
296
+ # individual parameters, see #add, #del, and #over.
211
297
  #
212
- # @param [String] val New query to set, like "a=1&b=2"
213
- # @return [Iri] A new iri
298
+ # @example Setting a query string
299
+ # Iri.new('https://example.com/search').query('q=ruby&limit=10')
300
+ # # => "https://example.com/search?q=ruby&limit=10"
301
+ #
302
+ # @param [String] val New query string to set, like "a=1&b=2"
303
+ # @return [Iri] A new Iri instance
304
+ # @see #add
305
+ # @see #del
306
+ # @see #over
214
307
  def query(val)
308
+ raise ArgumentError, "The query can't be nil" if val.nil?
215
309
  modify do |c|
216
310
  c.query = val
217
311
  end
218
312
  end
219
313
 
220
- # Remove the entire path+query+fragment part.
314
+ # Removes the entire path, query, and fragment parts and sets a new path.
221
315
  #
222
- # For example:
316
+ # This method is useful for "cutting off" everything after the host:port
317
+ # and setting a new path, effectively removing query string and fragment.
223
318
  #
224
- # Iri.new('https://google.com/a/b?q=test').cut('/hello')
319
+ # @example Cutting off path/query/fragment and setting a new path
320
+ # Iri.new('https://google.com/a/b?q=test').cut('/hello')
321
+ # # => "https://google.com/hello"
225
322
  #
226
- # The result will contain "https://google.com/hello".
323
+ # @example Resetting to root path
324
+ # Iri.new('https://google.com/a/b?q=test#section2').cut()
325
+ # # => "https://google.com/"
227
326
  #
228
- # @param [String] path New path to set, like "/foo"
229
- # @return [Iri] A new iri
327
+ # @param [String] path New path to set, defaults to "/"
328
+ # @return [Iri] A new Iri instance
329
+ # @see #path
330
+ # @see #query
331
+ # @see #fragment
230
332
  def cut(path = '/')
333
+ raise ArgumentError, "The path can't be nil" if path.nil?
231
334
  modify do |c|
232
335
  c.query = nil
233
336
  c.path = path
@@ -235,17 +338,28 @@ class Iri
235
338
  end
236
339
  end
237
340
 
238
- # Append something new to the path.
341
+ # Appends a new segment to the existing path.
342
+ #
343
+ # This method adds a new segment to the existing path, automatically handling
344
+ # the slash between segments and URL encoding the new segment.
239
345
  #
240
- # For example:
346
+ # @example Appending a path segment
347
+ # Iri.new('https://example.com/a/b?q=test').append('hello')
348
+ # # => "https://example.com/a/b/hello?q=test"
241
349
  #
242
- # Iri.new('https://google.com/a/b?q=test').append('/hello')
350
+ # @example Appending to a path with a trailing slash
351
+ # Iri.new('https://example.com/a/').append('hello')
352
+ # # => "https://example.com/a/hello?q=test"
243
353
  #
244
- # The result will contain "https://google.com/a/b/hello?q=test".
354
+ # @example Appending a segment that needs URL encoding
355
+ # Iri.new('https://example.com/docs').append('section 1')
356
+ # # => "https://example.com/docs/section%201"
245
357
  #
246
- # @param [String] part New segment to add to existing path
247
- # @return [Iri] A new iri
358
+ # @param [String, #to_s] part New segment to add to the existing path
359
+ # @return [Iri] A new Iri instance
360
+ # @see #path
248
361
  def append(part)
362
+ raise ArgumentError, "The part can't be nil" if part.nil?
249
363
  modify do |c|
250
364
  tail = (c.path.end_with?('/') ? '' : '/') + CGI.escape(part.to_s)
251
365
  c.path = c.path + tail
@@ -254,6 +368,14 @@ class Iri
254
368
 
255
369
  private
256
370
 
371
+ # Parses the URI string into a URI object.
372
+ #
373
+ # This method handles the safe mode by catching and handling invalid URI errors.
374
+ # When safe mode is enabled (default), invalid URIs will return the root path URI "/"
375
+ # instead of raising an exception.
376
+ #
377
+ # @return [URI] The parsed URI object
378
+ # @raise [InvalidURI] If the URI is invalid and safe mode is disabled
257
379
  def the_uri
258
380
  @the_uri ||= URI(@uri)
259
381
  rescue URI::InvalidURIError => e
@@ -261,12 +383,27 @@ class Iri
261
383
  @the_uri = URI('/')
262
384
  end
263
385
 
386
+ # Creates a new Iri object after modifying the underlying URI.
387
+ #
388
+ # This helper method clones the current URI, yields it to a block for modification,
389
+ # and then creates a new Iri object with the modified URI, preserving the local and safe flags.
390
+ #
391
+ # @yield [URI] The cloned URI object for modification
392
+ # @return [Iri] A new Iri instance with the modified URI
264
393
  def modify
265
394
  c = the_uri.clone
266
395
  yield c
267
- Iri.new(c)
396
+ Iri.new(c, local: @local, safe: @safe)
268
397
  end
269
398
 
399
+ # Creates a new Iri object after modifying the query parameters.
400
+ #
401
+ # This helper method parses the current query string into a hash of parameter names
402
+ # to arrays of values, yields this hash for modification, and then encodes it back
403
+ # into a query string. It uses the modify method to create a new Iri object.
404
+ #
405
+ # @yield [Hash] The parsed query parameters for modification
406
+ # @return [Iri] A new Iri instance with the modified query string
270
407
  def modify_query
271
408
  modify do |c|
272
409
  params = CGI.parse(the_uri.query || '').map do |p, a|
data/test/test__helper.rb CHANGED
@@ -1,26 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright (c) 2019-2025 Yegor Bugayenko
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 NONINFINGEMENT. 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.
3
+ # SPDX-FileCopyrightText: Copyright (c) 2019-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
22
5
 
23
6
  $stdout.sync = true
24
7
 
25
8
  require 'simplecov'
26
- SimpleCov.start
9
+ require 'simplecov-cobertura'
10
+ unless SimpleCov.running
11
+ SimpleCov.command_name('test')
12
+ SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new(
13
+ [
14
+ SimpleCov::Formatter::HTMLFormatter,
15
+ SimpleCov::Formatter::CoberturaFormatter
16
+ ]
17
+ )
18
+ SimpleCov.minimum_coverage 100
19
+ SimpleCov.minimum_coverage_by_file 100
20
+ SimpleCov.start do
21
+ add_filter 'test/'
22
+ add_filter 'vendor/'
23
+ add_filter 'target/'
24
+ track_files 'lib/**/*.rb'
25
+ track_files '*.rb'
26
+ end
27
+ end
28
+
29
+ require 'minitest/autorun'
30
+ require 'minitest/reporters'
31
+ Minitest::Reporters.use! [Minitest::Reporters::SpecReporter.new]