Un constructeur de requêtes immuable basé sur des closures pour Angular HttpClient
Remplacer le code générique répétitif HttpParams par un petit constructeur de requête basé sur des closures, connecté via un InjectionToken, qui reste immuable, typé et facile à tester.
Sources: Stop Hand-Wiring HttpParams: Build a Fluent, Type-Safe Request Builder in Angular
Les grandes bases de code Angular ont tendance à accumuler les mêmes quelques lignes dans des dizaines de services : créer un HttpParams, définir le filtre, définir le tri, spécifier la page, puis enfin effectuer la requête. Chaque version diffère légèrement, et ces petites différences entraînent des noms de paramètres de requête et des conventions de tri incohérents au sein des équipes.
Ce guide montre comment remplacer cette répétition par un constructeur de requêtes fluide construit uniquement à partir de fonctions fermées. Il n’a besoin ni de classes, ni de décorateurs, ni d’héritage ; une seule fonction factory est exposée via un InjectionToken et résolue avec inject(). Vous verrez comment cela fonctionne, pourquoi il est immuable par construction, comment le tester sans TestBed, et quand il ne vaut pas la peine d’être ajouté.
Le code générique qui est remplacé
Voici un point de terminaison de liste typique écrit à la main.
getUsers(status: string, sortField: string, page: number, size: number) {
let params = new HttpParams()
.set('status', status)
.set('sort', sortField)
.set('direction', 'asc')
.set('page', page)
.set('size', size);
return this.http.get<User[]>('/api/users', { params });
}
Rien n’est incorrect dans cette approche, mais elle ne s’adapte pas bien à de grandes échelles. Un service nomme la taille de la page pageSize, un autre utilise size ; une équipe trie en utilisant sort et direction, tandis qu’une autre encode les deux valeurs dans un seul paramètre. Chaque nouveau point d’entrée entraîne de nouvelles appels à HttpParams avant d’arriver à la partie vraiment importante. L’objectif est d’avoir un endroit où les appels ressemblent à une description de la requête :
loadUsers() {
return this.request<User[]>('/api/users')
.where('status', 'active')
.sortBy('name')
.paginate(0, 20)
.execute();
}
Chaque appel dans cette chaîne renvoie une valeur nouvelle et indépendante, et l’appel final execute() génère l’objet observable. Le reste de ce guide décrit les éléments qui rendent cela possible.
Une usine qui se renvoie elle-même
Tout le constructeur ne compte que une vingtaine de lignes.
import { Observable } from 'rxjs';
import { HttpClient, HttpParams } from '@angular/common/http';
type SortDirection = 'asc' | 'desc';
interface RequestBuilder<T> {
where(key: string, value: string | number | boolean): RequestBuilder<T>;
sortBy(field: string, direction?: SortDirection): RequestBuilder<T>;
paginate(page: number, size: number): RequestBuilder<T>;
execute(): Observable<T>;
}
export function createRequest(http: HttpClient) {
const build = <T>(url: string, params = new HttpParams()): RequestBuilder<T> => ({
where: (key, value) => build<T>(url, params.set(key, String(value))),
sortBy: (field, direction = 'asc') =>
build<T>(url, params.set('sort', field).set('direction', direction)),
paginate: (page, size) =>
build<T>(url, params.set('page', page).set('size', size)),
execute: () => http.get<T>(url, { params }),
});
return build;
}
Le truc principal : l’appel à build vous donne un objet, et chaque méthode de cet objet appelle à nouveau build. Chaque étape de la chaîne crée un constructeur nouveau qui prend en compte les HttpParams mis à jour. Le constructeur précédent n’est jamais modifié.
Cela fonctionne si bien parce que HttpParams est déjà immuable : params.set() laisse l’instance originale intacte et renvoie une nouvelle instance contenant la nouvelle entrée. Le constructeur relève simplement ce comportement d’un niveau, de sorte que les objets constructeurs héritent de la même garantie. where convertit les valeurs en chaînes de caractères, tandis que sortBy définit les clés sort et direction (avec une ordre ascendant par défaut) et que paginate définit les clés page et size. Seul execute() interagit avec le réseau, en appelant http.get avec les paramètres accumulés.
Il convient également de noter ce qui manque. Il n’y a pas de this, ni de champ privé, ni de class. Tout l’état se trouve dans les arguments de fonction capturés par des closures, ce qui empêche toute modification accidentelle et ne laisse rien à suivre en dehors de la fonction actuellement exécutée. Si les closures qui capturent des arguments vous semblent familiers, l’explication de la manière dont la chaîne de portée de JavaScript résout les variables décrit le mécanisme sous-jacent.
Exposer le constructeur via l’injection de dépendances
Une fonction qui prend en paramètre un HttpClient est utile, mais les composants doivent pouvoir l’injecter comme n’importe quelle autre dépendance. Un InjectionToken basé sur une usine a été conçu précisément pour cela.
import { InjectionToken, inject } from '@angular/core';
export const REQUEST_BUILDER = new InjectionToken<ReturnType<typeof createRequest>>(
'REQUEST_BUILDER',
{
providedIn: 'root',
factory: () => createRequest(inject(HttpClient)),
}
);
Deux détails méritent attention.
Laisser le compilateur déduire le type
ReturnType<typeof createRequest> signifie que le type du constructeur n’est jamais écrit manuellement. Lorsque createRequest acquiert une méthode, le type du token est déterminé automatiquement, et tous les utilisateurs perçoivent ce changement.
Résoudre HttpClient lorsqu’un contexte d’injection existe
inject(HttpClient) s’exécute à l’intérieur de la factory du token, et cette factory fonctionne toujours dans un contexte d’injection. C’est l’endroit approprié pour cela. Une erreur fréquente consiste à appeler inject() depuis une méthode ordinaire ou un callback exécuté en dehors de tout contexte d’injection ; Angular génère alors l’erreur NG0203 en temps de exécution. Résoudre HttpClient une seule fois, au moment de la création du token, et le conserver dans la closure permet à ce constructeur d’être utilisé n’importe où par la suite : à l’intérieur d’effects, d’opérateurs RxJS ou de fonctions utilitaires classiques.
L’utiliser depuis un composant
Un composant injecte le jeton dans un champ et l’appelle comme une fonction.
@Component({
selector: 'app-users',
template: `...`,
})
export class UsersComponent {
private readonly request = inject(REQUEST_BUILDER);
loadUsers() {
return this.request<User[]>('/api/users')
.where('status', 'active')
.sortBy('name')
.paginate(0, 20)
.execute();
}
}
L’argument générique fourni au début, User[], parcourt toute la chaîne ; par conséquent, execute() renvoie Observable<User[]> sans aucun casting.
Modèles qui sortent de l’immuabilité
Recherche réactive avec des signaux
Puisque un constructeur représente une valeur peu coûteuse et immuable, il est tout à fait possible d’en créer un nouveau chaque fois que les entrées réactives changent. Ici, une ressource pilotée par des signaux reconstruit la requête à partir du texte de recherche et de la page actuels.
export class ProductSearchComponent {
private readonly request = inject(REQUEST_BUILDER);
readonly query = signal('');
readonly page = signal(0);
readonly products = rxResource({
params: () => ({ q: this.query(), page: this.page() }),
stream: ({ params }) =>
this.request<Product[]>('/api/products')
.where('q', params.q)
.where('inStock', true)
.sortBy('price', 'desc')
.paginate(params.page, 24)
.execute(),
});
}
Lorsque query ou page change, la fonction params génère un nouvel objet, et la fonction stream crée une nouvelle requête à partir de celui-ci. Il n’existe pas de constructeur partagé qui puisse être réinitialisé entre les exécutions. Notez que rxResource ainsi que les noms de ses options ont changé dans les dernières versions d’Angular (les versions antérieures utilisaient des noms de propriétés différents), il convient donc de consulter l’API de la version que vous utilisez.
Chaînage conditionnel
Puisque chaque étape renvoie une valeur plutôt que de modifier directement une variable, des filtres optionnels peuvent être appliqués en réaffectant une variable locale.
loadOrders(filter: OrderFilter) {
let builder = this.request<Order[]>('/api/orders')
.paginate(filter.page, filter.size);
if (filter.status) {
builder = builder.where('status', filter.status);
}
if (filter.sortField) {
builder = builder.sortBy(filter.sortField, filter.direction);
}
return builder.execute();
}
Seule la variable builder est réaffectée ; les objets constructeurs eux-mêmes ne changent jamais. Chaque branche se contente de remplacer le constructeur par un nouveau constructeur étendu.
Test sans TestBed
createRequest est une fonction simple de HttpClient, donc un test unitaire n’a absolument pas besoin d’un module de test Angular. Un petit stub qui enregistre les paramètres reçus suffit.
it('accumulates params immutably', () => {
const captured: HttpParams[] = [];
const httpStub = {
get: (_url: string, opts: { params: HttpParams }) => {
captured.push(opts.params);
return of([]);
},
} as unknown as HttpClient;
const request = createRequest(httpStub);
const base = request<unknown[]>('/api/items').where('a', 1);
base.where('b', 2).execute();
base.where('c', 3).execute();
expect(captured[0].get('b')).toBe('2');
expect(captured[0].get('c')).toBeNull(); // branches don't leak into each other
expect(captured[1].get('c')).toBe('3');
});
Les assertions finales montrent les avantages de l’immutabilité. Les chaînes qui partent d’un constructeur base commun restent complètement isolées : le paramètre b de la première chaîne n’apparaît jamais dans la seconde, et inversement. Avec un constructeur mutable, la deuxième appelation hériterait silencieusement du filtre de la première.
Pourquoi cette conception fonctionne
- Immutable par construction. Il n’existe aucun état interne qui pourrait être corrompu. Réutiliser une demande partiellement construite, par exemple une requête de base pour les utilisateurs actifs et une autre pour les utilisateurs archivés, est sûr par défaut. Un constructeur basé sur des classes mutable commet cette erreur sans aucun avertissement.
sort, direction, page et size sont définies en un seul endroit. Lorsque le backend renomme l’une d’elles, il suffit de modifier une seule ligne au lieu de parcourir quarante services. Le constructeur devient ainsi efficacement le contrat entre l’interface utilisateur et les conventions de requête de l’API.execute(). Les constructeurs basés sur des classes obligent souvent à répéter le passage des génériques à travers chaque niveau d’héritage.HttpClient dans un contexte d’injection garanti, évitant ainsi toute la série d’erreurs liées à l’appel de inject() en dehors d’un contexte d’injection, qui apparaissent lorsque des constructeurs sont créés de manière différée dans des callbacks.providedIn: 'root' est supprimé du bundle lorsque personne ne l’utilise. Le noyau en question étant une simple fonction, les tests n’ont pas besoin ni de TestBed.configureTestingModule ni de HttpTestingController, sauf si l’on souhaite expressément un couvert au niveau de l’intégration.execute() appelle finalement HttpClient.get, de sorte que les en-têtes d’authentification, la logique de tentative et la gestion des erreurs présents dans votre chaîne d’intercepteurs s’appliquent sans modification. Le constructeur fonctionne par-dessus HttpClient ; il ne le remplace pas.Compromis à prendre en compte
- Les clés sont de simples chaînes de caractères. L’expression
where('status', 'active')n’est pas validée par l’API. Une faute de frappe dans la clé se compile correctement et ne provoque une erreur qu’à l’exécution. Restreindre les clés à l’aide d’un type générique, commewhere<K extends keyof UserQuery>, résout ce problème, mais au prix de types plus complexes ; décidez si votre équipe en a besoin. - Écrasement des appels répétés. La méthode
HttpParams.setremplace la valeur existante, de sorte qu’en appelantsortBy()deux fois, seule la deuxième invocation est conservée. C’est généralement ce que l’on souhaite pour le tri, mais cela surprend ceux qui s’attendent à des paramètres multi-valeurs. Pour des clés répétées telles que?tag=a&tag=b, ajoutez une méthodewhereAllqui utiliseparams.append. - Utilisation délibérée uniquement de GET. Le constructeur permet de composer des chaînes de requête. Les corps de demande, les en-têtes et les options relatives au type de réponse ne sont pas pris en compte. Vous pouvez ajouter des méthodes comme
withHeadersoupost(body), mais chaque ajout tend à reimplémenter les fonctionnalités déjà disponibles dans l’objetHttpClient. Sachez où s’arrêter. - Un nouvel objet par étape. Chaque appel en chaîne alloue un constructeur et un
HttpParams. Il s’agit d’objets minuscules et de courte durée de vie que les moteurs JavaScript collectent facilement, de sorte que le coût est négligeable en pratique, mais il est utile de le savoir. - Une abstraction supplémentaire à maîtriser. Les nouveaux membres de l’équipe doivent découvrir l’existence de
REQUEST_BUILDERet savoir à quoi il sert. Dans une application disposant de deux points d’entrée, appeler directementHttpClientest plus simple. Ce pattern s’avère avantageux dans des bases de code contenant de nombreux points d’entrée sous forme de listes ou de tableaux qui partagent des règles de pagination et de tri, ce qui correspond à une grande partie des applications Angular d’entreprise.
Manières de l’étendre
Lorsque la version de base est mise en place, quelques extensions deviennent naturelles.
Schémas de requête typés par point d’entrée
Un deuxième paramètre générique peut décrire quels clés et valeurs une extrémité de point de terminaison accepte, ce qui transforme une clé mal orthographiée en erreur à temps de compilation.
interface UserQuery {
status: 'active' | 'archived';
role: string;
}
const users = this.request<User[], UserQuery>('/api/users')
.where('status', 'active'); // 'staus' would now be a compile error
Cet extrait montre l’endroit où l’appel est prévu. Pour le prendre en charge, createRequest et l’interface RequestBuilder nécessitent un deuxième paramètre de type, et where doit être défini en fonction de keyof, qui représente ce type de clé et ses types de valeurs.
URL de base spécifiques à l’environnement
Résolvez également un token BASE_API_URL dans la factory de tokens, et ajoutez-le en préfixe à chaque chemin reçu par le constructeur. Les composants continuent d’envoyer des chemins relatifs et ne gèrent jamais la configuration de l’environnement.
Adaptateurs de réponse
Une étape map peut remodeler la réponse du serveur à l’intérieur du constructeur, ce qui permet aux composants d’obtenir des modèles prêts à être affichés plutôt que des structures API brutes.
Conclusion
Le constructeur de requêtes basé sur la fermeture offre beaucoup avec très peu de code. Environ vingt lignes suffisent pour créer une couche immuable, typée et injectable qui standardise la manière dont toute l’application communique avec des points de terminaison paginés. Il ne repose que sur ce que Angular fournit déjà : l’immutabilité de HttpParams, les tokens d’injection basés sur des usines et la fonction inject(), sans aucun mécanisme supplémentaire en temps de exécution.
Comme pour toute abstraction, il existe un point d’équilibre. Pour un petit nombre de points de terminaison, les appels simples à HttpClient sont plus clairs. Au-delà, le constructeur transforme les conventions relatives aux chaînes de requête, que les utilisateurs doivent mémoriser, en un contrat que le compilateur vérifie à leur place.