url_canonicalize 1.0.2 → 2.0.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: 6b8529a2f607ea6c3be592b855fa12bb79256bc62a11dfb94aa7b2d6aec1b45f
4
- data.tar.gz: 54a13bd40248b52cab8b3058fe04216bef20598896431cd90dd7e4cb4089d67f
3
+ metadata.gz: 503c645a9625985b4391f2743587f148d750d09d43d473c7a9923be2a2ec8c7f
4
+ data.tar.gz: 3983c156ec6754bac592880a968a07af984a5c11109ab31d096376c0774584a2
5
5
  SHA512:
6
- metadata.gz: 76310623c993c4550972e0b08bb28516c1673d246f3b0173738fbdb8e65f416d6375b7174d83e513d640a7c71c56de06fc115e2e91bb37a3d1e583c12a0231a6
7
- data.tar.gz: a68a7ee377c1cf90d162ee44a76c54a01936e0e1ace7055c0cd084a89bf3ade561060651aed21231f23a8e4d0cc129384534d9b9ced4c7dffb7e2a0bbaef7d9d
6
+ metadata.gz: b85dc8c4af5866b0bd9d79c92181672d3401419587f0d6868fdce28cfc2c1e973c86e208c633d3062b09dcad9e9e4794badff12b157c5e6cf0a3a95a0426f041
7
+ data.tar.gz: 498beaf7ca0e0a56d0f2f9b3280c42b7417ff8a14f38453ae757a480cd78388e5391ed7c3896a596648796226be8e3ee745446a412217a51f36b3025bf2a6295
data/CHANGELOG.md CHANGED
@@ -8,6 +8,60 @@ and this project adheres to
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [2.0.0] - 2026-07-25
12
+
13
+ ### Added
14
+
15
+ - `URLCanonicalize::Client`, a reusable, thread-safe client whose options are
16
+ validated and frozen at construction; request headers, a logger and the
17
+ transport are configurable, so tests can inject a fake transport or
18
+ resolver
19
+ ([#186](https://github.com/dominicsayers/url_canonicalize/issues/186))
20
+ - Documented, table-tested URL normalization per RFC 3986: lowercase scheme
21
+ and host, punycode hosts, normalized percent-encoding, resolved dot
22
+ segments, removed default ports and stripped fragments, applied to the
23
+ input, every hop and the returned URL
24
+ ([#187](https://github.com/dominicsayers/url_canonicalize/issues/187))
25
+ - SonarQube quality gate badge in the README
26
+ ([#196](https://github.com/dominicsayers/url_canonicalize/issues/196))
27
+
28
+ ### Changed
29
+
30
+ - **Breaking:** the core-class extensions (`String#canonicalize` and friends)
31
+ are opt-in via `require 'url_canonicalize/core_ext'`; requiring the gem
32
+ alone no longer patches anything
33
+ ([#186](https://github.com/dominicsayers/url_canonicalize/issues/186))
34
+ - **Breaking:** `URLCanonicalize.fetch` returns an immutable
35
+ `URLCanonicalize::Result` exposing the canonical URL, the confirming
36
+ response, the parsed HTML, the request chain and the discovery source
37
+ ([#186](https://github.com/dominicsayers/url_canonicalize/issues/186))
38
+ - **Breaking:** returned URLs are normalized (an empty path becomes `/`) and
39
+ a scheme is recognized only as a leading `scheme:` token, so a colon later
40
+ in a bare host's path no longer causes a parse error
41
+ ([#187](https://github.com/dominicsayers/url_canonicalize/issues/187))
42
+ - **Breaking:** Ruby 3.3 or later is required; CI tests Ruby 3.3, 3.4 and
43
+ 4.0 on GitHub Actions and the Travis and CircleCI configurations are gone
44
+ ([#188](https://github.com/dominicsayers/url_canonicalize/issues/188))
45
+ - Debug output moved from the `DEBUG` environment variable to the `logger`
46
+ option
47
+ ([#186](https://github.com/dominicsayers/url_canonicalize/issues/186))
48
+ - Update nokogiri to 1.19.4, fixing all eleven open Dependabot alerts
49
+ ([#210](https://github.com/dominicsayers/url_canonicalize/issues/210))
50
+ - Replace the `bin/release` scripts with the
51
+ [`release_ceremony`](https://github.com/dominicsayers/release_ceremony) gem
52
+ they were extracted into; the release commands are now
53
+ `bundle exec release_ceremony prepare|publish VERSION`
54
+
55
+ ### Removed
56
+
57
+ - Hound and CodeClimate configuration; RuboCop in CI and SonarQube Cloud
58
+ cover static analysis
59
+ ([#211](https://github.com/dominicsayers/url_canonicalize/issues/211),
60
+ [#195](https://github.com/dominicsayers/url_canonicalize/issues/195))
61
+ - The RuboCop to-do file; its offences are fixed and the deliberate spec
62
+ limits are explicit policy in `.rubocop.yml`
63
+ ([#198](https://github.com/dominicsayers/url_canonicalize/issues/198))
64
+
11
65
  ## [1.0.2] - 2026-07-24
12
66
 
13
67
  ### Fixed
@@ -89,7 +143,8 @@ changelog format. Their history is available from the
89
143
  and the
90
144
  [commit log](https://github.com/dominicsayers/url_canonicalize/commits/v0.1.15).
91
145
 
92
- [Unreleased]: https://github.com/dominicsayers/url_canonicalize/compare/v1.0.2...HEAD
146
+ [Unreleased]: https://github.com/dominicsayers/url_canonicalize/compare/v2.0.0...HEAD
147
+ [2.0.0]: https://github.com/dominicsayers/url_canonicalize/compare/v1.0.2...v2.0.0
93
148
  [1.0.2]: https://github.com/dominicsayers/url_canonicalize/compare/v1.0.1...v1.0.2
94
149
  [1.0.1]: https://github.com/dominicsayers/url_canonicalize/compare/v1.0.0...v1.0.1
95
150
  [1.0.0]: https://github.com/dominicsayers/url_canonicalize/compare/v0.2.1...v1.0.0
data/README.md CHANGED
@@ -1,14 +1,15 @@
1
1
  # URLCanonicalize
2
2
  [![Gem Version](https://img.shields.io/gem/v/url_canonicalize.svg)](https://rubygems.org/gems/url_canonicalize)
3
3
  [![Gem downloads](https://img.shields.io/gem/dt/url_canonicalize.svg)](https://rubygems.org/gems/url_canonicalize)
4
- [![CircleCI](https://dl.circleci.com/status-badge/img/gh/dominicsayers/url_canonicalize/tree/main.svg?style=shield)](https://dl.circleci.com/status-badge/redirect/gh/dominicsayers/url_canonicalize/tree/main)
4
+ [![Test](https://github.com/dominicsayers/url_canonicalize/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/dominicsayers/url_canonicalize/actions/workflows/test.yml)
5
5
  [![CodeQL](https://github.com/dominicsayers/url_canonicalize/actions/workflows/codeql-analysis.yml/badge.svg?branch=main)](https://github.com/dominicsayers/url_canonicalize/actions/workflows/codeql-analysis.yml)
6
+ [![Quality gate status](https://sonarcloud.io/api/project_badges/measure?project=dominicsayers_url_canonicalize&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=dominicsayers_url_canonicalize)
6
7
 
7
8
  URLCanonicalize is a Ruby gem that finds the canonical version of a URL. It
8
- provides `canonicalize` methods for the String, URI::HTTP, URI::HTTPS and
9
- Addressable::URI classes.
9
+ provides a configurable client API, and optional `canonicalize` extension
10
+ methods for the String, URI::HTTP, URI::HTTPS and Addressable::URI classes.
10
11
 
11
- Ruby 3.1 or later is required.
12
+ Ruby 3.3 or later is required.
12
13
 
13
14
  ## Installation
14
15
 
@@ -20,33 +21,123 @@ gem 'url_canonicalize'
20
21
 
21
22
  ## Usage
22
23
 
23
- Requiring the gem extends `String`, `URI::HTTP`, `URI::HTTPS` and
24
- `Addressable::URI` with a `canonicalize` method:
24
+ The primary API is a reusable client. Its options are validated and frozen at
25
+ construction, so one client can be shared safely between threads:
25
26
 
26
27
  ```ruby
27
- 'http://www.twitter.com'.canonicalize # => 'https://twitter.com/'
28
+ client = URLCanonicalize::Client.new(
29
+ total_timeout: 10,
30
+ max_redirects: 8,
31
+ max_body_bytes: 512_000
32
+ )
28
33
 
29
- URI('http://www.twitter.com').canonicalize # => #<URI::HTTP:0x00000008767908 URL:https://twitter.com/>
34
+ client.canonicalize('http://www.twitter.com') # => 'https://twitter.com/'
35
+ ```
30
36
 
31
- Addressable::URI.canonicalize('http://www.twitter.com') # => #<Addressable::URI:0x43c9 URI:https://twitter.com/>
37
+ For one-off calls the module methods accept the same options and build a
38
+ client internally:
39
+
40
+ ```ruby
41
+ URLCanonicalize.canonicalize('http://www.twitter.com') # => 'https://twitter.com/'
42
+ URLCanonicalize.fetch('http://www.twitter.com') # => #<URLCanonicalize::Result ...>
32
43
  ```
33
44
 
34
- Options can be passed to the module API or any of the `canonicalize` extension
35
- methods:
45
+ `canonicalize` returns the canonical URL as a string. `fetch` returns an
46
+ immutable `URLCanonicalize::Result` with:
47
+
48
+ - `url` — the canonical URL;
49
+ - `response` — the `Net::HTTPResponse` that confirmed it;
50
+ - `html` — the parsed Nokogiri document when the response was HTML;
51
+ - `chain` — the request chain: each hop's URL and how it was discovered
52
+ (`:initial`, `:redirect` or `:canonical_link`);
53
+ - `source` — how the final URL was discovered.
54
+
55
+ ### Configuration
56
+
57
+ Beyond the security limits described below, the client accepts:
58
+
59
+ - `headers` — the request headers to send (replaces the default browser-like
60
+ `User-Agent` and `Accept-Language`);
61
+ - `logger` — any object responding to `debug`; each request and discovered
62
+ canonical URL is logged. Nothing is logged by default;
63
+ - `transport` — anything responding to `call(uri, options)` and returning a
64
+ `Net::HTTP`-compatible connection. The default transport resolves and
65
+ validates the destination address (see below) and configures TLS and
66
+ timeouts. Tests can inject a fake transport to avoid the network entirely;
67
+ a custom resolver can be injected with
68
+ `URLCanonicalize::Transport.new(resolver: ...)`.
69
+
70
+ ### Core-class extensions
71
+
72
+ The core-class extensions are opt-in. Requiring `url_canonicalize/core_ext`
73
+ extends `String`, `URI::HTTP`, `URI::HTTPS` and `Addressable::URI` with a
74
+ `canonicalize` method:
36
75
 
37
76
  ```ruby
38
- URLCanonicalize.fetch(
39
- 'https://example.com/article',
40
- total_timeout: 10,
41
- max_body_bytes: 512_000
42
- )
77
+ require 'url_canonicalize/core_ext'
78
+
79
+ 'http://www.twitter.com'.canonicalize # => 'https://twitter.com/'
80
+
81
+ URI('http://www.twitter.com').canonicalize # => #<URI::HTTP:0x00000008767908 URL:https://twitter.com/>
82
+
83
+ Addressable::URI.canonicalize('http://www.twitter.com') # => #<Addressable::URI:0x43c9 URI:https://twitter.com/>
43
84
 
44
85
  'https://example.com/article'.canonicalize(total_timeout: 10)
45
86
  ```
46
87
 
47
- If you prefer the module API without the core-class extensions, call
48
- `URLCanonicalize.canonicalize(url)` (returns the canonical URL string) or
49
- `URLCanonicalize.fetch(url)` (returns the full response object).
88
+ Requiring `url_canonicalize` alone no longer patches any core class: if you
89
+ relied on the extensions, add the extra require.
90
+
91
+ ## Normalization and canonicalization
92
+
93
+ URLCanonicalize distinguishes two operations:
94
+
95
+ - **Normalization** is syntactic: rewriting a URL into its RFC 3986 normal
96
+ form without any network access. Parsing and reference resolution use
97
+ Ruby's `URI` (strict); normalization uses `Addressable::URI#normalize`.
98
+ - **Canonicalization** is discovery: fetching the URL, following redirects
99
+ and server-declared canonical links, and returning the normalized final
100
+ URL.
101
+
102
+ Every URL is processed in this order:
103
+
104
+ 1. The input is stripped of surrounding whitespace and parsed. A leading
105
+ `scheme:` token is honored; an input without one is treated as a host
106
+ name and given `http://`. A colon elsewhere in the URL does not suppress
107
+ the default scheme. Non-HTTP(S) schemes raise
108
+ `URLCanonicalize::Exception::URI`.
109
+ 2. The URL is normalized (see below) and fetched with a `HEAD` request,
110
+ retried as `GET` where the server rejects `HEAD`.
111
+ 3. Redirects (any `3xx` with a usable `Location`, temporary or permanent)
112
+ are followed. The `Location` value is resolved against the request URL
113
+ per RFC 3986, so absolute, protocol-relative, root-relative,
114
+ path-relative and query-only forms all work.
115
+ 4. On a successful response, a canonical link is looked for in the `Link`
116
+ response header first, then in the HTML `<link rel="canonical">` element;
117
+ **the header wins when they conflict**. HTML links resolve against the
118
+ document's `<base href>` when one is declared, otherwise against the
119
+ request URL.
120
+ 5. A declared canonical URL is not trusted blindly: it is fetched and
121
+ validated itself. If fetching it fails, the declared canonical URL is
122
+ still returned, confirmed by the response that declared it.
123
+ 6. Redirects and followed canonical links share one hop budget and one
124
+ visited-URL set, so cycles and over-long chains terminate
125
+ deterministically. The returned URL is always normalized.
126
+
127
+ Normalization makes these changes and no others:
128
+
129
+ | Component | Rule |
130
+ | --- | --- |
131
+ | Scheme and host | Lowercased |
132
+ | Internationalized host | Converted to punycode (`xn--…`) |
133
+ | Default port (`:80`/`:443`) | Removed; other ports preserved |
134
+ | Dot segments (`/./`, `/../`) | Resolved |
135
+ | Percent-encoding | Uppercase hex; unreserved characters decoded; invalid characters encoded |
136
+ | Empty path | Replaced by `/` |
137
+ | Trailing slash | Preserved (`/a/` and `/a` stay distinct) |
138
+ | Query string | Preserved verbatim — parameters are never reordered or dropped |
139
+ | Fragment (`#…`) | Stripped — fragments are client-side state |
140
+ | Userinfo (`user:pass@`) | Rejected as a security error at fetch time |
50
141
 
51
142
  ## Security and limits
52
143
 
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module URLCanonicalize
4
+ # A reusable canonicalization client. The options are validated and frozen
5
+ # at construction, so one client can be shared safely between threads
6
+ class Client
7
+ attr_reader :options
8
+
9
+ def initialize(**values)
10
+ @options = URLCanonicalize::Options.new(**values)
11
+ end
12
+
13
+ def canonicalize(url)
14
+ fetch(url).url
15
+ end
16
+
17
+ def fetch(url)
18
+ URLCanonicalize::HTTP.new(url, options: options).fetch
19
+ end
20
+ end
21
+ end
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'addressable/uri'
4
+
3
5
  module Addressable
4
6
  # Patch for Addressable's URI class
5
7
  class URI
6
- def self.canonicalize(uri, **options)
8
+ def self.canonicalize(uri, **)
7
9
  url = parse(uri).to_s # uri can be anything Addressable::URI can handle
8
- canonical_url = URLCanonicalize.canonicalize(url, **options)
10
+ canonical_url = URLCanonicalize.canonicalize(url, **)
9
11
  parse(canonical_url)
10
12
  end
11
13
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Patch for Ruby's String class
4
4
  class String
5
- def canonicalize(**options)
6
- URLCanonicalize.canonicalize(self, **options)
5
+ def canonicalize(**)
6
+ URLCanonicalize.canonicalize(self, **)
7
7
  end
8
8
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'uri'
4
+
3
5
  module URI
4
6
  # URI having the HTTP protocol
5
7
  class HTTP
6
- def canonicalize(**options)
7
- new_url = URLCanonicalize.canonicalize(to_s, **options)
8
+ def canonicalize(**)
9
+ new_url = URLCanonicalize.canonicalize(to_s, **)
8
10
  ::URI.parse(new_url)
9
11
  end
10
12
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'url_canonicalize'
4
+ require 'url_canonicalize/core_ext/string'
5
+ require 'url_canonicalize/core_ext/uri'
6
+ require 'url_canonicalize/core_ext/addressable'
@@ -4,11 +4,12 @@ module URLCanonicalize
4
4
  # Persistent connection for possible repeated requests to the same host
5
5
  class HTTP
6
6
  def fetch
7
- ::Timeout.timeout(
7
+ final = ::Timeout.timeout(
8
8
  options[:total_timeout],
9
9
  URLCanonicalize::Exception::Timeout,
10
10
  "Canonicalization exceeded #{options[:total_timeout]} seconds"
11
11
  ) { fetch_within_deadline }
12
+ build_result(final)
12
13
  end
13
14
 
14
15
  def uri
@@ -24,10 +25,22 @@ module URLCanonicalize
24
25
  http.request(http_request) { |response| read_response_body(http_request, response) }
25
26
  end
26
27
 
28
+ attr_reader :options
29
+
27
30
  private
28
31
 
29
32
  attr_accessor :last_known_good
30
- attr_reader :options
33
+
34
+ def initialize(raw_url, options: nil, **values)
35
+ raise ArgumentError, 'Pass either options: or keyword options, not both' if options && !values.empty?
36
+
37
+ @raw_url = raw_url
38
+ @options = options || URLCanonicalize::Options.new(**values)
39
+ end
40
+
41
+ def build_result(final)
42
+ Result.new(url: final.url, response: final.response, html: final.html, chain: chain, source: @final_source)
43
+ end
31
44
 
32
45
  def fetch_within_deadline
33
46
  loop do
@@ -36,11 +49,6 @@ module URLCanonicalize
36
49
  end
37
50
  end
38
51
 
39
- def initialize(raw_url, **options)
40
- @raw_url = raw_url
41
- @options = URLCanonicalize::Options.new(**options)
42
- end
43
-
44
52
  # Fetch the response
45
53
  def response
46
54
  @response ||= fetch_response
@@ -85,47 +93,60 @@ module URLCanonicalize
85
93
 
86
94
  def handle_redirect
87
95
  reason = hop_refusal_reason
88
- return follow_hop unless reason
96
+ return follow_hop(:redirect) unless reason
89
97
 
90
- last_known_good || raise(URLCanonicalize::Exception::Redirect, reason)
98
+ finish_with_last_known_good || raise(URLCanonicalize::Exception::Redirect, reason)
91
99
  end
92
100
 
93
101
  def handle_canonical_found
94
102
  self.last_known_good = response.response
95
- return last_known_good if hop_refusal_reason
103
+ return finish_with_last_known_good if hop_refusal_reason
104
+
105
+ follow_hop(:canonical_link)
106
+ end
96
107
 
97
- follow_hop
108
+ # A terminal response whose URL came from a server-declared canonical link
109
+ def finish_with_last_known_good
110
+ return unless last_known_good
111
+
112
+ @final_source = :canonical_link
113
+ last_known_good
98
114
  end
99
115
 
100
116
  # Redirects and followed canonical links share one visited-URL set and one
101
117
  # hop budget, so any cycle or over-long chain terminates deterministically
102
118
  def hop_refusal_reason
103
- return 'Redirect loop detected' if visited.include?(response_url)
119
+ return 'Redirect loop detected' if visited?(response_url)
104
120
  return "#{hops + 1} redirects is too many" if hops >= options[:max_redirects]
105
121
 
106
122
  nil
107
123
  end
108
124
 
109
- def follow_hop
110
- visited << response_url
111
- @hops = hops + 1
125
+ def follow_hop(via)
126
+ chain << Result::Hop.new(url: response_url, via: via)
112
127
  self.url = response_url
113
128
  nil
114
129
  end
115
130
 
116
- def visited
117
- @visited ||= [url]
131
+ # The URLs requested so far and how each one was discovered
132
+ def chain
133
+ @chain ||= [Result::Hop.new(url: url, via: :initial)]
134
+ end
135
+
136
+ def visited?(candidate)
137
+ chain.any? { |hop| hop.url == candidate }
118
138
  end
119
139
 
120
140
  def hops
121
- @hops ||= 0
141
+ chain.length - 1
122
142
  end
123
143
 
124
144
  def handle_failure
125
- return last_known_good if last_known_good
126
-
127
- raise URLCanonicalize::Exception::Failure, "#{response.failure_class}: #{response.message}",
128
- cause: response.error
145
+ finish_with_last_known_good || raise(
146
+ URLCanonicalize::Exception::Failure,
147
+ "#{response.failure_class}: #{response.message}",
148
+ cause: response.error
149
+ )
129
150
  end
130
151
 
131
152
  def handle_unhandled_response
@@ -133,6 +154,7 @@ module URLCanonicalize
133
154
  end
134
155
 
135
156
  def handle_success
157
+ @final_source = chain.last.via
136
158
  self.last_known_good = response
137
159
  end
138
160
 
@@ -145,7 +167,7 @@ module URLCanonicalize
145
167
  return @http if same_origin? # reuse connection
146
168
 
147
169
  @previous = uri
148
- @http = new_http
170
+ @http = options[:transport].call(uri, options)
149
171
  end
150
172
 
151
173
  def same_origin?
@@ -156,16 +178,6 @@ module URLCanonicalize
156
178
  @previous ||= Struct.new(:scheme, :host, :port).new
157
179
  end
158
180
 
159
- def new_http
160
- h = Net::HTTP.new uri.host, uri.port, nil
161
-
162
- h.ipaddr = URLCanonicalize::Destination.resolve(uri, options)
163
- configure_timeouts(h)
164
- ssl!(h)
165
-
166
- h
167
- end
168
-
169
181
  def read_response_body(http_request, response)
170
182
  if buffer_response_body?(http_request, response)
171
183
  enforce_content_length!(response)
@@ -185,21 +197,5 @@ module URLCanonicalize
185
197
  raise URLCanonicalize::Exception::ResponseTooLarge,
186
198
  "Response body exceeds #{options[:max_body_bytes]} bytes"
187
199
  end
188
-
189
- def configure_timeouts(http)
190
- http.open_timeout = options[:open_timeout]
191
- http.read_timeout = options[:read_timeout]
192
- http.write_timeout = options[:write_timeout]
193
- end
194
-
195
- def ssl!(http)
196
- if uri.scheme == 'https'
197
- http.use_ssl = true # Can generate exception
198
- http.verify_mode = OpenSSL::SSL::VERIFY_PEER
199
- http.verify_hostname = true
200
- else
201
- http.use_ssl = false
202
- end
203
- end
204
200
  end
205
201
  end
@@ -6,12 +6,20 @@ module URLCanonicalize
6
6
  DEFAULTS = {
7
7
  allow_private_networks: false,
8
8
  allowed_ports: [80, 443].freeze,
9
+ headers: {
10
+ 'Accept-Language' => 'en-US,en;q=0.8',
11
+ 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; WOW64) ' \
12
+ 'AppleWebKit/537.36 (KHTML, like Gecko) ' \
13
+ 'Chrome/51.0.2704.103 Safari/537.36'
14
+ }.freeze,
15
+ logger: nil,
9
16
  max_body_bytes: 1_048_576,
10
17
  total_timeout: 30,
11
18
  open_timeout: 8,
12
19
  read_timeout: 15,
13
20
  write_timeout: 8,
14
- max_redirects: 10
21
+ max_redirects: 10,
22
+ transport: Transport.new
15
23
  }.freeze
16
24
 
17
25
  POSITIVE_NUMERIC_OPTIONS = %i[total_timeout open_timeout read_timeout write_timeout].freeze
@@ -23,6 +31,7 @@ module URLCanonicalize
23
31
  @values = DEFAULTS.merge(values)
24
32
  validate!
25
33
  @values[:allowed_ports] = @values[:allowed_ports].dup.freeze
34
+ @values[:headers] = @values[:headers].dup.freeze
26
35
  @values.freeze
27
36
  freeze
28
37
  end
@@ -32,7 +41,7 @@ module URLCanonicalize
32
41
  end
33
42
 
34
43
  def to_h
35
- @values.merge(allowed_ports: @values[:allowed_ports].dup)
44
+ @values.merge(allowed_ports: @values[:allowed_ports].dup, headers: @values[:headers].dup)
36
45
  end
37
46
 
38
47
  private
@@ -41,6 +50,9 @@ module URLCanonicalize
41
50
  validate_private_networks!
42
51
  validate_ports!
43
52
  validate_positive_values!
53
+ validate_headers!
54
+ validate_logger!
55
+ validate_transport!
44
56
  end
45
57
 
46
58
  def validate_known_options!(values)
@@ -75,5 +87,26 @@ module URLCanonicalize
75
87
  raise ArgumentError, "#{name} must be positive" unless value.is_a?(Integer) && value.positive?
76
88
  end
77
89
  end
90
+
91
+ def validate_headers!
92
+ headers = @values[:headers]
93
+ valid = headers.is_a?(Hash) && headers.all? { |key, value| key.is_a?(String) && value.is_a?(String) }
94
+ return if valid
95
+
96
+ raise ArgumentError, 'headers must be a Hash of String keys and String values'
97
+ end
98
+
99
+ def validate_logger!
100
+ logger = @values[:logger]
101
+ return if logger.nil? || logger.respond_to?(:debug)
102
+
103
+ raise ArgumentError, 'logger must respond to debug'
104
+ end
105
+
106
+ def validate_transport!
107
+ return if @values[:transport].respond_to?(:call)
108
+
109
+ raise ArgumentError, 'transport must respond to call'
110
+ end
78
111
  end
79
112
  end
@@ -85,7 +85,7 @@ module URLCanonicalize
85
85
  end
86
86
 
87
87
  def handle_success
88
- @canonical_url = relative_to_absolute(URLCanonicalize::LinkHeader.canonical(response))
88
+ @canonical_url = normalized(relative_to_absolute(URLCanonicalize::LinkHeader.canonical(response)))
89
89
 
90
90
  return enhanced_response if canonical_url || http_method == :get
91
91
 
@@ -122,7 +122,7 @@ module URLCanonicalize
122
122
 
123
123
  def enhanced_response
124
124
  if canonical_url
125
- puts " * canonical_url:\t#{canonical_url}" if ENV.fetch('DEBUG', nil)
125
+ logger&.debug { "canonical_url: #{canonical_url}" }
126
126
  response_plus = URLCanonicalize::Response::Success.new(canonical_url, response, html)
127
127
  URLCanonicalize::Response::CanonicalFound.new(canonical_url, response_plus)
128
128
  else
@@ -135,7 +135,13 @@ module URLCanonicalize
135
135
  end
136
136
 
137
137
  def canonical_url
138
- @canonical_url ||= relative_to_absolute(canonical_url_element&.[]('href'), document_base_url)
138
+ @canonical_url ||= normalized(relative_to_absolute(canonical_url_element&.[]('href'), document_base_url))
139
+ end
140
+
141
+ # Declared canonical URLs are returned to callers, so they get the same
142
+ # syntactic normalization as every requested URL
143
+ def normalized(url)
144
+ URLCanonicalize::URI.normalize(url) if url
139
145
  end
140
146
 
141
147
  # The first HTML link element whose rel tokens include "canonical", however
@@ -179,12 +185,7 @@ module URLCanonicalize
179
185
  end
180
186
 
181
187
  def headers
182
- @headers ||= {
183
- 'Accept-Language' => 'en-US,en;q=0.8',
184
- 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; WOW64) ' \
185
- 'AppleWebKit/537.36 (KHTML, like Gecko) ' \
186
- 'Chrome/51.0.2704.103 Safari/537.36'
187
- }
188
+ http.options[:headers]
188
189
  end
189
190
 
190
191
  def http_method=(value)
@@ -210,14 +211,11 @@ module URLCanonicalize
210
211
  end
211
212
 
212
213
  def log_response
213
- debug = ENV.fetch('DEBUG', nil)
214
- return unless debug
215
-
216
- puts "#{http_method.upcase} #{url} #{response.code} #{response.message}"
217
-
218
- return unless debug.casecmp?('headers')
214
+ logger&.debug { "#{http_method.upcase} #{url} #{response.code} #{response.message}" }
215
+ end
219
216
 
220
- response.each { |k, v| puts " #{k}:\t#{v}" }
217
+ def logger
218
+ http.options[:logger]
221
219
  end
222
220
  end
223
221
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module URLCanonicalize
4
+ # The outcome of one canonicalization: the canonical URL, the response
5
+ # that confirmed it, and the chain of hops that led there. Immutable
6
+ class Result
7
+ # One step in the request chain: the URL that was requested and how it
8
+ # was discovered (:initial, :redirect or :canonical_link)
9
+ Hop = Struct.new(:url, :via, keyword_init: true)
10
+
11
+ attr_reader :url, :response, :html, :chain, :source
12
+
13
+ def xml
14
+ Nokogiri::XML(response.body)
15
+ end
16
+
17
+ private
18
+
19
+ def initialize(url:, response:, html:, chain:, source:)
20
+ @url = url
21
+ @response = response
22
+ @html = html
23
+ @chain = chain.map(&:freeze).freeze
24
+ @source = source
25
+ freeze
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module URLCanonicalize
4
+ # Builds the connection for a destination, resolving the host through the
5
+ # fetch security policy. Injectable via the :transport option: anything
6
+ # responding to call(uri, options) and returning a Net::HTTP-compatible
7
+ # connection can replace it
8
+ class Transport
9
+ # Resolved lazily so the destination policy can be stubbed in tests
10
+ DEFAULT_RESOLVER = lambda do |uri, options|
11
+ URLCanonicalize::Destination.resolve(uri, options)
12
+ end
13
+
14
+ def initialize(resolver: DEFAULT_RESOLVER)
15
+ @resolver = resolver
16
+ freeze
17
+ end
18
+
19
+ def call(uri, options)
20
+ http = Net::HTTP.new(uri.host, uri.port, nil)
21
+
22
+ http.ipaddr = @resolver.call(uri, options)
23
+ configure_timeouts(http, options)
24
+ configure_tls(http, uri)
25
+
26
+ http
27
+ end
28
+
29
+ private
30
+
31
+ def configure_timeouts(http, options)
32
+ http.open_timeout = options[:open_timeout]
33
+ http.read_timeout = options[:read_timeout]
34
+ http.write_timeout = options[:write_timeout]
35
+ end
36
+
37
+ def configure_tls(http, uri)
38
+ if uri.scheme == 'https'
39
+ http.use_ssl = true # Can generate exception
40
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
41
+ http.verify_hostname = true
42
+ else
43
+ http.use_ssl = false
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,20 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module URLCanonicalize
4
- # Manage the URL into a URI with local exception handling
4
+ # Parses and normalizes URLs with local exception handling
5
5
  class URI
6
6
  class << self
7
7
  VALID_CLASSES = [::URI::HTTP, ::URI::HTTPS].freeze
8
- COLON = ':'
8
+
9
+ # A scheme is recognized only as a leading scheme: token; a colon
10
+ # anywhere else no longer suppresses the default scheme
11
+ SCHEME = /\A[A-Za-z][A-Za-z0-9+.-]*:/
9
12
 
10
13
  def parse(url)
11
- uri = ::URI.parse decorate(url)
14
+ uri = ::URI.parse normalize(url)
12
15
  validate! uri
13
16
  uri
14
17
  rescue ::URI::InvalidURIError => e
15
18
  raise URLCanonicalize::Exception::URI, "#{e.class}: #{e.message}" # the original error becomes the cause
16
19
  end
17
20
 
21
+ # Syntactic normalization per RFC 3986, via Addressable: lowercase
22
+ # scheme and host, internationalized hosts to punycode, uppercase
23
+ # percent-encodings with unreserved characters decoded, dot segments
24
+ # resolved, default ports removed and the empty path replaced by "/".
25
+ # Fragments are client-side state, so no canonical URL carries one
26
+ def normalize(url)
27
+ addressable = Addressable::URI.parse(decorate(url.to_s.strip)).normalize
28
+ addressable.fragment = nil
29
+ addressable.to_s
30
+ rescue Addressable::URI::InvalidURIError, ArgumentError, Encoding::CompatibilityError => e
31
+ raise URLCanonicalize::Exception::URI, "#{e.class}: #{e.message}" # the original error becomes the cause
32
+ end
33
+
18
34
  private
19
35
 
20
36
  def validate!(uri)
@@ -24,7 +40,7 @@ module URLCanonicalize
24
40
  end
25
41
 
26
42
  def decorate(url)
27
- return url if url.include? COLON
43
+ return url if url.match?(SCHEME)
28
44
 
29
45
  "http://#{url}" # Add protocol if we just receive a host name
30
46
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module URLCanonicalize
4
- VERSION = '1.0.2'
4
+ VERSION = '2.0.0'
5
5
  end
@@ -11,6 +11,7 @@ autoload :OpenSSL, 'openssl'
11
11
  # Core methods
12
12
  module URLCanonicalize
13
13
  autoload :BoundedBody, 'url_canonicalize/bounded_body'
14
+ autoload :Client, 'url_canonicalize/client'
14
15
  autoload :Destination, 'url_canonicalize/destination'
15
16
  autoload :Exception, 'url_canonicalize/exception'
16
17
  autoload :HTTP, 'url_canonicalize/http'
@@ -19,20 +20,18 @@ module URLCanonicalize
19
20
  autoload :Options, 'url_canonicalize/options'
20
21
  autoload :Request, 'url_canonicalize/request'
21
22
  autoload :Response, 'url_canonicalize/response'
23
+ autoload :Result, 'url_canonicalize/result'
24
+ autoload :Transport, 'url_canonicalize/transport'
22
25
  autoload :URI, 'url_canonicalize/uri'
23
26
  autoload :VERSION, 'url_canonicalize/version'
24
27
 
25
28
  class << self
26
- def canonicalize(url, **options)
27
- fetch(url, **options).url
29
+ def canonicalize(url, **)
30
+ Client.new(**).canonicalize(url)
28
31
  end
29
32
 
30
- def fetch(url, **options)
31
- URLCanonicalize::HTTP.new(url, **options).fetch
33
+ def fetch(url, **)
34
+ Client.new(**).fetch(url)
32
35
  end
33
36
  end
34
37
  end
35
-
36
- require 'monkey_patches/uri'
37
- require 'monkey_patches/string'
38
- require 'monkey_patches/addressable/uri'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: url_canonicalize
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dominic Sayers
@@ -49,11 +49,13 @@ files:
49
49
  - LICENSE
50
50
  - README.md
51
51
  - SECURITY.md
52
- - lib/monkey_patches/addressable/uri.rb
53
- - lib/monkey_patches/string.rb
54
- - lib/monkey_patches/uri.rb
55
52
  - lib/url_canonicalize.rb
56
53
  - lib/url_canonicalize/bounded_body.rb
54
+ - lib/url_canonicalize/client.rb
55
+ - lib/url_canonicalize/core_ext.rb
56
+ - lib/url_canonicalize/core_ext/addressable.rb
57
+ - lib/url_canonicalize/core_ext/string.rb
58
+ - lib/url_canonicalize/core_ext/uri.rb
57
59
  - lib/url_canonicalize/destination.rb
58
60
  - lib/url_canonicalize/exception.rb
59
61
  - lib/url_canonicalize/http.rb
@@ -62,6 +64,8 @@ files:
62
64
  - lib/url_canonicalize/options.rb
63
65
  - lib/url_canonicalize/request.rb
64
66
  - lib/url_canonicalize/response.rb
67
+ - lib/url_canonicalize/result.rb
68
+ - lib/url_canonicalize/transport.rb
65
69
  - lib/url_canonicalize/uri.rb
66
70
  - lib/url_canonicalize/version.rb
67
71
  homepage: https://github.com/dominicsayers/url_canonicalize
@@ -81,7 +85,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
81
85
  requirements:
82
86
  - - ">="
83
87
  - !ruby/object:Gem::Version
84
- version: 3.1.0
88
+ version: 3.3.0
85
89
  required_rubygems_version: !ruby/object:Gem::Requirement
86
90
  requirements:
87
91
  - - ">="