Aller au contenu

Réagir à FastAPI

Le générateur api-connection permet d’intégrer rapidement votre site React avec votre backend FastAPI. Il configure tous les éléments nécessaires pour une connexion typée à vos APIs FastAPI, incluant la génération de clients et de hooks TanStack Query, la prise en charge de l’authentification AWS IAM et Cognito, ainsi qu’une gestion d’erreurs appropriée.

Avant d’utiliser ce générateur, assurez-vous que votre application React dispose de :

  1. Un fichier main.tsx qui rend votre application
  2. Un backend FastAPI fonctionnel (généré via le générateur FastAPI)
  3. Une authentification Cognito ajoutée via le générateur ts#react-website-auth si vous connectez une API utilisant l’authentification Cognito ou IAM
Exemple de structure requise pour main.tsx
import { StrictMode } from 'react';
import * as ReactDOM from 'react-dom/client';
import App from './app/app';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTZLElement,
);
root.render(
<StrictMode>
<App />
</StrictMode>,
);
  1. Installez le Nx Console VSCode Plugin si ce n'est pas déjà fait
  2. Ouvrez la console Nx dans VSCode
  3. Cliquez sur Generate (UI) dans la section "Common Nx Commands"
  4. Recherchez @aws/nx-plugin - api-connection
  5. Remplissez les paramètres requis
    • Cliquez sur Generate
    Paramètre Type Par défaut Description
    sourceProject Requis string - The source project which will call the API
    targetProject Requis string - The target project containing your API

    Le générateur modifiera les fichiers suivants dans votre projet FastAPI :

    • Répertoirescripts
      • generate_open_api.py Ajoute un script pour générer la spécification OpenAPI de votre API
    • project.json Ajoute une nouvelle cible de build pour exécuter le script de génération

    Le générateur modifiera les fichiers suivants dans votre application React :

    • Répertoiresrc
      • Répertoirecomponents
        • <ApiName>Provider.tsx Provider pour le client de votre API
        • QueryClientProvider.tsx Provider du client TanStack React Query
      • Répertoirehooks
        • use<ApiName>.tsx Ajoute un hook pour appeler votre API avec gestion d’état par TanStack Query
        • use<ApiName>Client.tsx Ajoute un hook pour instancier le client vanilla pouvant appeler votre API
        • useSigV4.tsx Ajoute un hook pour signer les requêtes HTTP avec SigV4 (si l’authentification IAM est sélectionnée)
    • project.json Ajoute une nouvelle cible de build pour générer un client typé
    • .gitignore Les fichiers clients générés sont ignorés par défaut

    Le générateur ajoutera également une configuration Runtime à votre infrastructure de site web si elle n’existe pas déjà, garantissant que l’URL de l’API FastAPI est disponible dans le site et configurée automatiquement par le hook use<ApiName>.tsx.

    Lors du build, un client typé est généré à partir de la spécification OpenAPI de votre FastAPI. Cela ajoute trois nouveaux fichiers à votre application React :

    • Répertoiresrc
      • Répertoiregenerated
        • Répertoire<ApiName>
          • types.gen.ts Types générés à partir des modèles pydantic de votre FastAPI
          • client.gen.ts Client typé pour appeler votre API
          • options-proxy.gen.ts Fournit des méthodes pour créer des options de hooks TanStack Query interagissant avec votre API

    Le client typé généré permet d’appeler votre FastAPI depuis votre application React. Il est recommandé d’utiliser les hooks TanStack Query, mais le client vanilla peut aussi être utilisé directement.

    Le générateur fournit un hook use<ApiName> pour appeler votre API avec TanStack Query.

    Utilisez la méthode queryOptions pour récupérer les options nécessaires à l’appel de votre API via le hook useQuery de TanStack Query :

    import { useQuery } from '@tanstack/react-query';
    import { useState, useEffect } from 'react';
    import { useMyApi } from './hooks/useMyApi';
    function MyComponent() {
    const api = useMyApi();
    const item = useQuery(api.getItem.queryOptions({ itemId: 'some-id' }));
    if (item.isLoading) return <div>Chargement...</div>;
    if (item.isError) return <div>Erreur : {item.error.message}</div>;
    return <div>Élément : {item.data.name}</div>;
    }
    Cliquez ici pour un exemple utilisant le client vanilla directement.

    Les hooks générés prennent en charge les mutations via le hook useMutation de TanStack Query, offrant une gestion propre des opérations de création, mise à jour et suppression avec états de chargement, gestion d’erreurs et mises à jour optimistes.

    import { useMutation } from '@tanstack/react-query';
    import { useMyApi } from './hooks/useMyApi';
    function CreateItemForm() {
    const api = useMyApi();
    // Crée une mutation avec les options générées
    const createItem = useMutation(api.createItem.mutationOptions());
    const handleSubmit = (e) => {
    e.preventDefault();
    createItem.mutate({ name: 'Nouvel élément', description: 'Un nouvel élément' });
    };
    return (
    <form onSubmit={handleSubmit}>
    {/* Champs du formulaire */}
    <button
    type="submit"
    disabled={createItem.isPending}
    >
    {createItem.isPending ? 'Création...' : 'Créer l\'élément'}
    </button>
    {createItem.isSuccess && (
    <div className="success">
    Élément créé avec l'ID : {createItem.data.id}
    </div>
    )}
    {createItem.isError && (
    <div className="error">
    Erreur : {createItem.error.message}
    </div>
    )}
    </form>
    );
    }

    Vous pouvez ajouter des callbacks pour différents états de mutation :

    const createItem = useMutation({
    ...api.createItem.mutationOptions(),
    onSuccess: (data) => {
    console.log('Élément créé :', data);
    navigate(`/items/${data.id}`);
    },
    onError: (error) => {
    console.error('Échec de la création :', error);
    },
    onSettled: () => {
    queryClient.invalidateQueries({ queryKey: api.listItems.queryKey() });
    }
    });
    Cliquez ici pour un exemple utilisant le client directement.

    Pour les endpoints utilisant un paramètre cursor, les hooks générés prennent en charge les requêtes infinies via useInfiniteQuery de TanStack Query, facilitant l’implémentation de fonctionnalités “charger plus” ou de défilement infini.

    import { useInfiniteQuery } from '@tanstack/react-query';
    import { useMyApi } from './hooks/useMyApi';
    function ItemList() {
    const api = useMyApi();
    const items = useInfiniteQuery({
    ...api.listItems.infiniteQueryOptions({
    limit: 10,
    }, {
    getNextPageParam: (lastPage) =>
    lastPage.nextCursor || undefined
    }),
    });
    if (items.isLoading) {
    return <LoadingSpinner />;
    }
    if (items.isError) {
    return <ErrorMessage message={items.error.message} />;
    }
    return (
    <div>
    <ul>
    {items.data.pages.flatMap(page =>
    page.items.map(item => (
    <li key={item.id}>{item.name}</li>
    ))
    )}
    </ul>
    <button
    onClick={() => items.fetchNextPage()}
    disabled={!items.hasNextPage || items.isFetchingNextPage}
    >
    {items.isFetchingNextPage
    ? 'Chargement...'
    : items.hasNextPage
    ? 'Charger plus'
    : 'Fin des éléments'}
    </button>
    </div>
    );
    }

    Les hooks générés gèrent automatiquement la pagination par curseur si votre API la supporte. La valeur nextCursor est extraite de la réponse pour récupérer la page suivante.

    Cliquez ici pour un exemple utilisant le client directement.

    L’intégration inclut une gestion d’erreurs typée. Un type <operation-name>Error est généré, encapsulant les réponses d’erreur possibles définies dans la spécification OpenAPI. Chaque erreur a une propriété status et error, permettant un traitement spécifique via le status.

    import { useMutation } from '@tanstack/react-query';
    function MyComponent() {
    const api = useMyApi();
    const createItem = useMutation(api.createItem.mutationOptions());
    const handleClick = () => {
    createItem.mutate({ name: 'Nouvel élément' });
    };
    if (createItem.error) {
    switch (createItem.error.status) {
    case 400:
    return (
    <div>
    <h2>Entrée invalide :</h2>
    <p>{createItem.error.error.message}</p>
    <ul>
    {createItem.error.error.validationErrors.map((err) => (
    <li key={err.field}>{err.message}</li>
    ))}
    </ul>
    </div>
    );
    case 403:
    return (
    <div>
    <h2>Non autorisé :</h2>
    <p>{createItem.error.error.reason}</p>
    </div>
    );
    case 500:
    case 502:
    return (
    <div>
    <h2>Erreur serveur :</h2>
    <p>{createItem.error.error.message}</p>
    <p>Trace ID : {createItem.error.error.traceId}</p>
    </div>
    );
    }
    }
    return <button onClick={handleClick}>Créer l'élément</button>;
    }
    Cliquez ici pour un exemple utilisant le client vanilla directement.

    Si vous avez configuré votre FastAPI pour streamer des réponses, le hook useQuery mettra automatiquement à jour ses données à chaque réception de nouveau morceau.

    Exemple :

    function MyStreamingComponent() {
    const api = useMyApi();
    const stream = useQuery(api.myStream.queryOptions());
    return (
    <ul>
    {(stream.data ?? []).map((chunk) => (
    <li>
    {chunk.timestamp.toISOString()} : {chunk.message}
    </li>
    ))}
    </ul>
    );
    }

    Le cycle de vie d’un flux :

    1. La requête HTTP est envoyée

      • isLoading = true
      • fetchStatus = 'fetching'
      • data = undefined
    2. Premier morceau reçu

      • isLoading = false
      • fetchStatus = 'fetching'
      • data = tableau avec le premier morceau
    3. Morceaux suivants reçus

      • isLoading reste false
      • fetchStatus reste 'fetching'
      • data est mis à jour à chaque morceau
    4. Flux terminé

      • isLoading reste false
      • fetchStatus = 'idle'
      • data contient tous les morceaux
    Cliquez ici pour un exemple utilisant le client vanilla directement.

    Par défaut, les opérations utilisant PUT, POST, PATCH et DELETE sont considérées comme des mutations. Ce comportement peut être modifié via x-query et x-mutation.

    @app.post(
    "/items",
    openapi_extra={
    "x-query": True
    }
    )
    def list_items():
    # ...

    Génère des queryOptions malgré la méthode POST :

    const items = useQuery(api.listItems.queryOptions());
    @app.get(
    "/start-processing",
    openapi_extra={
    "x-mutation": True
    }
    )
    def start_processing():
    # ...

    Génère des mutationOptions malgré la méthode GET :

    const startProcessing = useMutation(api.startProcessing.mutationOptions());

    Par défaut, le paramètre de pagination est nommé cursor. Personnalisez-le via x-cursor :

    @app.get(
    "/items",
    openapi_extra={
    "x-cursor": "page_token"
    }
    )
    def list_items(page_token: str = None, limit: int = 10):
    # ...
    return {
    "items": items,
    "page_token": next_page_token
    }

    Pour désactiver la pagination par curseur :

    @app.get(
    "/items",
    openapi_extra={
    "x-cursor": False
    }
    )
    def list_items(page: int = 1, limit: int = 10):
    # ...

    Les hooks et méthodes client sont organisés selon les tags OpenAPI de vos endpoints FastAPI.

    Exemple :

    items.py
    @app.get("/items", tags=["items"])
    def list():
    # ...
    @app.post("/items", tags=["items"])
    def create(item: Item):
    # ...
    users.py
    @app.get("/users", tags=["users"])
    def list():
    # ...

    Utilisation groupée :

    function ItemsAndUsers() {
    const api = useMyApi();
    const items = useQuery(api.items.list.queryOptions());
    const createItem = useMutation(api.items.create.mutationOptions());
    const users = useQuery(api.users.list.queryOptions());
    return (
    <div>
    <h2>Éléments</h2>
    <ul>
    {items.data?.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
    <button onClick={() => createItem.mutate({ name: 'Nouvel élément' })}>
    Ajouter
    </button>
    <h2>Utilisateurs</h2>
    <ul>
    {users.data?.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
    </div>
    );
    }
    Cliquez ici pour un exemple utilisant le client directement.

    Personnalisez les réponses d’erreur dans FastAPI avec des modèles d’exception et des gestionnaires. Le client généré gérera automatiquement ces types d’erreur.

    models.py
    class ErrorDetails(BaseModel):
    message: str
    class ValidationError(BaseModel):
    message: str
    field_errors: list[str]
    exceptions.py
    class NotFoundException(Exception):
    def __init__(self, message: str):
    self.message = message
    class ValidationException(Exception):
    def __init__(self, details: ValidationError):
    self.details = details
    main.py
    @app.exception_handler(NotFoundException)
    async def not_found_handler(request: Request, exc: NotFoundException):
    return JSONResponse(
    status_code=404,
    content=exc.message,
    )
    @app.exception_handler(ValidationException)
    async def validation_error_handler(request: Request, exc: ValidationException):
    return JSONResponse(
    status_code=400,
    content=exc.details.model_dump(),
    )
    main.py
    @app.get(
    "/items/{item_id}",
    responses={
    404: {"model": str},
    500: {"model": ErrorDetails}
    }
    )
    def get_item(item_id: str) -> Item:
    # ...
    @app.post(
    "/items",
    responses={
    400: {"model": ValidationError},
    403: {"model": str}
    }
    )
    def create_item(item: Item) -> Item:
    # ...
    function ItemComponent() {
    const api = useMyApi();
    const getItem = useQuery({
    ...api.getItem.queryOptions({ itemId: '123' }),
    onError: (error) => {
    switch (error.status) {
    case 404:
    console.error('Non trouvé :', error.error);
    break;
    case 500:
    console.error('Erreur serveur :', error.error.message);
    break;
    }
    }
    });
    const createItem = useMutation({
    ...api.createItem.mutationOptions(),
    onError: (error) => {
    switch (error.status) {
    case 400:
    console.error('Erreur de validation :', error.error.message);
    break;
    case 403:
    console.error('Interdit :', error.error);
    break;
    }
    }
    });
    if (getItem.isError) {
    if (getItem.error.status === 404) {
    return <NotFoundMessage message={getItem.error.error} />;
    } else {
    return <ErrorMessage message={getItem.error.error.message} />;
    }
    }
    return <div>{/* ... */}</div>;
    }
    Cliquez ici pour un exemple utilisant le client directement.

    Toujours gérer les états de chargement et d’erreur :

    function ItemList() {
    const api = useMyApi();
    const items = useQuery(api.listItems.queryOptions());
    if (items.isLoading) return <LoadingSpinner />;
    if (items.isError) {
    const err = items.error;
    switch (err.status) {
    case 403:
    return <ErrorMessage message={err.error.reason} />;
    case 500:
    return <ErrorMessage message={err.error.message} trace={err.error.traceId} />;
    default:
    return <ErrorMessage message="Erreur inconnue" />;
    }
    }
    return (
    <ul>
    {items.data.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
    );
    }
    Cliquez ici pour un exemple utilisant le client directement.

    Implémentez des mises à jour optimistes pour une meilleure expérience utilisateur :

    function ItemList() {
    const api = useMyApi();
    const queryClient = useQueryClient();
    const itemsQuery = useQuery(api.listItems.queryOptions());
    const deleteMutation = useMutation({
    ...api.deleteItem.mutationOptions(),
    onMutate: async (itemId) => {
    await queryClient.cancelQueries(api.listItems.queryKey());
    const previousItems = queryClient.getQueryData(api.listItems.queryKey());
    queryClient.setQueryData(api.listItems.queryKey(), old => old.filter(item => item.id !== itemId));
    return { previousItems };
    },
    onError: (err, itemId, context) => {
    queryClient.setQueryData(api.listItems.queryKey(), context.previousItems);
    },
    onSettled: () => {
    queryClient.invalidateQueries(api.listItems.queryKey());
    },
    });
    return (
    <ul>
    {itemsQuery.data.map(item => (
    <li key={item.id}>
    {item.name}
    <button
    onClick={() => deleteMutation.mutate(item.id)}
    disabled={deleteMutation.isPending}
    >
    {deleteMutation.isPending ? 'Suppression...' : 'Supprimer'}
    </button>
    </li>
    ))}
    </ul>
    );
    }
    Cliquez ici pour un exemple utilisant le client directement.

    L’intégration assure une sécurité des types de bout en bout. Votre IDE fournira l’autocomplétion et la vérification de types pour tous les appels d’API :

    function ItemForm() {
    const api = useMyApi();
    const createItem = useMutation({
    ...api.createItem.mutationOptions(),
    onSuccess: (data) => {
    console.log(`ID : ${data.id}`); // data typé selon le schéma de réponse
    },
    });
    const handleSubmit = (data: CreateItemInput) => {
    createItem.mutate(data); // Erreur de type si `data` ne correspond pas
    };
    if (createItem.error) {
    const error = createItem.error;
    switch (error.status) {
    case 400:
    return <FormError errors={error.error.validationErrors} />;
    case 403:
    return <AuthError reason={error.error.reason} />;
    default:
    return <ServerError message={error.error.message} />;
    }
    }
    return (
    <form onSubmit={e => {
    e.preventDefault();
    handleSubmit({ name: 'Nouvel élément' });
    }}>
    <button disabled={createItem.isPending}>
    {createItem.isPending ? 'Création...' : 'Créer'}
    </button>
    </form>
    );
    }
    Cliquez ici pour un exemple utilisant le client directement.

    Les types sont générés automatiquement depuis le schéma OpenAPI de votre FastAPI, garantissant que toute modification de l’API est reflétée dans le code frontend après un build.