13. API REST avec HttpClient

0

Lors du chapitre précédent, nous avons vu comment nous authentifier vis-à-vis d’un serveur et comment gérer des informations d’authentification. Aujourd’hui, nous allons voir comment intégrer une API REST à notre application de gestion de cartes à jouer, afin de créer et manipuler des cartes qui seront enregistrées sur un serveur, avec la particularité que chaque utilisateur aura sa propre liste de monstres et ne pourra pas interagir avec la liste des autres utilisateurs.

Dans ce cours on va:

  • voir ce qu’est une API REST ;
  • on présentera les API que nous allons intégrer ;
  • et pour finir, on regardera comment adapter notre code afin d’utiliser « HttpClient » pour interagir avec cette API.

Si vous souhaitez télécharger et installer les APIs utilisées pour ce cours, vous les retrouverez à la page suivante:

https://gitlab.com/simpletechprod1/playing_cards_backend

Pour rappel, cette série de vidéos s’inscrit dans une longue lignée de vidéos dont le fil rouge est la création d’une application de visualisation et gestion de cartes à collectionner de type Pokémon, Magic, Yu-Gi-Oh! ou autres.

Logout header

Commençons par expliquer ce qu’est une API REST. Le terme REST API signifie « REpresentational State Transfer Application Programming Interface », qui pourrait se traduire en français par « interface de programmation d’applications par transfert de représentation d’état ».

L’idée est que chaque ressource avec laquelle on pourra interagir aura une URL qui lui sera unique, et lorsqu’on voudra créer, lire ou modifier une ressource, on va transférer une représentation de l’état de cette ressource du serveur au client ou inversement. Dans notre cas, on va donc pouvoir transférer des monstres de/vers notre serveur, et on va représenter ces monstres sous forme de « json », mais cela aurait tout aussi bien pu être de l' »xml » ou un tout autre format.

Pour illustrer tout ça, regardons l’API qui j’ai préparé pour aujourd’hui:

Monsters API

Ici, vous voyez qu’on à une ressource « monsters » avec plusieurs méthodes HTTP qu’on pourra utiliser pour interagir avec ces monstres.

La première méthode est la méthode GET à l’URL « monsters/ » en elle-même, et ce GET nous retournera la liste de tous les monstres qui sont enregistrés sur le serveur pour l’utilisateur actuel.

Get monsters/

Comme vous pouvez le voir, chaque carte retournée a un identifiant dont on aura besoin pour faire appel à certaines autre méthodes HTTP.

POST monsters/

Ensuite, nous avons la méthode POST qui permet de créer un nouveau monstre qui sera ajouté à la liste de l’utilisateur. Le POST prend un « json » en paramètre qui contient les différents champs qu’on avait défini pour notre monstre dans les chapitres précédents et retourne un « json » qui, en plus des champs envoyés, contient l’identifiant qui aura été assigné à notre nouvelle ressource.

GET monsters/id/

Le prochain appel est un « get » à l’URL  » /monsters/  » suivi de l’identifiant d’un monstre précis. Cet appel nous renvoie le monstre qui possède l’identifiant en question.

Par la suite, nous avons deux méthodes très similaires, le PUT et le PATCH, qui doivent aussi être exécutées à l’URL  » monsters/  » suivi de l’identifiant d’une monstre.

PUT monsters/id/

Le PUT permet de remplacer toutes les données d’un monstre par de nouvelles données en envoyant un « json » au serveur avec tous les attributs qui représentent notre monstre.

PATCH monsters/id/

Le PATCH quant à lui permet de mettre à jour un monstre en n’envoyant dans le « json » que les paramètres qu’on souhaite changer. Ces deux méthodes retournent la ressource en question telle qu’elle est enregistrée sur le serveur suite aux modifications demandées.

Au final, toujours à l’URL  » monsters/  » suivi de l’identifiant d’un monstre, nous avons la méthode DELETE qui permet de supprimer un monstre.

DELETE monsters/id/

Maintenant que nous nous sommes familiarisés avec nos APIs, ouvrons le fichier « monster.service.ts » et effaçons tout le code qui se chargeait jusqu’à présent de manipuler nos objets dans le « localStorage ». Laissons-y uniquement les méthodes dont on aura besoin, qui sont les méthodes « getAll », « get », « add », « update » et « delete ».

monster.service.ts
import { Injectable } from '@angular/core';
import { Monster } from '../../models/monster.model';

@Injectable({
    providedIn: 'root'
})
export class MonsterService {

    getAll(): Monster[] {
    }

    get(id: number): Monster {
    }

    add(monster: Monster): Monster {
    }

    update(monster: Monster): Monster {
    }

    delete(id: number) {
    }

}

Avant de voir comment implémenter ces méthodes, on va avoir besoin de deux choses: 

  • premièrement, nos appels API vont utiliser respectivement retourner un « json » et idéalement, on devrait avoir un type qui représente la structure de données qu’on va manipuler
  • deuxièmement il nous faudra des méthodes pour convertir les résultats reçus via nos APIs en des objets de type monstre et vice-versa.

Tout d’abord, définissons une interface qu’on va créer dans un dossier « interfaces » dans lequel on va créer un fichier « monster.interface.ts » et qui contiendra tous les attributs de notre monstre:

monster.interface.ts
export interface IMonster {
    id?: number;
    name: string;
    image: string;
    type: string;
    hp: number;
    figureCaption: string;
    attackName: string;
    attackStrength: number;
    attackDescription: string;
}

Notez que l’ »id » est optionnel, car dans nos interactions avec le serveur, les données reçues contiendrons toujours les « ids » des monstres, alors que quand nous enverrons des données, l’ »id » ne sera pas inclus dans le « json » envoyé.

Maintenant dans le fichier « monster.model.ts », indiquons que notre monstre implémente cette interface et ajoutons y deux méthodes:

  • une méthode qui va créer un nouvel objet Monster à partir d’un dictionnaire de type « IMonster » reçu du serveur
  • et une deuxième méthode qui va convertir notre monstre en dictionnaire de type « IMonster » qu’on pourra envoyer à notre serveur.
monster.model.ts
import { IMonster } from "../interfaces/monster.interface";
import { MonsterType } from "../utils/monster.utils";

export class Monster implements IMonster {

    id: number = -1;
    name: string = "Monster";
    image: string = "img/pika.png"
    type: MonsterType = MonsterType.ELECTRIC;
    hp: number = 60;
    figureCaption: string = "N°001 Monster";

    attackName: string = "Standard Attack";
    attackStrength: number = 10;
    attackDescription: string = "This is an attack description...";

    copy(): Monster {
        return Object.assign(new Monster(), this);
    }

    static fromJson(monsterData: IMonster) {
        return Object.assign(new Monster(), monsterData);
    }

    toJson(): IMonster {
        const jsonObject: IMonster = Object.assign({}, this);
        delete jsonObject.id;
        return jsonObject;
    }

}

Là, on va pouvoir retourner dans notre fichier « monster.service.ts » et implémenter les fonctions qu’on avait créées à l’instant. Pour cela, injecter « HttpClient » dans un variable qu’on va appeler « http » et on va également définir un variable qui va contenir l’URL de base de notre API et qu’on va appeler « BASE_URL », dans mon cas l’API tourne à l’url « http://localhost:8000/monsters/ », mais vous devrez adapter ce paramètre afin qu’il pointe bien sur l’API que vous souhaitez utiliser.

Ensuite, pour l’implémentation des différentes méthodes, on va faire appel aux méthodes de « HttpClient », donc les méthodes « get », « post », « put » et « delete ». Attention:

  • les méthodes GET, POST et PUT retournent un dictionnaire qui aura le type « IMonster » qui correspond bien à la structure de données que notre serveur renvoie, et il faudra donc utiliser la méthode « fromJson » de notre classe « Monster » afin de convertir celui-ci en un objet « monstre »;
  • les méthodes POST et PUT prennent un dictionnaire au format « IMonster » en paramètre, et on devra l’obtenir à travers la méthode « toJson » que nous avons implémentée dans notre classe « Monster »
monster.service.ts
import { Observable, map } from 'rxjs';
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Monster } from '../../models/monster.model';
import { IMonster } from '../../interfaces/monster.interface';

@Injectable({
    providedIn: 'root'
})
export class MonsterService {

    private BASE_URL = 'http://localhost:8000/monsters/';
    private http = inject(HttpClient);

    getAll(): Observable<Monster[]> {
        return this.http.get<IMonster[]>(this.BASE_URL).pipe(
            map(monsterJsonArray => {
                return monsterJsonArray.map<Monster>(
                    monsterJson => Monster.fromJson(monsterJson)
                )
            })
        );
    }

    get(id: number): Observable<Monster> {
        return this.http.get<IMonster>(this.BASE_URL + id + '/').pipe(
            map(monsterJson => Monster.fromJson(monsterJson))
        );
    }

    add(monster: Monster): Observable<Monster> {
        return this.http.post<IMonster>(this.BASE_URL, monster.toJson()).pipe(
            map(monsterJson => Monster.fromJson(monsterJson))
        );
    }

    update(monster: Monster): Observable<Monster> {
        return this.http.put<IMonster>(this.BASE_URL + monster.id + '/', monster.toJson()).pipe(
            map(monsterJson => Monster.fromJson(monsterJson))
        );
    }

    delete(id: number): Observable<void> {
        return this.http.delete<void>(this.BASE_URL + id + '/');
    }

}

Veuillez noter que nos méthodes renvoient maintenant des « Observables » et que nous avons donc mis à jour tous les types de retours. En plus de cela, comme on souhaite renvoyer directement des « Observables » qui émettent des monstres, on va utiliser la méthode « map » de « rxjs » afin de convertir les données de type « IMonster » en objets de type « Monster ».

Et voilà, notre service est prêt et nous pouvons adapter notre application à cette nouvelle implémentation. Le « MonsterService » est utilisé dans deux fichiers: le fichier « monster-list.component.ts » et le fichier « monster.component.ts ». 

Commençons par adapter le fichier « monster-list.component.ts »:

monster-list.component.ts
import { Component, computed, inject, model } from '@angular/core';
import { PlayingCardComponent } from '../../components/playing-card/playing-card.component';
import { Monster } from '../../models/monster.model';
import { SearchBarComponent } from '../../components/search-bar/search-bar.component';
import { CommonModule } from '@angular/common';
import { MonsterService } from '../../services/monster/monster.service';
import { Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';

@Component({
    selector: 'app-monster-list',
    standalone: true,
    imports: [CommonModule, PlayingCardComponent, SearchBarComponent, MatButtonModule],
    templateUrl: './monster-list.component.html',
    styleUrl: './monster-list.component.css'
})
export class MonsterListComponent {

    private monsterService = inject(MonsterService);
    private router = inject(Router);

    monsters = toSignal(this.monsterService.getAll());
    search = model('');

    filteredMonsters = computed(() => {
        return this.monsters()?.filter(monster => monster.name.includes(this.search())) ?? [];
    });

    addMonster() {
        this.router.navigate(['monster']);
    }

    openMonster(monster: Monster) {
        this.router.navigate(['monster', monster.id]);
    }

}

Ici, la variable « monsters » qui est utilisée pour afficher les différentes cartes à l’écran est un signal. On a dû convertir le résultat de « this.monsterService.getAll() » qui est un « Observable » en signal. Pour cela, on a utilisé la méthode « toSignal » qui s’occupe de cela pour nous.

En plus de cela, dans la méthode filteredMonsters, on a dû considérer la possibilité que notre API ne réponde pas, et donc si « this.monster() » est « undefined », on retourne vers une liste vide.

Adaptons maintenant le fichier « monster.component.ts »:

monster.component.ts
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { filter, of, Subscription, switchMap } from 'rxjs';
import { MonsterType } from '../../utils/monster.utils';
import { Monster } from '../../models/monster.model';
import { PlayingCardComponent } from '../../components/playing-card/playing-card.component';
import { MonsterService } from '../../services/monster/monster.service';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDialog } from '@angular/material/dialog';
import { DeleteMonsterConfirmationDialogComponent } from '../../components/delete-monster-confirmation-dialog/delete-monster-confirmation-dialog.component';

@Component({
    selector: 'app-monster',
    standalone: true,
    imports: [
        ReactiveFormsModule,
        PlayingCardComponent,
        MatButtonModule,
        MatInputModule,
        MatSelectModule
    ],
    templateUrl: './monster.component.html',
    styleUrl: './monster.component.css'
})
export class MonsterComponent implements OnInit, OnDestroy {

    private route = inject(ActivatedRoute);
    private router = inject(Router);
    private fb = inject(FormBuilder);
    private monsterService = inject(MonsterService);
    private readonly dialog = inject(MatDialog);

    private routeSubscription: Subscription | null = null;
    private formValuesSubscription: Subscription | null = null;
    private saveSubscription: Subscription | null = null;
    private deleteSubscription: Subscription | null = null;

    formGroup = this.fb.group({
        name: ['', [Validators.required]],
        image: ['', [Validators.required]],
        type: [MonsterType.ELECTRIC, [Validators.required]],
        hp: [0, [Validators.required, Validators.min(1), Validators.max(200)]],
        figureCaption: ['', [Validators.required]],
        attackName: ['', [Validators.required]],
        attackStrength: [0, [Validators.required, Validators.min(1), Validators.max(200)]],
        attackDescription: ['', [Validators.required]]
    });

    monster: Monster = Object.assign(new Monster(), this.formGroup.value);
    monsterTypes = Object.values(MonsterType);
    monsterId = -1;

    ngOnInit(): void {

        this.formValuesSubscription = this.formGroup.valueChanges.subscribe(data => {
            this.monster = Object.assign(new Monster(), data);
        });

        this.routeSubscription = this.route.params.pipe(
            switchMap(params => {
                if (params['id']) {
                    this.monsterId = parseInt(params['id']);
                    return this.monsterService.get(this.monsterId);
                }
                return of(null);
            })
        ).subscribe(monster => {
            if (monster) {
                this.monster = monster;
                this.formGroup.patchValue(this.monster);
            }
        });

    }

    ngOnDestroy(): void {
        this.formValuesSubscription?.unsubscribe();
        this.routeSubscription?.unsubscribe();
        this.saveSubscription?.unsubscribe();
        this.deleteSubscription?.unsubscribe();
    }

    submit(event: Event) {
        event.preventDefault();
        let saveObservable;
        if (this.monsterId === -1) {
            saveObservable = this.monsterService.add(this.monster);
        } else {
            this.monster.id = this.monsterId;
            saveObservable = this.monsterService.update(this.monster);
        }
        this.saveSubscription = saveObservable.subscribe(_ => {
            this.navigateBack();
        })
    }

    navigateBack() {
        this.router.navigate(['/home']);
    }

    isFieldValid(name: string) {
        const formControl = this.formGroup.get(name);
        return formControl?.invalid && (formControl?.dirty || formControl?.touched);
    }

    deleteMonster() {
        const dialogRef = this.dialog.open(DeleteMonsterConfirmationDialogComponent);
        this.deleteSubscription = dialogRef.afterClosed().pipe(
            filter(confirmation => confirmation),
            switchMap(_ => this.monsterService.delete(this.monsterId))
        ).subscribe(_ => {
            this.navigateBack();
        })
    }

    onFileChange(event: any) {
        const reader = new FileReader();
        if (event.target.files && event.target.files.length) {
            const [file] = event.target.files;
            reader.readAsDataURL(file);
            reader.onload = () => {
                this.formGroup.patchValue({
                    image: reader.result as string,
                });
            };
        }
    }

}

Ici nous avons du faire plusieurs changements. Pour commencer au niveau de notre « ngOnInit », on a modifié notre « this.route.params.subscribe », afin d’y rajouter un « .pipe ». Dans ce « pipe », on exécute un « switchMap » dans lequel on récupère l’identifiant qui a été passé à notre composant en tant que paramètre d’URL. Ensuite, on retourne « this.monsterService.get » auquel on passe l’identifiant du monstre à récupérer. Si aucun « id » n’a été passé en paramètre à notre URL, on retourne un « Observable » qui retourne la valeur « null » grâce à la méthode « of » qu’on importe de « rxjs ».

Pour finir, on fait un « subscribe », et si un monstre est retourné, on assigne la valeur du monstre au « formGroup ».

monster.component.ts
...        
        this.routeSubscription = this.route.params.pipe(
            switchMap(params => {
                if (params['id']) {
                    this.monsterId = parseInt(params['id']);
                    return this.monsterService.get(this.monsterId);
                }
                return of(null);
            })
        ).subscribe(monster => {
            if (monster) {
                this.monster = monster;
                this.formGroup.patchValue(this.monster);
            }
        });
...

Ensuite dans la méthode « submit », on fait appel aux méthodes « add » et « update » de notre « MonsterService », qui maintenant retourne des observables. Donc on doit y faire un « subscribe », et on garde cette « subscription » dans la variable « saveSubscription » afin de pouvoir faire un « unsubscribe » dans la méthode « ngOnDestroy ».

monster.component.ts
submit(event: Event) {
    event.preventDefault();
    let saveObservable;
    if (this.monsterId === -1) {
        saveObservable = this.monsterService.add(this.monster);
    } else {
        this.monster.id = this.monsterId;
        saveObservable = this.monsterService.update(this.monster);
    }
    this.saveSubscription = saveObservable.subscribe(_ => {
        this.navigateBack();
    })
}

On doit aussi adapter la méthode « deleteMonster ». Ici on va exécuter la méthode « .pipe » lorsque le client fermera le « popup » de confirmation de suppression du monstre. Dans celui-ci, on va d’abord faire appel à la méthode « filter » de « rxjs ». Cette méthode permet de filtrer uniquement les événements qui nous intéressent, et donc ici, on ne s’intéresse qu’aux événements où l’utilisateur confirme qu’il souhaite bien supprimer le monstre en question.

Ensuite, si l’utilisateur a bien confirmé la suppression, on exécute un switchMap afin de retourner « this.monsterService.delete ». Pour finir, on souscrit à cet « Observable » et quand le delete émet une valeur on exécute « this.navigateBack() » afin de retourner à la liste de monstres.

monster.component.ts
deleteMonster() {
    const dialogRef = this.dialog.open(DeleteMonsterConfirmationDialogComponent);
    this.deleteSubscription = dialogRef.afterClosed().pipe(
        filter(confirmation => confirmation),
        switchMap(_ => this.monsterService.delete(this.monsterId))
    ).subscribe(_ => {
        this.navigateBack();
    })
}

Et voilà, maintenant si on retourne dans notre navigateur, on peut ajouter, modifier et supprimer des monstres, et chaque opération sera exécutée via nos APIs sur le serveur.

Logout header

C’est tout ce que je voulais vous montrer dans le contexte de ce cours d’introduction à Angular! Maintenant vous devriez être capable de créer des projets par vous-même.

Bien sûr il reste encore beaucoup de sujets plus avancés à traiter, comme « RxJS », le testing, l’internationalisation, l’utilisation de certaines librairies utiles ainsi que la mise en production d’une application Angular, et on en parlera dans les mois à venir, dans un cours Angular plus avancé.

Resources

Vous retrouvez ci-dessous un lien vers le code source utilisé dans ce chapitre. Ce lien est uniquement disponible pour les abonnés Standard et Premium. 

Quiz

Répondez au quiz ci-dessous afin de vérifier que vous avez bien compris le contenu de cette leçon. Les quiz sont uniquement disponibles pour les abonnés Standard et Premium.

Laisser un commentaire