Découvrez la méthode HTTP QUERY (RFC 10008) : sémantique sûre et idempotente, corps de requête, cache, avec exemples fetch, Node.js et Angular.
Pourquoi une nouvelle méthode HTTP ?
GET (sûr et cacheable, mais sans corps utile) et POST (corps riche, mais sémantiquement faux pour lire). QUERY est le chaînon manquant : un corps de requête plus une sémantique de lecture sûre.
Chaque développeur back-end a un jour buté sur le même mur. Vous construisez un endpoint de recherche avancée — filtres imbriqués, tri multi-colonnes, facettes, pagination — et vous devez choisir entre deux méthodes HTTP, aucune ne convenant vraiment.
L'impasse de GET
GET est la méthode idéale pour lire : elle est sûre (elle ne modifie rien), idempotente (la rejouer ne change rien) et cacheable. Mais son corps de requête est sémantiquement indéfini : les serveurs, proxies et navigateurs l'ignorent. Résultat, tout doit passer dans l'URL.
Un filtre un peu sérieux devient vite ce genre de monstre :
# Recherche produits : la lisibilité explose, et l'URL a une limite
GET /products?category=keyboards&inStock=true&minPrice=50&maxPrice=200&sort=price:asc,rating:desc&brands=logitech,keychron,corsair&tags=mechanical,wireless&page=2&size=20 HTTP/1.1
Host: example.com
Les problèmes s'accumulent :
- Limite de longueur : la plupart des serveurs coupent l'URL entre 2 000 et 8 000 caractères. Un filtre JSON profond dépasse vite.
- Encodage pénible : chaque caractère spécial doit être URL-encodé, les structures imbriquées deviennent illisibles.
- Fuite de données : l'URL est journalisée partout — logs serveur, historique navigateur, analytics, Referer. Des critères sensibles y transitent en clair.
Le mensonge sémantique de POST
Face à ce mur, l'industrie a bricolé la solution évidente : POST /search avec un corps JSON. Ça marche, mais c'est un abus. POST signifie « traite ce contenu pour créer ou modifier une ressource ». L'utiliser pour une simple lecture casse trois garanties :
- Le cache HTTP ne s'applique pas : chaque recherche identique refrappe le serveur.
- L'idempotence n'est pas garantie : un proxy ou un client ne peut pas rejouer la requête en toute sécurité.
- La cohérence de l'API se brouille :
POSTpour lire,POSTpour écrire — impossible de raisonner sur le trafic.
POST. QUERY répond enfin proprement au besoin, au niveau du protocole.
QUERY en bref : ce que dit la RFC 10008
QUERY comme une méthode qui « demande à la cible de traiter le contenu fourni de manière sûre et idempotente, puis de répondre avec le résultat de ce traitement ». Sûre, idempotente, cacheable — avec un corps de requête.
La définition officielle tient en une phrase, mais chaque mot compte. Décortiquons les trois propriétés fondamentales que QUERY apporte, là où GET et POST échouaient.
| Propriété | Signification | Bénéfice concret |
|---|---|---|
| Sûre (safe) | La requête ne demande aucune modification d'état côté serveur. | Les crawlers, préchargeurs et proxies peuvent l'exécuter sans risque. |
| Idempotente | La rejouer N fois produit le même effet que l'exécuter une fois. | Retry automatique possible en cas de timeout réseau. |
| Cacheable | La réponse peut être mise en cache — mais la clé inclut le corps. | Deux recherches identiques servent la même réponse cachée. |
La subtilité, on y reviendra, tient à la dernière ligne : contrairement à GET dont la clé de cache est l'URI, la clé de cache d'une réponse QUERY doit intégrer le contenu du corps. C'est ce qui rend la fonctionnalité puissante… et délicate à implémenter.
Anatomie d'une requête QUERY
QUERY ressemble à un POST avec un corps JSON, mais impose un Content-Type (sinon 400). La réponse peut renvoyer Content-Location et Location pour rendre la recherche partageable.
Voici une requête QUERY complète, telle qu'elle circule sur le fil :
QUERY /products HTTP/1.1
Host: example.com
Content-Type: application/json
Accept: application/json
{
"filters": { "category": "keyboards", "inStock": true },
"sort": [{ "field": "price", "order": "asc" }],
"page": { "size": 20 }
}
Les en-têtes qui comptent
Content-Type(obligatoire) : décrit le format du corps. En son absence, le serveur doit répondre400 Bad Request.Accept: indique le format de réponse souhaité (JSON, CSV, etc.).Content-Location(réponse) : pointe vers une ressource représentant le résultat de la requête.Location(réponse) : pointe vers une ressource représentant la requête elle-même, ce qui permet de générer un lien partageable vers une recherche.
Les codes d'erreur spécifiques
| Code | Quand ? |
|---|---|
400 Bad Request | En-tête Content-Type absent. |
415 Unsupported Media Type | Le Content-Type fourni n'est pas géré par l'endpoint. |
422 Unprocessable Entity | Le corps est bien parsé, mais ne constitue pas une requête valide. |
Une réponse type ressemble alors à ceci :
HTTP/1.1 200 OK
Content-Type: application/json
Content-Location: /products/results/9f2c1a
Location: /queries/9f2c1a
Cache-Control: max-age=60
{
"items": [ { "id": 42, "name": "Keychron K8", "price": 89.9 } ],
"total": 137,
"page": { "size": 20, "next": "eyJvZmZzZXQiOjIwfQ==" }
}
QUERY vs GET vs POST
QUERY coche les trois cases de GET (sûre, idempotente, cacheable) tout en acceptant un corps comme POST. C'est littéralement l'union des deux, sans leurs défauts respectifs.
| Méthode | Sûre | Idempotente | Cacheable | Corps de requête |
|---|---|---|---|---|
| GET | ✅ | ✅ | ✅ | ❌ (sémantique indéfinie) |
| POST | ❌ | ❌ | ❌ | ✅ |
| QUERY | ✅ | ✅ | ✅ | ✅ |
Retenez la règle de décision suivante pour vos futurs endpoints :
- Lecture simple, peu de paramètres →
GET(rien ne change). - Lecture complexe avec corps structuré →
QUERY(le nouveau standard cible). - Création ou modification de ressource →
POST/PUT/PATCH.
QUERY ne remplace pas GET. Pour récupérer une ressource unique par son identifiant (GET /products/42), GET reste parfait. QUERY vise le cas précis de la recherche paramétrée.
Appeler QUERY côté client
QUERY : curl, fetch, axios. Le piège numéro un : écrire le nom en MAJUSCULES.
Avec curl
# -X QUERY force la méthode ; --data-raw porte le corps JSON
curl -X QUERY https://example.com/products \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data-raw '{
"filters": { "category": "keyboards", "inStock": true },
"sort": [{ "field": "price", "order": "asc" }],
"page": { "size": 20 }
}'
Avec fetch (le piège de la casse)
L'API Fetch ne normalise la casse que pour les méthodes standard (get, post, put…). Un nom non standard part verbatim. Écrivez "query" en minuscules et un serveur sensible à la casse rejettera la requête.
// ✅ CORRECT : "QUERY" en majuscules
const res = await fetch("/products", {
method: "QUERY",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filters: { category: "keyboards", inStock: true },
sort: [{ field: "price", order: "asc" }],
page: { size: 20 },
}),
});
const data = await res.json();
console.log(`${data.total} produits trouvés`);
// ❌ PIÈGE : "query" minuscule part tel quel → rejet possible (405/501)
// fetch("/products", { method: "query", ... });
Avec axios
import axios from "axios";
// axios accepte n'importe quelle méthode via l'option `method`
const { data } = await axios.request({
url: "/products",
method: "QUERY",
headers: { "Content-Type": "application/json" },
data: {
filters: { category: "keyboards", inStock: true },
page: { size: 20 },
},
});
console.log(data.items);
Un wrapper réutilisable avec fallback
En attendant que toute votre chaîne accepte QUERY, encapsulez l'appel pour retomber automatiquement sur POST si le serveur renvoie 405 Method Not Allowed :
// Client de recherche tolérant : QUERY d'abord, POST en secours
async function search(url, queryBody) {
const options = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(queryBody),
};
// 1) Tenter la méthode moderne
let res = await fetch(url, { method: "QUERY", ...options });
// 2) Fallback si l'infra ne connaît pas encore QUERY
if (res.status === 405 || res.status === 501) {
console.warn("QUERY non supporté, bascule sur POST");
res = await fetch(url, { method: "POST", ...options });
}
if (!res.ok) {
throw new Error(`Recherche échouée : ${res.status}`);
}
return res.json();
}
Implémenter QUERY côté serveur
router.query() généralisé. On enregistre QUERY comme une méthode custom, on valide le Content-Type, puis on traite le corps comme une lecture.
Node.js avec Express
Express route par méthode. Comme QUERY n'est pas encore un helper dédié, on utilise app.all() et on filtre sur req.method, ou l'API bas niveau router.route() :
import express from "express";
const app = express();
app.use(express.json()); // parse le corps JSON de la requête QUERY
// Endpoint de recherche via QUERY
app.all("/products", (req, res) => {
// On ne gère que la méthode QUERY sur cette route
if (req.method !== "QUERY") {
res.set("Allow", "QUERY");
return res.status(405).json({ error: "Méthode non autorisée" });
}
// 1) Content-Type obligatoire (RFC 10008 → 400 sinon)
if (!req.is("application/json")) {
return res.status(400).json({ error: "Content-Type application/json requis" });
}
const { filters = {}, sort = [], page = {} } = req.body ?? {};
// 2) Valider la structure de la requête (sinon 422)
if (typeof filters !== "object") {
return res.status(422).json({ error: "Le champ 'filters' est invalide" });
}
// 3) Traiter la recherche (sûre + idempotente : aucune écriture)
const results = runSearch(filters, sort, page);
// 4) En-têtes de cache + ressource partageable
res.set("Cache-Control", "max-age=60");
res.set("Content-Location", `/products/results/${results.hash}`);
return res.status(200).json(results.payload);
});
app.listen(3000, () => console.log("API QUERY prête sur :3000"));
ASP.NET Core (C#)
Avec ASP.NET Core, on déclare un attribut de routage custom via [AcceptVerbs] qui accepte n'importe quelle méthode, y compris QUERY :
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("products")]
public class ProductsController : ControllerBase
{
// Autorise explicitement le verbe QUERY sur cette action
[AcceptVerbs("QUERY")]
public IActionResult Search([FromBody] ProductQuery query)
{
// ModelState valide le corps désérialisé (422 si invalide)
if (!ModelState.IsValid)
return UnprocessableEntity(ModelState);
var results = _searchService.Run(query);
// Réponse cacheable + lien vers le résultat
Response.Headers["Cache-Control"] = "max-age=60";
Response.Headers["Content-Location"] = $"/products/results/{results.Hash}";
return Ok(results.Payload);
}
}
// DTO de la requête (validation par DataAnnotations)
public class ProductQuery
{
public Dictionary<string, object> Filters { get; set; } = new();
public List<SortRule> Sort { get; set; } = new();
public PageRule Page { get; set; } = new();
}
Python avec FastAPI
FastAPI s'appuie sur Starlette : on ajoute une route bas niveau qui déclare la méthode QUERY dans la liste methods :
from fastapi import FastAPI, Request, Response
from pydantic import BaseModel, ValidationError
app = FastAPI()
class ProductQuery(BaseModel):
filters: dict = {}
sort: list = []
page: dict = {}
async def search_products(request: Request):
# Content-Type obligatoire → 400 sinon
if request.headers.get("content-type") != "application/json":
return Response('{"error":"Content-Type requis"}', status_code=400,
media_type="application/json")
try:
body = ProductQuery(**(await request.json()))
except ValidationError:
return Response('{"error":"Requête invalide"}', status_code=422,
media_type="application/json")
results = run_search(body) # lecture sûre et idempotente
return Response(results.json(), media_type="application/json",
headers={"Cache-Control": "max-age=60"})
# Enregistre la route avec la méthode custom QUERY
app.add_api_route("/products", search_products, methods=["QUERY"])
Intégration Angular avec HttpClient
HttpClient n'a pas de méthode query(), mais request() accepte n'importe quel verbe. On l'encapsule dans un service typé, avec repli sur POST.
La méthode générique HttpClient.request() prend le nom du verbe HTTP en premier argument. On peut donc envoyer un QUERY proprement et rester typé :
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, catchError, throwError } from 'rxjs';
export interface ProductQuery {
filters?: Record<string, unknown>;
sort?: { field: string; order: 'asc' | 'desc' }[];
page?: { size: number };
}
export interface ProductResult {
items: Product[];
total: number;
}
@Injectable({ providedIn: 'root' })
export class ProductSearchService {
private readonly url = '/products';
constructor(private http: HttpClient) {}
// Envoie un QUERY via la méthode générique request()
search(query: ProductQuery): Observable<ProductResult> {
return this.http
.request<ProductResult>('QUERY', this.url, {
body: query,
headers: { 'Content-Type': 'application/json' },
})
.pipe(
catchError((err) => {
// Repli sur POST si l'infra ne connaît pas QUERY
if (err.status === 405 || err.status === 501) {
return this.http.post<ProductResult>(this.url, query);
}
return throwError(() => err);
})
);
}
}
Côté composant, l'usage reste identique à n'importe quel appel HttpClient :
import { Component, inject, signal } from '@angular/core';
import { ProductSearchService } from './product-search.service';
@Component({ selector: 'app-search', standalone: true, template: '...' })
export class SearchComponent {
private search = inject(ProductSearchService);
results = signal<Product[]>([]);
onSearch(): void {
this.search
.search({ filters: { category: 'keyboards', inStock: true }, page: { size: 20 } })
.subscribe((res) => this.results.set(res.items));
}
}
Cache, CORS et proxies : les pièges de production
QUERY à traverser CORS, Nginx, les WAF et les API gateways — sans quoi la requête meurt avant d'atteindre votre serveur.
CORS : un preflight obligatoire
QUERY n'est pas une méthode « CORS-safelisted ». Tout appel cross-origin déclenche donc une requête preflight OPTIONS. Le serveur doit répondre en autorisant explicitement le verbe :
// Middleware CORS Express : autoriser QUERY dans le preflight
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "https://app.example.com");
// Sans QUERY dans cette liste, le navigateur bloque la requête réelle
res.header("Access-Control-Allow-Methods", "GET, POST, QUERY, OPTIONS");
res.header("Access-Control-Allow-Headers", "Content-Type, Accept");
if (req.method === "OPTIONS") return res.sendStatus(204);
next();
});
Nginx : ne pas filtrer la méthode
Beaucoup de configurations Nginx utilisent limit_except pour n'autoriser que GET et POST. Un tel bloc renverra 405 sur un QUERY. Il faut l'ajouter à la liste blanche :
location /products {
# Autoriser explicitement QUERY (sinon 405 avant d'atteindre l'app)
limit_except GET POST QUERY {
deny all;
}
proxy_pass http://backend_api;
proxy_set_header Host $host;
}
QUERY.
Le cache : la clé inclut le corps
C'est le point le plus subtil. Pour GET, la clé de cache est l'URI. Pour QUERY, deux requêtes vers /products peuvent avoir des corps différents et donc des résultats différents. La clé de cache doit intégrer un hash normalisé du corps :
import { createHash } from "node:crypto";
// Normaliser le corps (tri des clés) puis le hasher pour la clé de cache
function cacheKey(url, body) {
const normalized = JSON.stringify(sortKeysDeep(body));
const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
return `query:${url}:${hash}`;
}
// Deux recherches équivalentes → même clé → même réponse cachée
const key = cacheKey("/products", { inStock: true, category: "keyboards" });
Pagination : pas de Range HTTP
La RFC déconseille les requêtes Range HTTP pour paginer une réponse QUERY. Le format de requête doit porter sa propre pagination (curseur, offset/limit, ou l'équivalent SQL FETCH FIRST), comme dans nos exemples avec le champ page.
Sécurité : un endpoint de recherche est une surface d'attaque
Accepter un corps structuré et arbitraire ouvre la porte à des abus classiques. Une recherche est certes « sûre » au sens HTTP (elle ne modifie rien), mais elle peut être coûteuse : un attaquant peut forger un corps qui déclenche des requêtes SQL lourdes ou une consommation mémoire massive. Trois garde-fous s'imposent.
- Limiter la taille du corps pour éviter les payloads géants (déni de service).
- Valider strictement la structure (allow-list de champs et d'opérateurs) — ne jamais interpréter le corps comme du code ou une requête brute.
- Appliquer un rate limiting, comme sur toute API publique.
import rateLimit from "express-rate-limit";
// 1) Plafonner la taille du corps JSON (anti-DoS)
app.use("/products", express.json({ limit: "16kb" }));
// 2) Limiter le débit par IP sur l'endpoint de recherche
const searchLimiter = rateLimit({ windowMs: 60_000, max: 60 });
app.use("/products", searchLimiter);
// 3) Allow-list stricte : seuls ces champs sont acceptés dans le corps
const ALLOWED_FILTERS = new Set(["category", "inStock", "minPrice", "maxPrice"]);
function sanitizeFilters(filters) {
return Object.fromEntries(
Object.entries(filters).filter(([key]) => ALLOWED_FILTERS.has(key))
);
}
Stratégie d'adoption progressive
POST /search existants du jour au lendemain. QUERY brille surtout sur les nouveaux endpoints, avec un fallback POST tant que l'écosystème mûrit.
Une adoption raisonnable suit trois temps :
-
Nouveaux endpoints d'abord. Concevez vos nouvelles routes de recherche en
QUERY, avec un repliPOSTtransparent côté client (voir nos wrappers plus haut). - Vérifiez toute la chaîne. Avant d'exposer publiquement, testez que CORS, Nginx, le CDN, le WAF et l'API gateway laissent bien passer la méthode. Une seule couche récalcitrante casse tout.
-
Ne migrez l'existant que si le gain est réel. Un
POST /searchqui fonctionne n'a pas besoin d'être réécrit « pour la pureté ». Migrez quand le cache HTTP ou l'idempotence vous apportent un bénéfice mesurable.
✅ Checklist avant de servir QUERY en production
- Nom de méthode envoyé en MAJUSCULES côté client
Content-Typevalidé côté serveur (400 sinon)- Corps validé structurellement (422 sinon)
- Traitement strictement en lecture (sûr + idempotent)
- Preflight CORS autorisant
QUERY - Nginx / proxies / WAF configurés pour laisser passer
- Clé de cache incluant un hash normalisé du corps
- Pagination portée par le format, pas par
Range - Fallback
POSTimplémenté côté client
Conclusion
La méthode QUERY corrige une frustration vieille de quinze ans : elle réunit enfin le corps de requête riche de POST et la sémantique sûre, idempotente et cacheable de GET. Pour les endpoints de recherche complexe, c'est le chaînon manquant du REST — plus honnête, plus cohérent, mieux caché.
Mais restons mesurés sur l'impact à court terme. Une RFC ne rend pas une méthode utilisable du jour au lendemain. En 2026, l'écosystème n'est pas prêt : les navigateurs, CDN, WAF, API gateways et frameworks doivent encore la reconnaître et l'autoriser. Une requête QUERY non prévue se fait souvent rejeter par un intermédiaire bien avant d'atteindre votre code. GraphQL occupe déjà une partie du terrain, et le support natif dans les frameworks s'étalera sur plusieurs années.
Le bon réflexe aujourd'hui n'est donc pas de remplacer tous vos GET et POST, mais de connaître la méthode, l'expérimenter en interne, et l'adopter progressivement sur vos nouveaux endpoints avec un fallback. C'est une évolution lente mais saine du protocole HTTP — du même acabit que l'arrivée de PATCH. À moyen terme, QUERY deviendra le moyen canonique de faire des recherches structurées en REST. À court terme, c'est avant tout un excellent sujet de veille à garder à l'œil.
- Ce que c'est : RFC 10008 — méthode HTTP sûre, idempotente, cacheable, avec corps.
- À quoi ça sert : recherches et filtres complexes, sans abuser de
POST. - Utilisable ? Côté client oui ; côté infra, prévoir un fallback
POST. - Verdict : vraie bonne nouvelle sur le fond, adoption réelle à 2-3 ans.