La méthode HTTP QUERY, et qui la prend vraiment en charge

Partager

En juin, l’IETF a publié la RFC 10008, qui définit une nouvelle méthode HTTP appelée QUERY. La dernière méthode standardisée était PATCH, en 2010. QUERY fonctionne comme un GET avec un corps : elle est sûre, idempotente et mise en cache possible, et la requête passe dans le corps au lieu de l’URL.

Cela ressemble à quelque chose que tout le monde devrait déjà prendre en charge, alors nous avons testé. Nous avons envoyé une requête QUERY à cinq API de test publiques, dont la nôtre, et obtenu cinq réponses différentes. Une seule a exécuté la requête. Nous avons aussi essayé Node, ASP.NET Core et Python, et les trois l’ont gérée sans problème. La méthode est prête côté code applicatif. Les proxys et serveurs intermédiaires, c’est une autre affaire.

Le problème que résout QUERY

Imaginez une recherche de produits avec une douzaine de filtres, une fourchette de prix imbriquée et un tri. Vous avez aujourd’hui deux options, et toutes deux ont des défauts.

  • GET avec tout dans la query string. Il faut encoder les filtres imbriqués dans l’URL d’une façon ou d’une autre. Les URL longues butent sur les limites de longueur des serveurs et des proxys, et la requête entière finit dans les logs d’accès, l’historique du navigateur et les en-têtes Referer. Mettre un corps sur un GET n’aide pas : la RFC 9110 dit que le contenu d’une requête GET n’a pas de sens défini, et beaucoup de proxys et de clients l’ignorent.
  • POST /search. C’est ce que font la plupart des API. Ça marche, mais POST annonce à chaque client et proxy que la requête pourrait modifier quelque chose. Rien ne la relance donc automatiquement après une coupure, et aucun cache ne garde le résultat.

QUERY vous donne un corps de requête que clients, proxys et caches peuvent toujours traiter comme un GET :

GETPOSTQUERY
Le corps de requête a un sens définiNonOuiOui
Sûre (ne modifie rien)OuiNonOui
Idempotente (on peut réessayer)OuiNonOui
Réponses mises en cacheOuiRarementOui, avec le corps dans la clé
La requête finit dans les URL et les logsOuiNonNon

À quoi ressemble une requête QUERY

On dirait un POST, sauf que le nom de la méthode indique à tous les intermédiaires qu’elle ne fait que lire :

Requête
QUERY /products/search HTTP/1.1
Host: shop.example
Content-Type: application/json
Accept: application/json

{"category":"electronics","maxPrice":500,"inStock":true,"sort":"price"}
Réponse
HTTP/1.1 200 OK
Content-Type: application/json
Location: /products/search/q/7f3a9c
Cache-Control: max-age=60

[{"id":4,"name":"USB-C hub","price":39},{"id":11,"name":"Mechanical keyboard","price":89}]

L’en-tête Location est facultatif, et c’est l’une des parties les plus utiles de la RFC. Il donne une URL qui exécute la même requête avec un simple GET : un client envoie le corps une fois, puis se sert d’un lien ordinaire. Content-Location veut dire autre chose : une URL qui renvoie ce résultat précis. Un serveur peut aussi répondre 303 See Other sans aucun résultat, ce qui envoie le client les récupérer en GET à l’URL de Location.

Ce que la RFC demande aux serveurs

Il s’agit surtout d’être strict sur le corps, puisque le corps est la requête :

  • Content-Type est obligatoire. S’il manque, le serveur rejette la requête avec un 4xx, en général 400. Le serveur ne doit pas deviner le type d’après le corps.
  • Un format que la ressource n’accepte pas pour les requêtes reçoit 415. Un corps qui ne correspond pas à son type déclaré reçoit 400, et une requête bien formée que le serveur ne peut pas exécuter reçoit 422.
  • Un nouvel en-tête de réponse, Accept-Query, liste les formats de requête qu’une ressource accepte. Il indique aussi aux clients que QUERY est pris en charge :
Réponse
HTTP/1.1 415 Unsupported Media Type
Accept-Query: application/json, "application/jsonpath"
  • Les caches peuvent garder les réponses QUERY, mais la clé de cache doit inclure le corps de la requête. Deux requêtes vers la même URL avec des filtres différents font deux entrées de cache différentes.
  • Après une redirection 301 ou 302, le client renvoie la QUERY vers la nouvelle URL. Les clients transforment souvent un POST en GET après ces redirections, et la RFC l’interdit pour QUERY.

Nous avons envoyé QUERY à cinq API de test publiques

Les API de test sont faites pour renvoyer ce qu’on leur envoie, on s’attendrait donc à ce qu’elles soient les serveurs les plus accommodants qui soient. Voici le script :

probe-query.mjs
// Send one QUERY request to each URL and report what came back.
const targets = [
  "https://httpbin.org/anything",
  "https://httpbingo.org/anything",
  "https://postman-echo.com/query",
  "https://jsonplaceholder.typicode.com/posts",
  "https://api.testingapis.com/api/methods/any",
];

for (const url of targets) {
  const res = await fetch(url, {
    method: "QUERY", // capitals: fetch only uppercases the six standard methods
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ category: "electronics", maxPrice: 500 }),
  });
  const type = (res.headers.get("content-type") ?? "-").split(";")[0];
  const allow = res.headers.get("allow") ?? "-";
  console.log(new URL(url).host.padEnd(29), res.status, type.padEnd(17), "allow:", allow);
}
Sortie (Node 24, 10 octobre 2026)
httpbin.org                   405 text/html         allow: HEAD, POST, TRACE, PUT, GET, OPTIONS, DELETE, PATCH
httpbingo.org                 200 application/json  allow: -
postman-echo.com              400 text/plain        allow: -
jsonplaceholder.typicode.com  404 application/json  allow: -
api.testingapis.com           400 text/plain        allow: -

Cinq serveurs, cinq réponses différentes :

APIRéponseCe qu’elle dit à un client
httpbingo.org200 avec la méthode et le corps renvoyésÇa marche. httpbingo est une réécriture d’httpbin en Go, et net/http de Go laisse passer n’importe quelle méthode.
httpbin.org405 avec un en-tête AllowLa bonne façon de dire non : la méthode n’est pas prise en charge, et voici la liste de celles qui le sont.
jsonplaceholder.typicode.com404Trompeur. L’URL existe, mais le routeur n’a rien trouvé, un client croirait donc s’être trompé de chemin.
postman-echo.com400, 11 octets de text/plainDit que la requête était mal formée, ce qu’elle n’était pas.
api.testingapis.com400, 11 octets de text/plainLa même chose, et celle-ci est la nôtre.

Voici notre propre API en échec :

Terminal
$ curl -i -X QUERY https://api.testingapis.com/api/methods/any \
    -H "content-type: application/json" -d '{"q":"x"}'
HTTP/1.1 400 Bad Request
content-length: 11
content-type: text/plain
connection: close

Bad Request

Cette réponse n’a jamais atteint notre application. Chaque réponse de notre application ASP.NET Core porte les en-têtes server: Kestrel et x-powered-by: TestingAPIs, et celle-ci n’a ni l’un ni l’autre. L’API tourne sur Azure Container Apps, et le proxy d’entrée placé devant a rejeté la méthode avant de la transmettre. Postman Echo renvoie le même corps de 11 octets et un en-tête x-envoy-upstream-service-time, il semble donc qu’il se passe la même chose là-bas, derrière Envoy. Nous obtenons le même 400 pour une méthode inventée comme FOO : le proxy traite QUERY comme une méthode inconnue, pas comme une mauvaise requête.

La leçon porte sur l’endroit du problème. Votre framework peut très bien gérer QUERY pendant qu’un répartiteur de charge, un ingress ou un WAF devant lui refuse la requête, et l’erreur reçue n’en dit rien. Testez donc QUERY à travers chaque saut qu’emprunte un vrai client, pas seulement contre localhost.

Les frameworks la prennent déjà en charge

Côté code applicatif, c’est bien mieux. Nous avons essayé trois serveurs sur la machine où nous écrivons ceci, et chacun a géré QUERY sans configuration supplémentaire.

Node.js 24. Le parseur HTTP intégré connaît la méthode, et fetch l’envoie :

server.mjs
import http from "node:http";

console.log(http.METHODS.includes("QUERY")); // true on Node 24

http
  .createServer((req, res) => {
    if (req.method !== "QUERY") {
      res.writeHead(405, { allow: "QUERY" }).end();
      return;
    }
    let body = "";
    req.on("data", (chunk) => (body += chunk));
    req.on("end", () => {
      const filters = JSON.parse(body);
      res.writeHead(200, { "content-type": "application/json" });
      res.end(JSON.stringify({ method: req.method, filters }));
    });
  })
  .listen(3000);

ASP.NET Core 10. .NET 10 inclut HttpMethods.Query côté serveur et HttpMethod.Query pour HttpClient :

Program.cs
using System.Net.Http.Json;

var app = WebApplication.Create(args);

app.MapMethods("/products/search", [HttpMethods.Query], async (HttpRequest req) =>
{
    var filters = await req.ReadFromJsonAsync<ProductFilter>();
    return Results.Ok(new { method = req.Method, filters });
});

app.Urls.Add("http://localhost:5000");
await app.StartAsync();

// Call it with HttpClient, then try a GET on the same route.
using var http = new HttpClient();
var msg = new HttpRequestMessage(HttpMethod.Query, "http://localhost:5000/products/search")
{
    Content = JsonContent.Create(new { category = "electronics", maxPrice = 500 })
};
var res = await http.SendAsync(msg);
Console.WriteLine($"QUERY: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");

var get = await http.GetAsync("http://localhost:5000/products/search");
Console.WriteLine($"GET:   {(int)get.StatusCode} Allow: {string.Join(", ", get.Content.Headers.Allow)}");

await app.StopAsync();

record ProductFilter(string Category, decimal MaxPrice);
Sortie (.NET 10)
QUERY: 200 {"method":"QUERY","filters":{"category":"electronics","maxPrice":500}}
GET:   405 Allow: QUERY

Regardez la deuxième ligne. Un GET vers une route qui ne gère que QUERY reçoit un 405 avec Allow: QUERY sans que vous ayez rien écrit pour cela. C’est le même refus poli que nous a donné httpbin.org.

Python. http.server passe chaque requête à un gestionnaire do_<METHOD>, ajouter QUERY ne demande donc qu’une méthode. Côté client, urllib.request.Request accepte method="QUERY".

server.py
from http.server import BaseHTTPRequestHandler, HTTPServer
import json

class Handler(BaseHTTPRequestHandler):
    def do_QUERY(self):
        length = int(self.headers.get("content-length", 0))
        filters = json.loads(self.rfile.read(length))
        body = json.dumps({"method": self.command, "filters": filters}).encode()
        self.send_response(200)
        self.send_header("content-type", "application/json")
        self.end_headers()
        self.wfile.write(body)

HTTPServer(("127.0.0.1", 8000), Handler).serve_forever()

Écrivez-la en majuscules

Les noms de méthodes HTTP sont sensibles à la casse. Le standard fetch en met six en majuscules pour vous : DELETE, GET, HEAD, OPTIONS, POST et PUT. Tout le reste part exactement comme vous l’avez tapé. Voici ce qu’un serveur Node a renvoyé pour trois graphies :

Sortie (Node 24)
"query" -> 400
"QUERY" -> 200 QUERY
"post" -> 200 POST

method: "query" part en minuscules, query, et le serveur répond 400. PATCH a le même piège depuis des années, et Node affiche un avertissement quand vous écrivez "patch". Pour "query", il ne dit rien. Si une requête QUERY échoue sans raison apparente, vérifiez les majuscules.

Les navigateurs envoient d’abord un preflight

QUERY ne figure pas dans la courte liste des méthodes que les navigateurs envoient en cross-origin sans demander d’abord. Avant une QUERY cross-origin, le navigateur envoie donc un preflight OPTIONS, et le serveur doit citer QUERY dans Access-Control-Allow-Methods. httpbingo était la seule API à exécuter notre requête, et voici ce qu’elle répond à un navigateur :

Terminal
$ curl -i -X OPTIONS https://httpbingo.org/anything \
    -H "Origin: https://example.com" \
    -H "Access-Control-Request-Method: QUERY" \
    -H "Access-Control-Request-Headers: content-type"
HTTP/1.1 200 OK
access-control-allow-origin: https://example.com
access-control-allow-methods: GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS
access-control-allow-headers: content-type

QUERY n’est pas dans cette liste, donc la même requête qui marchait depuis Node échouerait dans un navigateur avec une erreur CORS. Si votre configuration CORS liste les méthodes autorisées par leur nom, ajoutez-y QUERY.

OpenAPI 3.2 sait la décrire

OpenAPI 3.2, sorti en septembre 2025, a ajouté une opération query aux path items, à côté de get et post :

openapi.yaml
openapi: 3.2.0
info:
  title: Shop
  version: 1.0.0
paths:
  /products/search:
    query:
      summary: Search products with filters in the body
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProductFilter"
      responses:
        "200":
          description: Matching products

Reste à savoir si votre générateur de documentation, votre générateur de clients ou votre passerelle d’API lisent déjà ce champ. Vérifiez avant de compter dessus, comme pour le réseau.

Faut-il déjà utiliser QUERY ?

Pour le trafic entre vos propres services, où vous maîtrisez chaque saut, oui. Testez le chemin complet une fois et vous obtenez les nouvelles tentatives et le cache que POST /search ne peut pas offrir.

Pour une API publique, nous l’ajouterions sans rien retirer :

  • Gardez POST /search en état de marche et ajoutez QUERY sur la même ressource. Les clients et réseaux qui ne savent pas envoyer QUERY gardent une porte d’entrée.
  • Envoyez Accept-Query pour que les clients sachent que QUERY est pris en charge avant d’essayer.
  • Renvoyez un en-tête Location. Une fois qu’un client a exécuté une requête, il peut la répéter avec un GET, qui passe par tous les proxys existants.
  • Sur les routes qui ne prennent pas QUERY en charge, répondez 405 avec un en-tête Allow. Ne répondez pas 400 ou 404, qui suggèrent un problème dans la requête du client.

Si vous vérifiez comment votre client gère un serveur qui refuse une méthode, notre leçon sur les méthodes HTTP couvre 405 et Allow, et le guide des réponses d’erreur montre comment obtenir à la demande des réponses 4xx et 5xx de notre API.

PATCH a mis des années à fonctionner de façon fiable entre clients, proxys et frameworks, et QUERY prendra sans doute aussi un moment. La différence, cette fois, c’est que le code applicatif est prêt dès le premier jour. Ce sont les proxys qui coincent encore.