diff --git a/README.md b/README.md index a32220a0..dc0246a6 100644 --- a/README.md +++ b/README.md @@ -592,11 +592,20 @@ response.data.pipe(fs.createWriteStream('ada_lovelace.jpg')); ##### axios(url[, config]) +`url` accepts either a string or a [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) instance. The `URL` is coerced to a string before the request is dispatched. + ```js // Send a GET request (default method) axios('/user/12345'); ``` +```js +// `url` may also be a URL object +axios.get(new URL('https://api.example.com/foo'), { + params: { a: 1 }, +}); +``` + ### Request method aliases For convenience, aliases have been provided for all common request methods. diff --git a/index.d.cts b/index.d.cts index 916b9958..b4480f44 100644 --- a/index.d.cts +++ b/index.d.cts @@ -176,53 +176,53 @@ declare class Axios { config: axios.AxiosRequestConfig ): Promise; get, D = any>( - url: string, + url: string | URL, config?: axios.AxiosRequestConfig ): Promise; delete, D = any>( - url: string, + url: string | URL, config?: axios.AxiosRequestConfig ): Promise; head, D = any>( - url: string, + url: string | URL, config?: axios.AxiosRequestConfig ): Promise; options, D = any>( - url: string, + url: string | URL, config?: axios.AxiosRequestConfig ): Promise; post, D = any>( - url: string, + url: string | URL, data?: D, config?: axios.AxiosRequestConfig ): Promise; put, D = any>( - url: string, + url: string | URL, data?: D, config?: axios.AxiosRequestConfig ): Promise; patch, D = any>( - url: string, + url: string | URL, data?: D, config?: axios.AxiosRequestConfig ): Promise; postForm, D = any>( - url: string, + url: string | URL, data?: D, config?: axios.AxiosRequestConfig ): Promise; putForm, D = any>( - url: string, + url: string | URL, data?: D, config?: axios.AxiosRequestConfig ): Promise; patchForm, D = any>( - url: string, + url: string | URL, data?: D, config?: axios.AxiosRequestConfig ): Promise; query, D = any>( - url: string, + url: string | URL, data?: D, config?: axios.AxiosRequestConfig ): Promise; @@ -473,7 +473,7 @@ declare namespace axios { type LookupAddress = string | LookupAddressEntry; interface AxiosRequestConfig { - url?: string; + url?: string | URL; method?: Method | string; baseURL?: string; allowAbsoluteUrls?: boolean; @@ -661,7 +661,7 @@ declare namespace axios { interface AxiosInstance extends Axios { , D = any>(config: AxiosRequestConfig): Promise; , D = any>( - url: string, + url: string | URL, config?: AxiosRequestConfig ): Promise; diff --git a/index.d.ts b/index.d.ts index e25555fa..6ae23e79 100644 --- a/index.d.ts +++ b/index.d.ts @@ -364,7 +364,7 @@ export interface LookupAddressEntry { export type LookupAddress = string | LookupAddressEntry; export interface AxiosRequestConfig { - url?: string; + url?: string | URL; method?: StringLiteralsOrString; baseURL?: string; allowAbsoluteUrls?: boolean; @@ -609,53 +609,53 @@ export class Axios { getUri(config?: AxiosRequestConfig): string; request, D = any>(config: AxiosRequestConfig): Promise; get, D = any>( - url: string, + url: string | URL, config?: AxiosRequestConfig ): Promise; delete, D = any>( - url: string, + url: string | URL, config?: AxiosRequestConfig ): Promise; head, D = any>( - url: string, + url: string | URL, config?: AxiosRequestConfig ): Promise; options, D = any>( - url: string, + url: string | URL, config?: AxiosRequestConfig ): Promise; post, D = any>( - url: string, + url: string | URL, data?: D, config?: AxiosRequestConfig ): Promise; put, D = any>( - url: string, + url: string | URL, data?: D, config?: AxiosRequestConfig ): Promise; patch, D = any>( - url: string, + url: string | URL, data?: D, config?: AxiosRequestConfig ): Promise; postForm, D = any>( - url: string, + url: string | URL, data?: D, config?: AxiosRequestConfig ): Promise; putForm, D = any>( - url: string, + url: string | URL, data?: D, config?: AxiosRequestConfig ): Promise; patchForm, D = any>( - url: string, + url: string | URL, data?: D, config?: AxiosRequestConfig ): Promise; query, D = any>( - url: string, + url: string | URL, data?: D, config?: AxiosRequestConfig ): Promise; @@ -663,7 +663,7 @@ export class Axios { export interface AxiosInstance extends Axios { , D = any>(config: AxiosRequestConfig): Promise; - , D = any>(url: string, config?: AxiosRequestConfig): Promise; + , D = any>(url: string | URL, config?: AxiosRequestConfig): Promise; create(config?: CreateAxiosDefaults): AxiosInstance; defaults: Omit & { diff --git a/lib/core/Axios.js b/lib/core/Axios.js index 903249a8..39d8d913 100644 --- a/lib/core/Axios.js +++ b/lib/core/Axios.js @@ -12,6 +12,19 @@ import transitionalDefaults from '../defaults/transitional.js'; const validators = validator.validators; +const { toString } = Object.prototype; + +const isURL = (url) => { + if (url === null || typeof url !== 'object') { + return false; + } + + const prototype = Object.getPrototypeOf(url); + return prototype !== null && prototype !== Object.prototype && toString.call(url) === '[object URL]'; +}; + +const normalizeURL = (url) => (isURL(url) ? url.toString() : url); + /** * Create a new instance of Axios * @@ -31,7 +44,7 @@ class Axios { /** * Dispatch a request * - * @param {String|Object} configOrUrl The config specific for this request (merged with this.defaults) + * @param {String|URL|Object} configOrUrl The config specific for this request (merged with this.defaults) * @param {?Object} config * * @returns {Promise} The Promise to be fulfilled @@ -82,13 +95,19 @@ class Axios { _request(configOrUrl, config) { /*eslint no-param-reassign:0*/ // Allow for axios('example/url'[, config]) a la fetch API - if (typeof configOrUrl === 'string') { + if (typeof configOrUrl === 'string' || isURL(configOrUrl)) { config = config || {}; - config.url = configOrUrl; + config.url = normalizeURL(configOrUrl); } else { config = configOrUrl || {}; } + // Coerce config.url to a string if it's a URL object, so that all downstream + // consumers (buildFullPath, combineURLs, adapters) see a string. + if (isURL(config.url)) { + config.url = normalizeURL(config.url); + } + config = mergeConfig(this.defaults, config); const { transitional, paramsSerializer, headers } = config; @@ -232,6 +251,9 @@ class Axios { getUri(config) { config = mergeConfig(this.defaults, config); + if (isURL(config.url)) { + config.url = normalizeURL(config.url); + } const fullPath = buildFullPath(config.baseURL, config.url, config.allowAbsoluteUrls); return buildURL(fullPath, config.params, config.paramsSerializer); } diff --git a/lib/core/dispatchRequest.js b/lib/core/dispatchRequest.js index 59662d46..fd1c3e5f 100644 --- a/lib/core/dispatchRequest.js +++ b/lib/core/dispatchRequest.js @@ -7,6 +7,25 @@ import CanceledError from '../cancel/CanceledError.js'; import AxiosHeaders from '../core/AxiosHeaders.js'; import adapters from '../adapters/adapters.js'; +const { toString } = Object.prototype; + +const isURL = (url) => { + if (url === null || typeof url !== 'object') { + return false; + } + + const prototype = Object.getPrototypeOf(url); + return prototype !== null && prototype !== Object.prototype && toString.call(url) === '[object URL]'; +}; + +const normalizeURL = (url) => (isURL(url) ? url.toString() : url); + +function normalizeConfigURL(config) { + if (isURL(config.url)) { + config.url = normalizeURL(config.url); + } +} + /** * Throws a `CanceledError` if cancellation has been requested. * @@ -32,6 +51,8 @@ function throwIfCancellationRequested(config) { * @returns {Promise} The Promise to be fulfilled */ export default function dispatchRequest(config) { + normalizeConfigURL(config); + throwIfCancellationRequested(config); config.headers = AxiosHeaders.from(config.headers); @@ -43,6 +64,8 @@ export default function dispatchRequest(config) { config.headers.setContentType('application/x-www-form-urlencoded', false); } + normalizeConfigURL(config); + const adapter = adapters.getAdapter(config.adapter || defaults.adapter, config); return adapter(config).then( diff --git a/lib/helpers/buildURL.js b/lib/helpers/buildURL.js index b3e230e5..2698a8f0 100644 --- a/lib/helpers/buildURL.js +++ b/lib/helpers/buildURL.js @@ -29,6 +29,12 @@ export function encode(val) { * @returns {string} The formatted url */ export default function buildURL(url, params, options) { + // Safeguard for direct callers (e.g. via ./unsafe/helpers/buildURL.js): coerce URL-like + // objects to strings before using string methods below. + if (url !== null && typeof url === 'object' && typeof url.toString === 'function') { + url = url.toString(); + } + if (!params) { return url; } diff --git a/tests/unit/adapters/http.test.js b/tests/unit/adapters/http.test.js index 56e2e934..f610a97f 100644 --- a/tests/unit/adapters/http.test.js +++ b/tests/unit/adapters/http.test.js @@ -3116,6 +3116,46 @@ describe('supports http with nodejs', () => { } }); + it('should support URL object with params', async () => { + const server = await startHTTPServer( + (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ url: req.url })); + }, + { port: SERVER_PORT } + ); + + try { + const url = new URL(`http://localhost:${server.address().port}/foo?a=1`); + const response = await axios.get(url, { params: { b: '2' } }); + + assert.strictEqual(response.status, 200); + assert.strictEqual(response.data.url, '/foo?a=1&b=2'); + } finally { + await stopHTTPServer(server); + } + }); + + it('should support URL object without params (no crash)', async () => { + const server = await startHTTPServer( + (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ url: req.url })); + }, + { port: SERVER_PORT } + ); + + try { + const url = new URL(`http://localhost:${server.address().port}/foo`); + const response = await axios.get(url); + + assert.strictEqual(response.status, 200); + assert.strictEqual(response.data.url, '/foo'); + } finally { + await stopHTTPServer(server); + } + }); + it('should support HTTP protocol', async () => { const server = await startHTTPServer( (req, res) => { diff --git a/tests/unit/api.test.js b/tests/unit/api.test.js index bcfd2ec4..a8070ad0 100644 --- a/tests/unit/api.test.js +++ b/tests/unit/api.test.js @@ -35,6 +35,94 @@ describe('static api', () => { await promise; }); + it('should support URL object shorthand with config', async () => { + const url = new URL('http://example.com/test?a=1'); + + const response = await axios(url, { + params: { + b: 2, + }, + adapter: (config) => + Promise.resolve({ + data: null, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {}, + }), + }); + + assert.strictEqual(response.config.url, url.toString()); + assert.deepStrictEqual(response.config.params, { b: 2 }); + }); + + it('should not require global URL to be a constructor for string requests', async () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'URL'); + + Object.defineProperty(globalThis, 'URL', { + configurable: true, + writable: true, + value: {}, + }); + + try { + const response = await axios('/test', { + adapter: (config) => + Promise.resolve({ + data: null, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {}, + }), + }); + + assert.strictEqual(response.config.url, '/test'); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, 'URL', descriptor); + } else { + delete globalThis.URL; + } + } + }); + + it('should normalize URL object set by a request interceptor before dispatch', async () => { + const url = new URL('http://example.com/interceptor'); + let transformUrl; + const interceptorId = axios.interceptors.request.use((config) => { + config.url = url; + return config; + }); + + try { + const response = await axios('/test', { + transformRequest: [ + function (data) { + transformUrl = this.url; + return data; + }, + ], + adapter: (config) => + Promise.resolve({ + data: null, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {}, + }), + }); + + assert.strictEqual(transformUrl, url.toString()); + assert.strictEqual(response.config.url, url.toString()); + } finally { + axios.interceptors.request.eject(interceptorId); + } + }); + it('should have defaults', () => { assert.strictEqual(typeof axios.defaults, 'object'); assert.strictEqual(typeof axios.defaults.headers, 'object'); @@ -69,6 +157,16 @@ describe('static api', () => { assert.strictEqual(typeof axios.getUri, 'function'); }); + it('should support URL object config in getUri', () => { + const url = new URL('https://api.example.com/foo'); + + assert.strictEqual(axios.getUri({ url }), url.toString()); + assert.strictEqual( + axios.getUri({ baseURL: 'https://example.com/base', url }), + url.toString() + ); + }); + it('should have isAxiosError properties', () => { assert.strictEqual(typeof axios.isAxiosError, 'function'); }); diff --git a/tests/unit/helpers/buildURL.test.js b/tests/unit/helpers/buildURL.test.js index 6f114c06..ee62a245 100644 --- a/tests/unit/helpers/buildURL.test.js +++ b/tests/unit/helpers/buildURL.test.js @@ -101,6 +101,51 @@ describe('helpers::buildURL', () => { expect(buildURL('/foo', new URLSearchParams('bar=baz'))).toEqual('/foo?bar=baz'); }); + it('should support URL object without params', () => { + const url = new URL('http://example.com/foo?a=1'); + expect(buildURL(url)).toEqual('http://example.com/foo?a=1'); + }); + + it('should support URL object with params', () => { + const url = new URL('http://example.com/foo?a=1'); + expect(buildURL(url, { b: 2 })).toEqual('http://example.com/foo?a=1&b=2'); + }); + + it('should support URL-like object with params', () => { + const url = { + toString() { + return 'http://example.com/foo?a=1'; + }, + }; + + expect(buildURL(url, { b: 2 })).toEqual('http://example.com/foo?a=1&b=2'); + }); + + it('should not require global URL to be a constructor', () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'URL'); + const url = { + toString() { + return 'http://example.com/foo?a=1'; + }, + }; + + Object.defineProperty(globalThis, 'URL', { + configurable: true, + writable: true, + value: {}, + }); + + try { + expect(buildURL(url, { b: 2 })).toEqual('http://example.com/foo?a=1&b=2'); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, 'URL', descriptor); + } else { + delete globalThis.URL; + } + } + }); + it('should support custom serialize function', () => { const params = { x: 1,