diff --git a/docs/es/pages/advanced/request-method-aliases.md b/docs/es/pages/advanced/request-method-aliases.md index 0bb95bee..4cc2220f 100644 --- a/docs/es/pages/advanced/request-method-aliases.md +++ b/docs/es/pages/advanced/request-method-aliases.md @@ -80,6 +80,26 @@ El método `patch` se usa para hacer una solicitud PATCH. Acepta una URL, un obj axios.patch(url: string, data?: D, config?: AxiosRequestConfig): AxiosResponse; ``` +### `query` + +El método `query` se usa para hacer una solicitud QUERY, un método seguro e idempotente que transporta un cuerpo. Acepta una URL, un objeto de datos opcional y un objeto de configuración opcional como argumentos, y devuelve una Promise que se resuelve en el objeto de respuesta. Úsalo para operaciones de tipo lectura cuyos parámetros sean demasiado complejos o sensibles para ir en la URL. + +```ts +axios.query(url: string, data?: D, config?: AxiosRequestConfig): AxiosResponse; +``` + +```js +// Enviar un filtro de búsqueda complejo como cuerpo de la solicitud +const { data } = await axios.query("/api/search", { + selector: ["name", "email"], + filter: { active: true, role: "admin" }, +}); +``` + +::: warning Especificación en borrador +El método QUERY está definido por un [Internet-Draft](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/) del IETF y todavía no ha sido estandarizado. La semántica e incluso el propio nombre del método pueden cambiar antes de la publicación final, y el soporte en servidores, proxies y CDN es desigual. Verifica que tu infraestructura acepte `QUERY` de extremo a extremo antes de usarlo en producción. +::: + ## Métodos abreviados para datos de formulario Estos métodos son equivalentes a sus contrapartes anteriores, pero predefinen `Content-Type` como `multipart/form-data`. Son la forma recomendada de subir archivos o enviar formularios HTML. diff --git a/docs/fr/pages/advanced/request-method-aliases.md b/docs/fr/pages/advanced/request-method-aliases.md index bf7ac76e..4689f117 100644 --- a/docs/fr/pages/advanced/request-method-aliases.md +++ b/docs/fr/pages/advanced/request-method-aliases.md @@ -80,6 +80,26 @@ La méthode `patch` est utilisée pour effectuer une requête PATCH. Elle accept axios.patch(url: string, data?: D, config?: AxiosRequestConfig): AxiosResponse; ``` +### `query` + +La méthode `query` est utilisée pour effectuer une requête QUERY, une méthode sûre et idempotente qui transporte un corps. Elle accepte une URL, un objet de données optionnel et un objet de configuration optionnel en arguments et retourne une promise qui se résout vers l'objet de réponse. Utilisez-la pour des opérations de type lecture dont les paramètres sont trop complexes ou trop sensibles pour figurer dans l'URL. + +```ts +axios.query(url: string, data?: D, config?: AxiosRequestConfig): AxiosResponse; +``` + +```js +// Envoyer un filtre de recherche complexe dans le corps de la requête +const { data } = await axios.query("/api/search", { + selector: ["name", "email"], + filter: { active: true, role: "admin" }, +}); +``` + +::: warning Spécification en cours d'élaboration +La méthode QUERY est définie par un [Internet-Draft](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/) de l'IETF et n'a pas encore été standardisée. La sémantique et le nom même de la méthode peuvent évoluer avant la publication finale, et la prise en charge par les serveurs, proxys et CDN est inégale. Vérifiez que votre pile accepte `QUERY` de bout en bout avant de vous en servir en production. +::: + ## Méthodes raccourcies pour les données de formulaire Ces méthodes sont équivalentes à leurs homologues ci-dessus, mais prédéfinissent le `Content-Type` à `multipart/form-data`. Elles constituent la façon recommandée d'envoyer des fichiers ou de soumettre des formulaires HTML. diff --git a/docs/pages/advanced/request-method-aliases.md b/docs/pages/advanced/request-method-aliases.md index 5bd97eae..a4768a0f 100644 --- a/docs/pages/advanced/request-method-aliases.md +++ b/docs/pages/advanced/request-method-aliases.md @@ -80,6 +80,26 @@ The `patch` method is used to make a PATCH request. It takes a URL, an optional axios.patch(url: string, data?: D, config?: AxiosRequestConfig): AxiosResponse; ``` +### `query` + +The `query` method is used to make a QUERY request, a safe and idempotent method that carries a body. It takes a URL, an optional data object, and an optional configuration object as arguments and returns a promise that resolves to the response object. Use it for read-style operations whose parameters are too complex or sensitive to fit in the URL. + +```ts +axios.query(url: string, data?: D, config?: AxiosRequestConfig): AxiosResponse; +``` + +```js +// Send a complex search filter as a request body +const { data } = await axios.query("/api/search", { + selector: ["name", "email"], + filter: { active: true, role: "admin" }, +}); +``` + +::: warning Draft specification +The QUERY method is defined by an IETF [Internet-Draft](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/) and has not yet been standardized. Semantics and the method name itself may change before final publication, and server, proxy, and CDN support is uneven. Verify your stack accepts `QUERY` end to end before relying on it in production. +::: + ## Form data shorthand methods These methods are equivalent to their counterparts above, but preset `Content-Type` to `multipart/form-data`. They are the recommended way to upload files or submit HTML forms. diff --git a/docs/zh/pages/advanced/request-method-aliases.md b/docs/zh/pages/advanced/request-method-aliases.md index 427a6a77..a20cc9c1 100644 --- a/docs/zh/pages/advanced/request-method-aliases.md +++ b/docs/zh/pages/advanced/request-method-aliases.md @@ -80,6 +80,26 @@ axios.put(url: string, data?: D, config?: AxiosRequestConfig): AxiosResponse< axios.patch(url: string, data?: D, config?: AxiosRequestConfig): AxiosResponse; ``` +### `query` + +`query` 方法用于发起 QUERY 请求,这是一种安全且幂等的、可以携带请求体的方法。它接受 URL、可选数据对象和可选配置对象,返回解析为响应对象的 Promise。当读取类操作的参数过于复杂或敏感、不适合放在 URL 中时,可以使用该方法。 + +```ts +axios.query(url: string, data?: D, config?: AxiosRequestConfig): AxiosResponse; +``` + +```js +// 将复杂的搜索条件作为请求体发送 +const { data } = await axios.query("/api/search", { + selector: ["name", "email"], + filter: { active: true, role: "admin" }, +}); +``` + +::: warning 草案规范 +QUERY 方法目前由 IETF 的 [Internet-Draft](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/) 定义,尚未成为正式标准。其语义乃至方法名称都可能在最终发布前发生变化,并且服务器、代理和 CDN 的支持情况参差不齐。在用于生产环境之前,请确认你的整个链路都能够正确处理 `QUERY` 请求。 +::: + ## 表单数据快捷方法 这些方法与上述对应方法等价,但会预设 `Content-Type` 为 `multipart/form-data`,是上传文件或提交 HTML 表单的推荐方式。 diff --git a/index.d.cts b/index.d.cts index d229d1e0..9133f055 100644 --- a/index.d.cts +++ b/index.d.cts @@ -221,6 +221,11 @@ declare class Axios { data?: D, config?: axios.AxiosRequestConfig ): Promise; + query, D = any>( + url: string, + data?: D, + config?: axios.AxiosRequestConfig + ): Promise; } declare enum HttpStatusCode { @@ -359,7 +364,8 @@ declare namespace axios { | 'PATCH' | 'PURGE' | 'LINK' - | 'UNLINK'; + | 'UNLINK' + | 'QUERY'; type Method = (UppercaseMethod | Lowercase) & {}; @@ -566,6 +572,7 @@ declare namespace axios { purge?: RawAxiosRequestHeaders; link?: RawAxiosRequestHeaders; unlink?: RawAxiosRequestHeaders; + query?: RawAxiosRequestHeaders; } interface AxiosDefaults extends Omit, 'headers'> { diff --git a/index.d.ts b/index.d.ts index 496b3bfe..18fa3292 100644 --- a/index.d.ts +++ b/index.d.ts @@ -243,7 +243,8 @@ type UppercaseMethod = | 'PATCH' | 'PURGE' | 'LINK' - | 'UNLINK'; + | 'UNLINK' + | 'QUERY'; export type Method = (UppercaseMethod | Lowercase) & {}; @@ -467,6 +468,7 @@ export interface HeadersDefaults { purge?: RawAxiosRequestHeaders; link?: RawAxiosRequestHeaders; unlink?: RawAxiosRequestHeaders; + query?: RawAxiosRequestHeaders; } export interface AxiosDefaults extends Omit, 'headers'> { @@ -650,6 +652,11 @@ export class Axios { data?: D, config?: AxiosRequestConfig ): Promise; + query, D = any>( + url: string, + data?: D, + config?: AxiosRequestConfig + ): Promise; } export interface AxiosInstance extends Axios { diff --git a/lib/core/Axios.js b/lib/core/Axios.js index 034e37ad..903249a8 100644 --- a/lib/core/Axios.js +++ b/lib/core/Axios.js @@ -148,7 +148,7 @@ class Axios { let contextHeaders = headers && utils.merge(headers.common, headers[config.method]); headers && - utils.forEach(['delete', 'get', 'head', 'post', 'put', 'patch', 'common'], (method) => { + utils.forEach(['delete', 'get', 'head', 'post', 'put', 'patch', 'query', 'common'], (method) => { delete headers[method]; }); @@ -251,7 +251,7 @@ utils.forEach(['delete', 'get', 'head', 'options'], function forEachMethodNoData }; }); -utils.forEach(['post', 'put', 'patch'], function forEachMethodWithData(method) { +utils.forEach(['post', 'put', 'patch', 'query'], function forEachMethodWithData(method) { function generateHTTPMethod(isForm) { return function httpMethod(url, data, config) { return this.request( @@ -271,7 +271,11 @@ utils.forEach(['post', 'put', 'patch'], function forEachMethodWithData(method) { Axios.prototype[method] = generateHTTPMethod(); - Axios.prototype[method + 'Form'] = generateHTTPMethod(true); + // QUERY is a safe/idempotent read method; multipart form bodies don't fit + // its semantics, so no queryForm shorthand is generated. + if (method !== 'query') { + Axios.prototype[method + 'Form'] = generateHTTPMethod(true); + } }); export default Axios; diff --git a/lib/defaults/index.js b/lib/defaults/index.js index 30c8ea9e..642a0895 100644 --- a/lib/defaults/index.js +++ b/lib/defaults/index.js @@ -170,7 +170,7 @@ const defaults = { }, }; -utils.forEach(['delete', 'get', 'head', 'post', 'put', 'patch'], (method) => { +utils.forEach(['delete', 'get', 'head', 'post', 'put', 'patch', 'query'], (method) => { defaults.headers[method] = {}; }); diff --git a/tests/module/cjs/tests/helpers/cjs-typing.ts b/tests/module/cjs/tests/helpers/cjs-typing.ts index 46509f57..5c12998b 100644 --- a/tests/module/cjs/tests/helpers/cjs-typing.ts +++ b/tests/module/cjs/tests/helpers/cjs-typing.ts @@ -91,6 +91,8 @@ axios.put('/user', { foo: 'bar' }).then(handleResponse).catch(handleError); axios.patch('/user', { foo: 'bar' }).then(handleResponse).catch(handleError); +axios.query('/user', { foo: 'bar' }).then(handleResponse).catch(handleError); + // Typed methods interface UserCreationDef { name: string; diff --git a/tests/module/esm/tests/helpers/esm-index.ts b/tests/module/esm/tests/helpers/esm-index.ts index ba38a8aa..eebf989d 100644 --- a/tests/module/esm/tests/helpers/esm-index.ts +++ b/tests/module/esm/tests/helpers/esm-index.ts @@ -113,6 +113,8 @@ axios.put('/user', { foo: 'bar' }).then(handleResponse).catch(handleError); axios.patch('/user', { foo: 'bar' }).then(handleResponse).catch(handleError); +axios.query('/user', { foo: 'bar' }).then(handleResponse).catch(handleError); + // Typed methods interface UserCreationDef { name: string; diff --git a/tests/unit/adapters/fetch.test.js b/tests/unit/adapters/fetch.test.js index a9e51cad..35cc0234 100644 --- a/tests/unit/adapters/fetch.test.js +++ b/tests/unit/adapters/fetch.test.js @@ -621,6 +621,33 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () => } }); + it('should send QUERY requests with a body through the fetch adapter', async () => { + const server = await startHTTPServer( + (req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ method: req.method, url: req.url, body })); + }); + }, + { port: 0 } + ); + + try { + const { data } = await fetchAxios.query( + `http://localhost:${server.address().port}/search`, + { selector: 'field1' } + ); + + assert.strictEqual(data.method, 'QUERY'); + assert.strictEqual(data.url, '/search'); + assert.deepStrictEqual(JSON.parse(data.body), { selector: 'field1' }); + } finally { + await stopHTTPServer(server); + } + }); + it('should support params', async () => { const server = await startHTTPServer((req, res) => res.end(req.url), { port: SERVER_PORT }); try { diff --git a/tests/unit/api.test.js b/tests/unit/api.test.js index d84aa0d5..bcfd2ec4 100644 --- a/tests/unit/api.test.js +++ b/tests/unit/api.test.js @@ -12,6 +12,7 @@ describe('static api', () => { assert.strictEqual(typeof axios.post, 'function'); assert.strictEqual(typeof axios.put, 'function'); assert.strictEqual(typeof axios.patch, 'function'); + assert.strictEqual(typeof axios.query, 'function'); }); it('should have promise method helpers', async () => { @@ -93,6 +94,7 @@ describe('instance api', () => { assert.strictEqual(typeof instance.post, 'function'); assert.strictEqual(typeof instance.put, 'function'); assert.strictEqual(typeof instance.patch, 'function'); + assert.strictEqual(typeof instance.query, 'function'); }); it('should have interceptors', () => { diff --git a/tests/unit/query.test.js b/tests/unit/query.test.js new file mode 100644 index 00000000..4a3f8d38 --- /dev/null +++ b/tests/unit/query.test.js @@ -0,0 +1,268 @@ +import { describe, it } from 'vitest'; +import assert from 'assert'; +import axios from '../../index.js'; +import { startHTTPServer, stopHTTPServer } from '../setup/server.js'; + +describe('QUERY method', () => { + describe('static axios.query()', () => { + it('should make a request with the QUERY HTTP method', async () => { + const response = await axios.query('/test', null, { + adapter: (config) => { + assert.strictEqual(config.method, 'query'); + return Promise.resolve({ + data: null, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {}, + }); + }, + }); + + assert.strictEqual(response.status, 200); + }); + + it('should support a request body', async () => { + const requestBody = { selector: 'field1, field2', filter: { active: true } }; + + await axios.query('/search', requestBody, { + adapter: (config) => { + assert.deepStrictEqual(config.data, JSON.stringify(requestBody)); + return Promise.resolve({ + data: null, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {}, + }); + }, + }); + }); + + it('should support custom headers', async () => { + await axios.query('/test', null, { + headers: { + 'X-Custom-Header': 'custom-value', + Authorization: 'Bearer token-abc', + }, + adapter: (config) => { + assert.strictEqual(config.headers.get('X-Custom-Header'), 'custom-value'); + assert.strictEqual(config.headers.get('Authorization'), 'Bearer token-abc'); + return Promise.resolve({ + data: null, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {}, + }); + }, + }); + }); + + it('should work with baseURL configuration', async () => { + const instance = axios.create({ baseURL: 'http://example.com/api' }); + + await instance.query('/resources', { fields: ['name'] }, { + adapter: (config) => { + assert.strictEqual(config.baseURL, 'http://example.com/api'); + assert.strictEqual(config.url, '/resources'); + assert.strictEqual(config.method, 'query'); + return Promise.resolve({ + data: null, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {}, + }); + }, + }); + }); + + it('should set Content-Type to application/json for object bodies', async () => { + await axios.query('/test', { key: 'value' }, { + adapter: (config) => { + assert.ok( + config.headers.get('Content-Type').includes('application/json'), + 'Expected Content-Type to include application/json' + ); + return Promise.resolve({ + data: null, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {}, + }); + }, + }); + }); + }); + + describe('instance.query()', () => { + it('should make a request with the QUERY HTTP method on an instance', async () => { + const instance = axios.create(); + + const response = await instance.query('/test', null, { + adapter: (config) => { + assert.strictEqual(config.method, 'query'); + return Promise.resolve({ + data: null, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {}, + }); + }, + }); + + assert.strictEqual(response.status, 200); + }); + + it('should merge instance defaults with request config', async () => { + const instance = axios.create({ + headers: { 'X-Instance-Header': 'from-instance' }, + }); + + await instance.query('/test', null, { + headers: { 'X-Request-Header': 'from-request' }, + adapter: (config) => { + assert.strictEqual(config.headers.get('X-Instance-Header'), 'from-instance'); + assert.strictEqual(config.headers.get('X-Request-Header'), 'from-request'); + return Promise.resolve({ + data: null, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {}, + }); + }, + }); + }); + }); + + describe('axios({ method: "query" })', () => { + it('should support the generic request form', async () => { + const response = await axios({ + method: 'query', + url: '/test', + data: { selector: '*' }, + adapter: (config) => { + assert.strictEqual(config.method, 'query'); + assert.deepStrictEqual(config.data, JSON.stringify({ selector: '*' })); + return Promise.resolve({ + data: { result: 'ok' }, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {}, + }); + }, + }); + + assert.deepStrictEqual(response.data, { result: 'ok' }); + }); + }); + + describe('with HTTP server', () => { + it('should send QUERY requests with a body to a real server', async () => { + const server = await startHTTPServer( + (req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + method: req.method, + url: req.url, + body, + headers: req.headers, + })); + }); + }, + { port: 0 } + ); + + try { + const { data } = await axios.query( + `http://localhost:${server.address().port}/search`, + { selector: 'field1' } + ); + + assert.strictEqual(data.method, 'QUERY'); + assert.strictEqual(data.url, '/search'); + + const parsedBody = JSON.parse(data.body); + assert.deepStrictEqual(parsedBody, { selector: 'field1' }); + assert.ok( + data.headers['content-type'].includes('application/json'), + 'Expected server to receive application/json content-type' + ); + } finally { + await stopHTTPServer(server); + } + }); + + it('should send QUERY requests with custom headers to a real server', async () => { + const server = await startHTTPServer( + (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + method: req.method, + headers: req.headers, + })); + }, + { port: 0 } + ); + + try { + const { data } = await axios.query( + `http://localhost:${server.address().port}/test`, + null, + { + headers: { + 'X-Custom': 'test-value', + }, + } + ); + + assert.strictEqual(data.method, 'QUERY'); + assert.strictEqual(data.headers['x-custom'], 'test-value'); + } finally { + await stopHTTPServer(server); + } + }); + + it('should send QUERY requests with baseURL to a real server', async () => { + const server = await startHTTPServer( + (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + method: req.method, + url: req.url, + })); + }, + { port: 0 } + ); + + try { + const instance = axios.create({ + baseURL: `http://localhost:${server.address().port}/api`, + }); + + const { data } = await instance.query('/resources', { fields: ['name'] }); + + assert.strictEqual(data.method, 'QUERY'); + assert.strictEqual(data.url, '/api/resources'); + } finally { + await stopHTTPServer(server); + } + }); + }); +});