Skip to main content

Vue d’ensemble

L’API Layer est une couche d’abstraction TypeScript qui centralise toutes les interactions avec le backend Nhost via GraphQL. Elle fournit des fonctions type-safe, une gestion d’erreurs cohérente et une détection automatique des erreurs réseau. Principe fondamental :
Aucune requête GraphQL directe dans les composants. Tout passe par src/api/ via les wrappers apiCall() et apiCallVoid().
Avantages :
  • Type safety avec TypeScript strict
  • Gestion d’erreurs centralisée (ApiError)
  • Détection automatique des erreurs réseau
  • Logging via logger (jamais de console.log en prod)
  • Tests unitaires simplifiés
  • Refactoring facile (changement de backend transparent)
Structure :
src/api/
├── client.ts          # Client Nhost singleton
├── base.ts            # apiCall, apiCallVoid, ApiError
├── types.ts           # Types API
├── auth.ts            # Authentication (Nhost Auth)
├── tools.ts           # Tools CRUD (GraphQL)
├── categories.ts      # Categories CRUD
├── filters.ts         # Filters CRUD
├── workflows.ts       # Workflows CRUD
├── packs.ts           # Tool Packs CRUD
├── quiz/              # Quiz operations
├── index.ts           # Barrel exports
└── __tests__/         # Tests unitaires

Client Nhost

Configuration

Fichier : src/api/client.ts
import { createClient } from '@nhost/nhost-js';

const nhostSubdomain = import.meta.env.VITE_NHOST_SUBDOMAIN || 'gqvlmqwbsmkhlllmgbyw';
const nhostRegion = import.meta.env.VITE_NHOST_REGION || 'eu-central-1';

if (!nhostSubdomain || !nhostRegion) {
  throw new Error('Missing Nhost environment variables.');
}

export const nhost = createClient({
  subdomain: nhostSubdomain,
  region: nhostRegion,
});
Singleton : Une seule instance Nhost réutilisée partout. Le client expose automatiquement :
  • nhost.graphql — requêtes GraphQL vers Hasura
  • nhost.auth — authentification (signIn, signOut, session)
  • nhost.storage — upload/download de fichiers

Wrappers apiCall / apiCallVoid

apiCall — Pour les opérations qui retournent des données

Fichier : src/api/base.ts
export async function apiCall<T>(
  operation: () => Promise<{ data: T; error: unknown }>,
  operationName: string
): Promise<T> {
  let result;
  try {
    result = await operation();
  } catch (error) {
    if (isNetworkError(error)) {
      throw new ApiError('NETWORK_UNAVAILABLE');
    }
    throw new ApiError(error instanceof Error ? error.message : 'Unknown error');
  }

  const { data, error } = result;
  if (error) {
    throw new ApiError(/* message from error */);
  }
  return data;
}

apiCallVoid — Pour les opérations sans retour (mutations)

export async function apiCallVoid(
  operation: () => Promise<{ error: unknown }>,
  operationName: string
): Promise<void> {
  // Même logique, mais ne retourne rien
}

ApiError Class

export class ApiError extends Error {
  constructor(message: string, public code?: string, public details?: unknown) {
    super(message);
    this.name = 'ApiError';
  }
}

Tools API — Exemple complet

Fichier : src/api/tools.ts Les requêtes sont faites via nhost.graphql.request() avec des queries GraphQL :
import { apiCall, apiCallVoid } from './base';
import { nhost } from './client';
import type { EnhancedTool, ToolInsert } from './types';

// Helper interne : récupérer les catégories
async function fetchCategories(): Promise<Map<string, string>> {
  const query = `query { categories { id name } }`;
  const result = await nhost.graphql.request({ query });
  if (result.error) return new Map();
  const categories = result.body?.data?.categories || [];
  return new Map(categories.map((c: any) => [c.id, c.name]));
}

export const toolsApi = {
  /**
   * Liste tous les outils avec catégorie et features enrichies
   */
  async list(): Promise<EnhancedTool[]> {
    const query = `
      query GetAllTools {
        tools(order_by: { name: asc }) {
          id name description pricing_tier category_id
          logo_url website_url created_at
        }
      }
    `;

    const data = await apiCall(
      () => nhost.graphql.request({ query }).then(r => ({
        data: r.body?.data?.tools || [],
        error: r.error
      })),
      'tools.list'
    );

    // Enrichir avec noms de catégories et features
    const categoryMap = await fetchCategories();
    return data.map(tool => ({
      ...tool,
      category_name: categoryMap.get(tool.category_id) || 'Non catégorisé',
      features: [] // Chargées séparément
    }));
  },

  /**
   * Créer un nouvel outil
   */
  async create(tool: ToolInsert): Promise<any> {
    const mutation = `
      mutation InsertTool($object: tools_insert_input!) {
        insert_tools_one(object: $object) {
          id name
        }
      }
    `;

    return apiCall(
      () => nhost.graphql.request({
        query: mutation,
        variables: { object: tool }
      }).then(r => ({
        data: r.body?.data?.insert_tools_one,
        error: r.error
      })),
      'tools.create'
    );
  },

  /**
   * Mettre à jour un outil (convention Hasura : _set)
   */
  async update(id: string, updates: Partial<ToolInsert>): Promise<any> {
    const mutation = `
      mutation UpdateTool($id: uuid!, $set: tools_set_input!) {
        update_tools_by_pk(pk_columns: { id: $id }, _set: $set) {
          id name
        }
      }
    `;

    return apiCall(
      () => nhost.graphql.request({
        query: mutation,
        variables: { id, set: updates }
      }).then(r => ({
        data: r.body?.data?.update_tools_by_pk,
        error: r.error
      })),
      'tools.update'
    );
  },

  /**
   * Supprimer un outil
   */
  async delete(id: string): Promise<void> {
    const mutation = `
      mutation DeleteTool($id: uuid!) {
        delete_tools_by_pk(id: $id) { id }
      }
    `;

    await apiCallVoid(
      () => nhost.graphql.request({
        query: mutation,
        variables: { id }
      }).then(r => ({ error: r.error })),
      'tools.delete'
    );
  }
};
Convention Hasura : Pour les mutations update, utilisez _set (avec underscore) pour définir les champs à mettre à jour.

Utilisation dans les composants

Pattern recommandé

❌ Mauvais : Requête GraphQL directe
function ToolsList() {
  useEffect(() => {
    // ❌ NE PAS FAIRE
    nhost.graphql.request({ query: '{ tools { id name } }' })
      .then(r => setTools(r.body.data.tools));
  }, []);
}
✅ Bon : Via API Layer
import { toolsApi } from '@/api';

function ToolsList() {
  const [tools, setTools] = useState<EnhancedTool[]>([]);

  useEffect(() => {
    toolsApi.list()
      .then(setTools)
      .catch(err => {
        if (err instanceof ApiError) {
          logger.error(err.message);
        }
      });
  }, []);
}

Avec Custom Hook (meilleure approche)

import { useAppData } from '@/hooks/useAppData';

function ToolsList() {
  const { tools, isLoading, error } = useAppData();

  if (isLoading) return <LoadingSpinner />;
  if (error) return <ErrorMessage message={error} />;

  return (
    <div className="grid md:grid-cols-3 gap-4">
      {tools.map(tool => (
        <ToolCard key={tool.id} tool={tool} />
      ))}
    </div>
  );
}

Bonnes pratiques

✅ À faire

Toujours utiliser apiCall/apiCallVoid
// ✅ Bon
const data = await apiCall(
  () => nhost.graphql.request({ query, variables }),
  'operationName'
);

// ❌ Mauvais
const result = await nhost.graphql.request({ query });
Utiliser logger au lieu de console.log
// ✅ Bon
logger.error('API error:', error, { component: 'api' });

// ❌ Mauvais (pas autorisé en production)
console.log('error', error);
Typer les retours API
async list(): Promise<EnhancedTool[]> { }

❌ À éviter

Ne pas appeler nhost.graphql directement dans les composants. Toujours passer par src/api/. Ne pas ignorer les erreurs silencieusement. Ne pas utiliser de types any — préférer les types définis dans src/api/types.ts.

Ressources

Nhost & GraphQL

Architecture backend complète

Database Schema

Structure des tables pour comprendre les relations

Custom Hooks

Hooks qui utilisent l’API Layer

Testing

Guide complet pour tester l’API