Repository: nock/nock (language: javascript)

Issue:
Allow WHATWG URL objects in interceptors
### Please avoid duplicates

- [X] I checked [all open feature requests](https://github.com/nock/nock.js/issues?q=is%3Aissue+is%3Aopen+label%3Afeature) and none of them matched my request.

### Context

It would be nice to allow WHATWG URL objects (implemented in node's [URL API](https://nodejs.org/api/url.html) since v6.13.0, globally available since v10.0.0).

Since we are here, it would be nice to get rid of the legacy [`url.parse`](https://github.com/nock/nock/blob/main/lib/scope.js#L55) and simply use `new URL(...)` instead.

This may be a breaking change due to the fact that the legecay parser is slightly different than the WHATWG parser. I do not know what the differences are, but I imagine they are subtle and that some users have come to rely on the legacy implementation.

### Alternatives

I can stringify URLs - but these strings are parsed... which is unnecessary.

```js
// this works, but the stringification is not necessary
const apiServer = new URL('http://localhost:8888');
nock(`${apiServer}`)...
```

### If the feature request is accepted, would you be willing to submit a PR?

- [X] yes


Discussion / hints:
I took a stab at implementing this and wanted to post my initial findings. After digging through tests, I eventually found an attempt was made several years ago and it never got completed: https://github.com/nock/nock/pull/1692. The conversation in that PR highlights the biggest issue I've run into, but I'll recap here:

There is an unfortunate difference between the legacy `url.parse` and the WHATWG parser (`new Url`) regarding the hostname for bracketed IPv6 addresses:

```js
const urlString = 'http://[2607:f0d0:1002:51::5]:8080';
url.parse(urlString).hostname //-> 2607:f0d0:1002:51::5
new URL(urlString).hostname //-> [2607:f0d0:1002:51::5]
```

Note the preservation of brackets in the WHATWG URL. This presents a few challenges:
- preserving backwards compatability
- treating `[2607:f0d0:1002:51::5]` and `2607:f0d0:1002:51::5` as equals... they're indeed the same hostname

Long story short, all of the above challenges are "solved" by keeping internal references to the non-bracketed hostnames - effectively preserving the legacy parsing behavior while still using the WHATWG parser and all of it's robustness. Here is some pseudo code:

```js
const parsed = new URL(inputString);
const normalizedHostname = removeBrackets(parsed.hostname);
this.internalRef = { ...parsed, normalizedHostname }
```

All internal matching and filtering should take place on the `normalizedHostname`. This should be fairly easy to implement, and I will port over some of the other changes made the previous PR.



Fix patch:
diff --git a/README.md b/README.md
index 6d7e7f3b3..19aba35c6 100644
--- a/README.md
+++ b/README.md
@@ -159,7 +159,7 @@ If you don’t want interceptors to be removed as they are used, you can use the
 
 ### Specifying hostname
 
-The request hostname can be a string or a RegExp.
+The request hostname can be a string, URL, or a RegExp.
 
 ```js
 const scope = nock('http://www.example.com')
@@ -167,6 +167,12 @@ const scope = nock('http://www.example.com')
   .reply(200, 'domain matched')
 ```
 
+```js
+const scope = nock(new URL('http://www.example.com'))
+  .get('/resource')
+  .reply(200, 'domain matched')
+```
+
 ```js
 const scope = nock(/example\.com/)
   .get('/resource')
diff --git a/lib/scope.js b/lib/scope.js
index 505c2fd52..8e32c618e 100644
--- a/lib/scope.js
+++ b/lib/scope.js
@@ -11,6 +11,7 @@ const debug = require('debug')('nock.scope')
 const { EventEmitter } = require('events')
 const Interceptor = require('./interceptor')
 
+const { URL, Url: LegacyUrl } = url
 let fs
 
 try {
@@ -20,7 +21,46 @@ try {
 }
 
 /**
- * @param  {string|RegExp|url.url} basePath
+ * Normalizes the passed url for consistent internal processing
+ * @param {string|LegacyUrl|URL} u
+ */
+function normalizeUrl(u) {
+  if (!(u instanceof URL)) {
+    if (u instanceof LegacyUrl) {
+      return normalizeUrl(new URL(url.format(u)))
+    }
+    // If the url is invalid, let the URL library report it
+    return normalizeUrl(new URL(u))
+  }
+
+  if (!/https?:/.test(u.protocol)) {
+    throw new TypeError(
+      `Protocol '${u.protocol}' not recognized. This commonly occurs when a hostname and port are included without a protocol, producing a URL that is valid but confusing, and probably not what you want.`
+    )
+  }
+
+  return {
+    href: u.href,
+    origin: u.origin,
+    protocol: u.protocol,
+    username: u.username,
+    password: u.password,
+    host: u.host,
+    hostname:
+      // strip brackets from IPv6
+      typeof u.hostname === 'string' && u.hostname.startsWith('[')
+        ? u.hostname.slice(1, -1)
+        : u.hostname,
+    port: u.port || (u.protocol === 'http:' ? 80 : 443),
+    pathname: u.pathname,
+    search: u.search,
+    searchParams: u.searchParams,
+    hash: u.hash,
+  }
+}
+
+/**
+ * @param  {string|RegExp|LegacyUrl|URL} basePath
  * @param  {Object}   options
  * @param  {boolean}  options.allowUnmocked
  * @param  {string[]} options.badheaders
@@ -52,9 +92,8 @@ class Scope extends EventEmitter {
     let logNamespace = String(basePath)
 
     if (!(basePath instanceof RegExp)) {
-      this.urlParts = url.parse(basePath)
-      this.port =
-        this.urlParts.port || (this.urlParts.protocol === 'http:' ? 80 : 443)
+      this.urlParts = normalizeUrl(basePath)
+      this.port = this.urlParts.port
       this.basePathname = this.urlParts.pathname.replace(/\/$/, '')
       this.basePath = `${this.urlParts.protocol}//${this.urlParts.hostname}:${this.port}`
       logNamespace = this.urlParts.host


Test patch:
diff --git a/tests/test_scope.js b/tests/test_scope.js
index 3c80447f9..1e04becf7 100644
--- a/tests/test_scope.js
+++ b/tests/test_scope.js
@@ -34,8 +34,7 @@ describe('`Scope#constructor`', () => {
     scope.done()
   })
 
-  // TODO: https://github.com/nock/nock/pull/1879
-  it.skip('accepts a WHATWG URL instance', async () => {
+  it('accepts a WHATWG URL instance', async () => {
     const scope = nock(new url.URL('http://example.test')).get('/').reply()
 
     const { statusCode } = await got('http://example.test')
@@ -43,10 +42,33 @@ describe('`Scope#constructor`', () => {
     scope.done()
   })
 
-  it('fails when provided a WHATWG URL instance', () => {
-    // This test just proves the lack of current support. When this feature is added,
-    // this test should be removed and the test above un-skipped.
-    expect(() => nock(new url.URL('http://example.test'))).to.throw()
+  it('throws on invalid or omitted protocol', async () => {
+    expect(() => nock('ws://example.test')).to.throw()
+    expect(() => nock('localhost/foo')).to.throw()
+    expect(() => nock('foo.com:1234')).to.throw()
+  })
+
+  it('throws on invalid URL format', async () => {
+    expect(() => nock(['This is not a url'])).to.throw()
+    // The following contains all valid properties of WHATWG URL, but is not an
+    // instance of URL. Maybe we should support object literals some day? A
+    // simple duck-type validator would suffice.
+    expect(() =>
+      nock({
+        href: 'http://google.com/foo',
+        origin: 'http://google.com',
+        protocol: 'http:',
+        username: '',
+        password: '',
+        host: 'google.com',
+        hostname: 'google.com',
+        port: 80,
+        pathname: '/foo',
+        search: '',
+        searchParams: {},
+        hash: '',
+      })
+    ).to.throw()
   })
 })