Skip to content

Latest commit

 

History

History
1624 lines (1386 loc) · 43.4 KB

File metadata and controls

1624 lines (1386 loc) · 43.4 KB

🔝 Retour au Sommaire

23.6 Intégration avec des frameworks JavaScript

Introduction

Les frameworks JavaScript modernes comme React, Vue.js et Angular ont révolutionné le développement d'interfaces web. Ils permettent de créer des applications web interactives, réactives et performantes. La bonne nouvelle ? Vous pouvez combiner la puissance de Delphi pour le backend avec ces frameworks pour le frontend !

L'idée centrale : Delphi gère les données et la logique métier (backend), tandis qu'un framework JavaScript crée une interface utilisateur moderne (frontend).

┌─────────────────────────────────┐
│  Frontend (Navigateur)          │
│  ┌───────────────────────────┐  │
│  │  React / Vue / Angular    │  │
│  │  (Interface utilisateur)  │  │
│  └──────────┬────────────────┘  │
└─────────────┼───────────────────┘
              │
              │ API REST (JSON)
              │ HTTP/HTTPS
              │
┌─────────────┴───────────────────┐
│  Backend (Serveur)              │
│  ┌───────────────────────────┐  │
│  │  Delphi / Object Pascal   │  │
│  │  (Logique métier + BDD)   │  │
│  └───────────────────────────┘  │
└─────────────────────────────────┘

Pourquoi cette combinaison ?

Avantages de Delphi côté backend

Performance native - Code compilé rapide et efficace
Accès base de données - FireDAC puissant et mature
Frontière de confiance claire - Validation, autorisations et secrets restent côté serveur (et non livrés au navigateur)
Expertise existante - Capitaliser sur vos compétences
Stabilité éprouvée - Plateforme fiable pour applications critiques

Avantages des frameworks JavaScript côté frontend

Interface moderne - UX/UI à la pointe
Réactivité - Mises à jour instantanées sans rechargement
Écosystème riche - Milliers de composants prêts à l'emploi
Communauté massive - Support et ressources abondantes
Standards web - Technologies répandues et bien documentées

Le meilleur des deux mondes

Cette architecture vous permet de :

  • Utiliser Delphi pour ce qu'il fait de mieux : données et logique métier
  • Profiter des frameworks JavaScript pour créer des interfaces exceptionnelles
  • Évoluer indépendamment frontend et backend
  • Recruter facilement des développeurs frontend JavaScript

Architectures possibles

Architecture 1 : Séparation complète (recommandée)

┌──────────────────┐
│  Application JS  │ (React/Vue/Angular)
│  (Frontend)      │ Port 3000 ou 4200
└────────┬─────────┘
         │
         │ Appels API REST
         │
┌────────┴─────────┐
│  API Delphi      │ (Horse, RAD Server)
│  (Backend)       │ Port 9000
└────────┬─────────┘
         │
┌────────┴─────────┐
│  Base de données │
└──────────────────┘

Avantages :

  • Séparation claire des responsabilités
  • Développement parallèle possible
  • Déploiement indépendant
  • Scaling horizontal facilité

Architecture 2 : Serveur unique avec fichiers statiques

┌─────────────────────────────────┐
│  Serveur Web (Delphi)           │
│  ┌───────────────────────────┐  │
│  │  API REST    /api/*       │  │
│  ├───────────────────────────┤  │
│  │  Fichiers JS  /           │  │
│  │  (build React/Vue)        │  │
│  └───────────────────────────┘  │
└─────────────────────────────────┘

Avantages :

  • Déploiement simplifié
  • Un seul serveur à gérer
  • Pas de problème CORS
  • Configuration réseau simplifiée

Les frameworks JavaScript populaires

React

Créé par : Facebook/Meta
Type : Bibliothèque UI (pas un framework complet)
Philosophie : Composants réutilisables

Exemple simple React :

// Composant React qui affiche une liste de clients
import React, { useState, useEffect } from 'react';

function ClientsList() {
  const [clients, setClients] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Appel à l'API Delphi
    fetch('http://localhost:9000/api/clients')
      .then(response => response.json())
      .then(data => {
        setClients(data);
        setLoading(false);
      })
      .catch(error => {
        console.error('Erreur:', error);
        setLoading(false);
      });
  }, []);

  if (loading) return <div>Chargement...</div>;

  return (
    <div>
      <h1>Liste des clients</h1>
      <table>
        <thead>
          <tr>
            <th>Nom</th>
            <th>Prénom</th>
            <th>Email</th>
          </tr>
        </thead>
        <tbody>
          {clients.map(client => (
            <tr key={client.id}>
              <td>{client.nom}</td>
              <td>{client.prenom}</td>
              <td>{client.email}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

export default ClientsList;

Quand utiliser React :

  • Interface complexe avec beaucoup d'interactions
  • Besoin de flexibilité maximale
  • Grande communauté et écosystème
  • Nombreux développeurs disponibles sur le marché

Vue.js

Créé par : Evan You
Type : Framework progressif
Philosophie : Simple et intuitif

Exemple simple Vue.js :

<template>
  <div>
    <h1>Liste des clients</h1>
    <div v-if="loading">Chargement...</div>
    <table v-else>
      <thead>
        <tr>
          <th>Nom</th>
          <th>Prénom</th>
          <th>Email</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="client in clients" :key="client.id">
          <td>{{ client.nom }}</td>
          <td>{{ client.prenom }}</td>
          <td>{{ client.email }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
export default {
  name: 'ClientsList',
  data() {
    return {
      clients: [],
      loading: true
    };
  },
  mounted() {
    // Appel à l'API Delphi
    fetch('http://localhost:9000/api/clients')
      .then(response => response.json())
      .then(data => {
        this.clients = data;
        this.loading = false;
      })
      .catch(error => {
        console.error('Erreur:', error);
        this.loading = false;
      });
  }
};
</script>

Quand utiliser Vue.js :

  • Courbe d'apprentissage douce
  • Migration progressive d'application existante
  • Documentation excellente en français
  • Bonne productivité dès le départ

Angular

Créé par : Google
Type : Framework complet
Philosophie : Structure et conventions strictes

Exemple simple Angular :

// clients.component.ts
import { Component, OnInit } from '@angular/core';  
import { HttpClient } from '@angular/common/http';  

interface Client {
  id: number;
  nom: string;
  prenom: string;
  email: string;
}

@Component({
  selector: 'app-clients',
  templateUrl: './clients.component.html'
})
export class ClientsComponent implements OnInit {
  clients: Client[] = [];
  loading = true;

  constructor(private http: HttpClient) {}

  ngOnInit() {
    // Appel à l'API Delphi
    this.http.get<Client[]>('http://localhost:9000/api/clients')
      .subscribe({
        next: (data) => {
          this.clients = data;
          this.loading = false;
        },
        error: (error) => {
          console.error('Erreur:', error);
          this.loading = false;
        }
      });
  }
}
<!-- clients.component.html -->
<div>
  <h1>Liste des clients</h1>
  <div *ngIf="loading">Chargement...</div>
  <table *ngIf="!loading">
    <thead>
      <tr>
        <th>Nom</th>
        <th>Prénom</th>
        <th>Email</th>
      </tr>
    </thead>
    <tbody>
      <tr *ngFor="let client of clients">
        <td>{{ client.nom }}</td>
        <td>{{ client.prenom }}</td>
        <td>{{ client.email }}</td>
      </tr>
    </tbody>
  </table>
</div>

Quand utiliser Angular :

  • Applications d'entreprise complexes
  • Équipe habituée à TypeScript
  • Besoin de structure stricte
  • Applications à grande échelle

Configuration de l'API Delphi

Serveur Horse avec CORS

Pour que JavaScript puisse communiquer avec votre API Delphi, vous devez configurer CORS (Cross-Origin Resource Sharing).

program APIServer;

{$APPTYPE CONSOLE}

uses
  System.SysUtils,
  Horse,
  Horse.Jhonson,  // Pour JSON
  Horse.CORS,     // Pour CORS
  Horse.HandleException, // Gestion erreurs
  System.JSON,
  FireDAC.Comp.Client,
  DataModuleUnit in 'DataModuleUnit.pas';

var
  App: THorse;

// Route GET /api/clients
procedure GetClients(Req: THorseRequest; Res: THorseResponse; Next: TProc);  
var  
  Query: TFDQuery;
  JSONArray: TJSONArray;
  JSONObject: TJSONObject;
begin
  Query := TFDQuery.Create(nil);
  try
    Query.Connection := DMData.Connection;
    Query.SQL.Text := 'SELECT id, nom, prenom, email FROM clients ORDER BY nom';
    Query.Open;

    JSONArray := TJSONArray.Create;
    try
      while not Query.Eof do
      begin
        JSONObject := TJSONObject.Create;
        JSONObject.AddPair('id', TJSONNumber.Create(Query.FieldByName('id').AsInteger));
        JSONObject.AddPair('nom', Query.FieldByName('nom').AsString);
        JSONObject.AddPair('prenom', Query.FieldByName('prenom').AsString);
        JSONObject.AddPair('email', Query.FieldByName('email').AsString);
        JSONArray.Add(JSONObject);
        Query.Next;
      end;

      Res.Send<TJSONArray>(JSONArray);
    finally
      // JSONArray sera libéré automatiquement
    end;
  finally
    Query.Free;
  end;
end;

// Route GET /api/clients/:id
procedure GetClient(Req: THorseRequest; Res: THorseResponse; Next: TProc);  
var  
  Query: TFDQuery;
  JSONObject: TJSONObject;
  ClientID: Integer;
begin
  // ⚠️ TryStrToInt évite une AccessViolation si /api/clients/abc est appelé.
  //    StrToInt lèverait une EConvertError → 500 au lieu de 400.
  if not TryStrToInt(Req.Params['id'], ClientID) then
  begin
    Res.Status(400).Send('ID invalide');
    Exit;
  end;

  Query := TFDQuery.Create(nil);
  try
    Query.Connection := DMData.Connection;
    Query.SQL.Text := 'SELECT * FROM clients WHERE id = :id';
    Query.ParamByName('id').AsInteger := ClientID;
    Query.Open;

    if Query.IsEmpty then
    begin
      Res.Status(404).Send('Client non trouvé');
      Exit;
    end;

    JSONObject := TJSONObject.Create;
    try
      JSONObject.AddPair('id', TJSONNumber.Create(Query.FieldByName('id').AsInteger));
      JSONObject.AddPair('nom', Query.FieldByName('nom').AsString);
      JSONObject.AddPair('prenom', Query.FieldByName('prenom').AsString);
      JSONObject.AddPair('email', Query.FieldByName('email').AsString);
      JSONObject.AddPair('telephone', Query.FieldByName('telephone').AsString);

      Res.Send<TJSONObject>(JSONObject);
    finally
      // JSONObject sera libéré automatiquement
    end;
  finally
    Query.Free;
  end;
end;

// Route POST /api/clients
procedure CreateClient(Req: THorseRequest; Res: THorseResponse; Next: TProc);  
var  
  Query: TFDQuery;
  Body: TJSONObject;
  Response: TJSONObject;
  NewID: Integer;
  Nom, Prenom, Email: string;
begin
  Body := Req.Body<TJSONObject>;

  // ⚠️ TryGetValue<T> retourne un BOOLEAN et remplit un paramètre OUT.
  //    L'écriture `Body.TryGetValue<string>('nom').Trim.IsEmpty` ne
  //    compile pas — on extrait d'abord les valeurs via TryGetValue
  //    puis on les valide.
  if not Body.TryGetValue<string>('nom', Nom) then       Nom := '';
  if not Body.TryGetValue<string>('prenom', Prenom) then Prenom := '';
  if not Body.TryGetValue<string>('email', Email) then   Email := '';

  // Validation basique
  if (not Nom.Trim.IsEmpty) and
     (not Prenom.Trim.IsEmpty) and
     (not Email.Trim.IsEmpty) then
  begin
    Query := TFDQuery.Create(nil);
    try
      Query.Connection := DMData.Connection;
      Query.SQL.Text :=
        'INSERT INTO clients (nom, prenom, email, telephone) ' +
        'VALUES (:nom, :prenom, :email, :telephone)';
      Query.ParamByName('nom').AsString := Nom;
      Query.ParamByName('prenom').AsString := Prenom;
      Query.ParamByName('email').AsString := Email;
      // GetValue<T>(path, default) ne lève pas d'exception si la clé
      // est absente : pratique pour les champs optionnels comme le téléphone.
      Query.ParamByName('telephone').AsString := Body.GetValue<string>('telephone', '');
      Query.ExecSQL;

      // Récupérer l'ID du nouveau client — syntaxe MySQL/MariaDB ;
      // pour les variantes par SGBD, voir le fichier 23.3.
      Query.SQL.Text := 'SELECT LAST_INSERT_ID() as id';
      Query.Open;
      NewID := Query.FieldByName('id').AsInteger;

      Response := TJSONObject.Create;
      try
        Response.AddPair('success', TJSONBool.Create(True));
        Response.AddPair('id', TJSONNumber.Create(NewID));
        Response.AddPair('message', 'Client créé avec succès');

        // 💡 201 Created + header Location → bonne pratique REST (HATEOAS).
        Res.RawWebResponse.SetCustomHeader('Location',
          Format('/api/clients/%d', [NewID]));
        Res.Status(201).Send<TJSONObject>(Response);
      finally
        // Response sera libéré automatiquement
      end;
    finally
      Query.Free;
    end;
  end
  else
  begin
    Response := TJSONObject.Create;
    Response.AddPair('success', TJSONBool.Create(False));
    Response.AddPair('message', 'Données invalides');
    Res.Status(400).Send<TJSONObject>(Response);
  end;
end;

// Route PUT /api/clients/:id
procedure UpdateClient(Req: THorseRequest; Res: THorseResponse; Next: TProc);  
var  
  Query: TFDQuery;
  Body: TJSONObject;
  Response: TJSONObject;
  ClientID: Integer;
  Nom, Prenom, Email: string;
begin
  if not TryStrToInt(Req.Params['id'], ClientID) then
  begin
    Res.Status(400).Send('ID invalide');
    Exit;
  end;
  Body := Req.Body<TJSONObject>;

  // ⚠️ Protéger contre Body=nil et champs manquants — cohérent avec CreateClient.
  if not Assigned(Body) or
     not Body.TryGetValue<string>('nom', Nom) or
     not Body.TryGetValue<string>('prenom', Prenom) or
     not Body.TryGetValue<string>('email', Email) then
  begin
    Res.Status(400).Send('Champs requis : nom, prenom, email');
    Exit;
  end;

  Query := TFDQuery.Create(nil);
  try
    Query.Connection := DMData.Connection;
    Query.SQL.Text :=
      'UPDATE clients SET nom = :nom, prenom = :prenom, ' +
      'email = :email, telephone = :telephone WHERE id = :id';
    Query.ParamByName('id').AsInteger := ClientID;
    Query.ParamByName('nom').AsString := Nom;
    Query.ParamByName('prenom').AsString := Prenom;
    Query.ParamByName('email').AsString := Email;
    Query.ParamByName('telephone').AsString := Body.GetValue<string>('telephone', '');
    Query.ExecSQL;

    Response := TJSONObject.Create;
    try
      Response.AddPair('success', TJSONBool.Create(True));
      Response.AddPair('message', 'Client modifié avec succès');

      Res.Send<TJSONObject>(Response);
    finally
      // Response sera libéré automatiquement
    end;
  finally
    Query.Free;
  end;
end;

// Route DELETE /api/clients/:id
procedure DeleteClient(Req: THorseRequest; Res: THorseResponse; Next: TProc);  
var  
  Query: TFDQuery;
  ClientID: Integer;
begin
  if not TryStrToInt(Req.Params['id'], ClientID) then
  begin
    Res.Status(400).Send('ID invalide');
    Exit;
  end;

  Query := TFDQuery.Create(nil);
  try
    Query.Connection := DMData.Connection;
    Query.SQL.Text := 'DELETE FROM clients WHERE id = :id';
    Query.ParamByName('id').AsInteger := ClientID;
    Query.ExecSQL;

    // ⚠️ 204 No Content DOIT être renvoyé SANS corps (RFC 9110).
    //    Aligné avec le pattern du fichier 23.3 ; le client React du
    //    fichier intercepte déjà ce cas (cf. fonction deleteClient).
    Res.Status(204);
  finally
    Query.Free;
  end;
end;

begin
  App := THorse.Create;

  // Middlewares
  App.Use(Jhonson);           // Support JSON
  App.Use(CORS);              // Support CORS
  App.Use(HandleException);   // Gestion des erreurs

  // Routes API
  App.Get('/api/clients', GetClients);
  App.Get('/api/clients/:id', GetClient);
  App.Post('/api/clients', CreateClient);
  App.Put('/api/clients/:id', UpdateClient);
  App.Delete('/api/clients/:id', DeleteClient);

  App.Listen(9000);

  Writeln('API Delphi démarrée sur http://localhost:9000');
  Writeln('Endpoints disponibles :');
  Writeln('  GET    /api/clients');
  Writeln('  GET    /api/clients/:id');
  Writeln('  POST   /api/clients');
  Writeln('  PUT    /api/clients/:id');
  Writeln('  DELETE /api/clients/:id');
  Writeln('');
  Writeln('Appuyez sur Entrée pour arrêter...');

  Readln;
end.

Application React complète avec API Delphi

Structure du projet React

client-app/
├── public/
│   └── index.html
├── src/
│   ├── components/
│   │   ├── ClientsList.js
│   │   ├── ClientForm.js
│   │   └── ClientDetails.js
│   ├── services/
│   │   └── api.js
│   ├── App.js
│   └── index.js
└── package.json

Service API (api.js)

// src/services/api.js
const API_BASE_URL = 'http://localhost:9000/api';

// Configuration de base pour fetch
const defaultOptions = {
  headers: {
    'Content-Type': 'application/json',
  },
};

// Récupérer tous les clients
export async function getClients() {
  try {
    const response = await fetch(`${API_BASE_URL}/clients`, defaultOptions);
    if (!response.ok) {
      throw new Error('Erreur lors de la récupération des clients');
    }
    return await response.json();
  } catch (error) {
    console.error('Erreur:', error);
    throw error;
  }
}

// Récupérer un client par ID
export async function getClient(id) {
  try {
    const response = await fetch(`${API_BASE_URL}/clients/${id}`, defaultOptions);
    if (!response.ok) {
      throw new Error('Client non trouvé');
    }
    return await response.json();
  } catch (error) {
    console.error('Erreur:', error);
    throw error;
  }
}

// Créer un nouveau client
export async function createClient(clientData) {
  try {
    const response = await fetch(`${API_BASE_URL}/clients`, {
      ...defaultOptions,
      method: 'POST',
      body: JSON.stringify(clientData),
    });
    if (!response.ok) {
      throw new Error('Erreur lors de la création du client');
    }
    return await response.json();
  } catch (error) {
    console.error('Erreur:', error);
    throw error;
  }
}

// Modifier un client
export async function updateClient(id, clientData) {
  try {
    const response = await fetch(`${API_BASE_URL}/clients/${id}`, {
      ...defaultOptions,
      method: 'PUT',
      body: JSON.stringify(clientData),
    });
    if (!response.ok) {
      throw new Error('Erreur lors de la modification du client');
    }
    return await response.json();
  } catch (error) {
    console.error('Erreur:', error);
    throw error;
  }
}

// Supprimer un client
export async function deleteClient(id) {
  try {
    const response = await fetch(`${API_BASE_URL}/clients/${id}`, {
      ...defaultOptions,
      method: 'DELETE',
    });
    if (!response.ok) {
      throw new Error('Erreur lors de la suppression du client');
    }
    // ⚠️ Une réponse 204 No Content n'a PAS de corps : appeler
    //    response.json() lèverait `SyntaxError: Unexpected end of JSON input`.
    //    On retourne juste true en cas de succès.
    if (response.status === 204) {
      return true;
    }
    return await response.json();
  } catch (error) {
    console.error('Erreur:', error);
    throw error;
  }
}

Composant Liste des clients

// src/components/ClientsList.js
import React, { useState, useEffect } from 'react';  
import { getClients, deleteClient } from '../services/api';  

function ClientsList({ onEdit }) {
  const [clients, setClients] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  // Charger les clients au montage du composant
  useEffect(() => {
    loadClients();
  }, []);

  async function loadClients() {
    try {
      setLoading(true);
      const data = await getClients();
      setClients(data);
      setError(null);
    } catch (err) {
      setError('Impossible de charger les clients');
      console.error(err);
    } finally {
      setLoading(false);
    }
  }

  async function handleDelete(id) {
    if (window.confirm('Êtes-vous sûr de vouloir supprimer ce client ?')) {
      try {
        await deleteClient(id);
        // Recharger la liste après suppression
        loadClients();
      } catch (err) {
        alert('Erreur lors de la suppression');
        console.error(err);
      }
    }
  }

  if (loading) {
    return <div className="loading">Chargement des clients...</div>;
  }

  if (error) {
    return <div className="error">{error}</div>;
  }

  return (
    <div className="clients-list">
      <h2>Liste des clients ({clients.length})</h2>

      {clients.length === 0 ? (
        <p>Aucun client trouvé.</p>
      ) : (
        <table>
          <thead>
            <tr>
              <th>Nom</th>
              <th>Prénom</th>
              <th>Email</th>
              <th>Actions</th>
            </tr>
          </thead>
          <tbody>
            {clients.map(client => (
              <tr key={client.id}>
                <td>{client.nom}</td>
                <td>{client.prenom}</td>
                <td>{client.email}</td>
                <td>
                  <button onClick={() => onEdit(client)}>
                    ✏️ Modifier
                  </button>
                  <button onClick={() => handleDelete(client.id)}>
                    🗑️ Supprimer
                  </button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </div>
  );
}

export default ClientsList;

Composant Formulaire client

// src/components/ClientForm.js
import React, { useState, useEffect } from 'react';  
import { createClient, updateClient } from '../services/api';  

function ClientForm({ client, onSave, onCancel }) {
  const [formData, setFormData] = useState({
    nom: '',
    prenom: '',
    email: '',
    telephone: '',
  });
  const [errors, setErrors] = useState({});
  const [saving, setSaving] = useState(false);

  // Pré-remplir le formulaire si on modifie un client existant
  useEffect(() => {
    if (client) {
      setFormData({
        nom: client.nom || '',
        prenom: client.prenom || '',
        email: client.email || '',
        telephone: client.telephone || '',
      });
    }
  }, [client]);

  function handleChange(e) {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
    // Effacer l'erreur du champ modifié
    if (errors[name]) {
      setErrors(prev => ({
        ...prev,
        [name]: null
      }));
    }
  }

  function validate() {
    const newErrors = {};

    if (!formData.nom.trim()) {
      newErrors.nom = 'Le nom est obligatoire';
    }
    if (!formData.prenom.trim()) {
      newErrors.prenom = 'Le prénom est obligatoire';
    }
    if (!formData.email.trim()) {
      newErrors.email = 'L\'email est obligatoire';
    } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
      newErrors.email = 'Email invalide';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  }

  async function handleSubmit(e) {
    e.preventDefault();

    if (!validate()) {
      return;
    }

    try {
      setSaving(true);

      if (client) {
        // Modification
        await updateClient(client.id, formData);
      } else {
        // Création
        await createClient(formData);
      }

      onSave();
    } catch (err) {
      alert('Erreur lors de l\'enregistrement');
      console.error(err);
    } finally {
      setSaving(false);
    }
  }

  return (
    <div className="client-form">
      <h2>{client ? 'Modifier le client' : 'Nouveau client'}</h2>

      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label htmlFor="nom">Nom *</label>
          <input
            type="text"
            id="nom"
            name="nom"
            value={formData.nom}
            onChange={handleChange}
            className={errors.nom ? 'error' : ''}
          />
          {errors.nom && <span className="error-message">{errors.nom}</span>}
        </div>

        <div className="form-group">
          <label htmlFor="prenom">Prénom *</label>
          <input
            type="text"
            id="prenom"
            name="prenom"
            value={formData.prenom}
            onChange={handleChange}
            className={errors.prenom ? 'error' : ''}
          />
          {errors.prenom && <span className="error-message">{errors.prenom}</span>}
        </div>

        <div className="form-group">
          <label htmlFor="email">Email *</label>
          <input
            type="email"
            id="email"
            name="email"
            value={formData.email}
            onChange={handleChange}
            className={errors.email ? 'error' : ''}
          />
          {errors.email && <span className="error-message">{errors.email}</span>}
        </div>

        <div className="form-group">
          <label htmlFor="telephone">Téléphone</label>
          <input
            type="tel"
            id="telephone"
            name="telephone"
            value={formData.telephone}
            onChange={handleChange}
          />
        </div>

        <div className="form-actions">
          <button type="submit" disabled={saving}>
            {saving ? 'Enregistrement...' : 'Enregistrer'}
          </button>
          <button type="button" onClick={onCancel}>
            Annuler
          </button>
        </div>
      </form>
    </div>
  );
}

export default ClientForm;

Application principale

// src/App.js
import React, { useState } from 'react';  
import ClientsList from './components/ClientsList';  
import ClientForm from './components/ClientForm';  
import './App.css';  

function App() {
  const [view, setView] = useState('list'); // 'list' ou 'form'
  const [selectedClient, setSelectedClient] = useState(null);

  function handleNewClient() {
    setSelectedClient(null);
    setView('form');
  }

  function handleEditClient(client) {
    setSelectedClient(client);
    setView('form');
  }

  function handleSaveComplete() {
    setView('list');
    setSelectedClient(null);
  }

  function handleCancel() {
    setView('list');
    setSelectedClient(null);
  }

  return (
    <div className="App">
      <header>
        <h1>Gestion des Clients</h1>
        {view === 'list' && (
          <button onClick={handleNewClient}>
            ➕ Nouveau client
          </button>
        )}
      </header>

      <main>
        {view === 'list' ? (
          <ClientsList onEdit={handleEditClient} />
        ) : (
          <ClientForm
            client={selectedClient}
            onSave={handleSaveComplete}
            onCancel={handleCancel}
          />
        )}
      </main>

      <footer>
        <p>Application React + API Delphi</p>
      </footer>
    </div>
  );
}

export default App;

Authentification JWT

Côté Delphi - Génération du token

uses
  Horse, Horse.JWT, JOSE.Core.JWT, JOSE.Core.Builder, System.DateUtils;

procedure Login(Req: THorseRequest; Res: THorseResponse; Next: TProc);  
var  
  Body: TJSONObject;
  Username, Password: string;
  JWT: TJWT;
  Token: string;
  Response: TJSONObject;
begin
  Body := Req.Body<TJSONObject>;

  // ⚠️ Protéger contre Body=nil ou champs manquants — sinon GetValue
  //    lève une AccessViolation au lieu de retourner 400.
  if not Assigned(Body) or
     not Body.TryGetValue<string>('username', Username) or
     not Body.TryGetValue<string>('password', Password) then
  begin
    Response := TJSONObject.Create;
    Response.AddPair('success', TJSONBool.Create(False));
    Response.AddPair('message', 'username et password requis');
    Res.Status(400).Send<TJSONObject>(Response);
    Exit;
  end;

  // ⚠️ Les 5 règles de sécurité JWT (clé secrète, longueur de clé,
  //    expiration, contenu non sensible, durée courte) sont détaillées
  //    dans la section 23.3. Le nom `SECRET_KEY_CHANGE_ME` ci-dessous
  //    est volontairement explicite : en production, charger la clé
  //    depuis une variable d'environnement, pas depuis le source.

  // Vérifier les identifiants (à implémenter selon votre logique)
  if VerifyCredentials(Username, Password) then
  begin
    JWT := TJWT.Create;
    try
      JWT.Claims.Subject := Username;
      JWT.Claims.Expiration := IncHour(Now, 1); // Expire dans 1 h (durée courte recommandée)
      JWT.Claims.SetClaimOfType<string>('role', GetUserRole(Username));

      Token := TJOSE.SHA256CompactToken('SECRET_KEY_CHANGE_ME', JWT);

      Response := TJSONObject.Create;
      try
        Response.AddPair('success', TJSONBool.Create(True));
        Response.AddPair('token', Token);
        Response.AddPair('username', Username);
        Response.AddPair('expiresIn', '3600'); // 1 h en secondes

        Res.Send<TJSONObject>(Response);
      finally
        // Response sera libéré automatiquement
      end;
    finally
      JWT.Free;
    end;
  end
  else
  begin
    Response := TJSONObject.Create;
    Response.AddPair('success', TJSONBool.Create(False));
    Response.AddPair('message', 'Identifiants invalides');
    Res.Status(401).Send<TJSONObject>(Response);
  end;
end;

// Middleware d'authentification
procedure AuthMiddleware(Req: THorseRequest; Res: THorseResponse; Next: TProc);  
var  
  Token: string;
  JWT: TJWT;
begin
  Token := Req.Headers['Authorization'];

  if Token.IsEmpty then
  begin
    Res.Status(401).Send('Token manquant');
    Exit;
  end;

  // Retirer "Bearer " du token (IgnoreCase pour tolérer "bearer" en
  // minuscules — certains clients ne respectent pas la casse RFC 6750).
  if Token.StartsWith('Bearer ', True) then
    Token := Token.Substring(7);

  try
    JWT := TJOSE.Verify('SECRET_KEY_CHANGE_ME', Token);
    try
      // Token valide, continuer
      Next;
    finally
      JWT.Free;
    end;
  except
    Res.Status(401).Send('Token invalide ou expiré');
  end;
end;

// Configuration des routes
begin
  // Route de login (publique)
  THorse.Post('/api/login', Login);

  // Routes protégées
  THorse.AddCallback(AuthMiddleware)
    .Get('/api/clients', GetClients)
    .Post('/api/clients', CreateClient);
end;

Côté React - Gestion du token

🚨 Avertissement sécurité — Où stocker le JWT ?

Le choix du stockage du token côté client est un compromis sécurité :

Stockage Avantages Inconvénients
localStorage Simple, survit aux fermetures d'onglets Vulnérable au XSS — tout script JS peut le lire
sessionStorage Effacé à la fermeture de l'onglet ❌ Toujours vulnérable au XSS
Cookie HttpOnly + Secure + SameSite ✅ Inaccessible à JS (anti-XSS), envoyé automatiquement Nécessite gestion côté serveur ; vulnérable au CSRF (à atténuer avec SameSite)
Memory (variable JS) Aucun stockage persistant Perdu au refresh de page (nécessite refresh token)

Recommandation 2026 : pour les applications sensibles, stocker le token (ou un refresh token) dans un cookie HttpOnly + Secure + SameSite=Strict
servi par votre backend Delphi. L'exemple ci-dessous utilise
localStorage pour simplifier l'apprentissage, mais ce n'est PAS le choix recommandé en production.

// src/services/auth.js
// ⚠️ DÉMO PÉDAGOGIQUE — voir l'avertissement ci-dessus avant
//     de déployer en production.
const TOKEN_KEY = 'auth_token';

export function saveToken(token) {
  localStorage.setItem(TOKEN_KEY, token);
}

export function getToken() {
  return localStorage.getItem(TOKEN_KEY);
}

export function removeToken() {
  localStorage.removeItem(TOKEN_KEY);
}

export function isAuthenticated() {
  return !!getToken();
}

// Service API modifié avec authentification
// src/services/api.js
import { getToken } from './auth';

function getHeaders() {
  const headers = {
    'Content-Type': 'application/json',
  };

  const token = getToken();
  if (token) {
    headers['Authorization'] = `Bearer ${token}`;
  }

  return headers;
}

export async function login(username, password) {
  const response = await fetch(`${API_BASE_URL}/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, password }),
  });

  if (!response.ok) {
    throw new Error('Échec de la connexion');
  }

  return await response.json();
}

export async function getClients() {
  const response = await fetch(`${API_BASE_URL}/clients`, {
    headers: getHeaders(),
  });

  if (response.status === 401) {
    // Token expiré ou invalide
    throw new Error('NON_AUTHENTIFIE');
  }

  if (!response.ok) {
    throw new Error('Erreur lors de la récupération des clients');
  }

  return await response.json();
}

// Composant Login
// src/components/Login.js
import React, { useState } from 'react';  
import { login } from '../services/api';  
import { saveToken } from '../services/auth';  

function Login({ onLoginSuccess }) {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();
    setError('');
    setLoading(true);

    try {
      const response = await login(username, password);

      if (response.success) {
        saveToken(response.token);
        onLoginSuccess(response.username);
      } else {
        setError(response.message || 'Échec de la connexion');
      }
    } catch (err) {
      setError('Erreur de connexion');
      console.error(err);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="login-container">
      <h2>Connexion</h2>
      <form onSubmit={handleSubmit}>
        {error && <div className="error-message" role="alert">{error}</div>}

        <div className="form-group">
          {/* htmlFor relie le label à l'input (accessibilité +
              cliquer sur le label focus l'input).
              autoComplete aide les gestionnaires de mots de passe
              et déclenche le remplissage automatique du navigateur. */}
          <label htmlFor="login-username">Nom d'utilisateur</label>
          <input
            id="login-username"
            name="username"
            type="text"
            autoComplete="username"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            required
          />
        </div>

        <div className="form-group">
          <label htmlFor="login-password">Mot de passe</label>
          <input
            id="login-password"
            name="password"
            type="password"
            autoComplete="current-password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
          />
        </div>

        <button type="submit" disabled={loading}>
          {loading ? 'Connexion...' : 'Se connecter'}
        </button>
      </form>
    </div>
  );
}

export default Login;

Gestion des erreurs réseau

Intercepteur d'erreurs côté React

// src/services/api.js
async function handleResponse(response) {
  if (!response.ok) {
    // Tenter de lire le message d'erreur du serveur
    let errorMessage = 'Une erreur est survenue';

    try {
      const errorData = await response.json();
      errorMessage = errorData.message || errorMessage;
    } catch {
      // Le serveur n'a pas renvoyé de JSON
    }

    const error = new Error(errorMessage);
    error.status = response.status;
    throw error;
  }

  return response.json();
}

export async function getClients() {
  try {
    const response = await fetch(`${API_BASE_URL}/clients`, {
      headers: getHeaders(),
    });
    return await handleResponse(response);
  } catch (error) {
    if (error.status === 401) {
      // Rediriger vers login
      window.location.href = '/login';
    }
    throw error;
  }
}

// Hook personnalisé pour gérer les erreurs
// src/hooks/useApiError.js
import { useState } from 'react';

export function useApiError() {
  const [error, setError] = useState(null);

  function handleError(err) {
    console.error('Erreur API:', err);

    let message = 'Une erreur est survenue';

    if (err.status === 401) {
      message = 'Vous devez vous connecter';
    } else if (err.status === 403) {
      message = 'Accès refusé';
    } else if (err.status === 404) {
      message = 'Ressource non trouvée';
    } else if (err.status >= 500) {
      message = 'Erreur serveur. Veuillez réessayer plus tard.';
    } else if (err.message) {
      message = err.message;
    }

    setError(message);
  }

  function clearError() {
    setError(null);
  }

  return { error, handleError, clearError };
}

// Utilisation
function ClientsList() {
  const [clients, setClients] = useState([]);
  const { error, handleError, clearError } = useApiError();

  async function loadClients() {
    try {
      clearError();
      const data = await getClients();
      setClients(data);
    } catch (err) {
      handleError(err);
    }
  }

  return (
    <div>
      {error && <div className="error-banner">{error}</div>}
      {/* ... reste du composant */}
    </div>
  );
}

Déploiement

Option 1 : Déploiement séparé

Backend Delphi :

  • Serveur dédié (VPS, cloud)
  • URL : https://api.monapp.com
  • Port 9000 ou 443 (HTTPS)

Frontend React :

  • Hébergement statique (Netlify, Vercel, GitHub Pages)
  • URL : https://app.monapp.com
  • Appels API vers le backend

Option 2 : Déploiement combiné

Serveur unique Delphi servant :

  • API REST : /api/*
  • Fichiers statiques React : /*
// Servir les fichiers statiques React.
// 🚨 SÉCURITÉ : Cette fonction DOIT se protéger contre la traversée de
//    répertoire. Une requête comme GET /../../../etc/passwd ne doit
//    JAMAIS pouvoir lire un fichier en dehors du dossier `public`.
//    Le pattern : résoudre le chemin absolu, puis vérifier qu'il
//    commence bien par le dossier racine attendu.
procedure ServeStaticFiles(Req: THorseRequest; Res: THorseResponse; Next: TProc);  
var  
  RawPath, RootDir, FullPath: string;
begin
  RawPath := Req.PathInfo;
  if (RawPath = '') or (RawPath = '/') then
    RawPath := '/index.html';

  // Retirer le '/' initial pour que TPath.Combine ne le traite pas
  // comme un chemin absolu (sinon TPath.Combine ignore 'public').
  if RawPath.StartsWith('/') then
    RawPath := RawPath.Substring(1);

  RootDir := TPath.GetFullPath('public');
  FullPath := TPath.GetFullPath(TPath.Combine(RootDir, RawPath));

  // ⚠️ Garde-fou anti path-traversal : le chemin résolu DOIT rester
  //    sous RootDir. Sans ce test, un attaquant peut sortir du sandbox.
  if not FullPath.StartsWith(RootDir + PathDelim) then
  begin
    Res.Status(403).Send('Accès refusé');
    Exit;
  end;

  if TFile.Exists(FullPath) then
    Res.SendFile(FullPath)
  else
    Next; // Passer au handler suivant
end;

begin
  // Routes API
  THorse.Get('/api/clients', GetClients);

  // Fichiers statiques (après les routes API)
  THorse.Get('/*', ServeStaticFiles);

  THorse.Listen(9000);
end;

Bonnes pratiques

1. Variables d'environnement

Create React App (legacy, déprécié en 2023 mais encore très répandu) :

# .env
REACT_APP_API_URL=http://localhost:9000/api
const API_BASE_URL = process.env.REACT_APP_API_URL
                  || 'http://localhost:9000/api';

Vite (recommandé pour les nouveaux projets) :

# .env
VITE_API_URL=http://localhost:9000/api
const API_BASE_URL = import.meta.env.VITE_API_URL
                  || 'http://localhost:9000/api';

💡 Depuis 2023, l'équipe React recommande explicitement d'utiliser
Vite, Next.js, Remix… plutôt que Create React App pour les nouveaux
projets. CRA n'est plus maintenu.

2. Gestion du loading

function ClientsList() {
  const [loading, setLoading] = useState(false);
  const [clients, setClients] = useState([]);

  async function loadClients() {
    setLoading(true);
    try {
      const data = await getClients();
      setClients(data);
    } finally {
      setLoading(false); // Toujours arrêter le loading
    }
  }

  if (loading) {
    return <div className="spinner">Chargement...</div>;
  }

  return <div>{/* contenu */}</div>;
}

3. Debouncing pour recherche

import { useState, useEffect } from 'react';

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

// Utilisation
function SearchClients() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearch = useDebounce(searchTerm, 500);

  useEffect(() => {
    if (debouncedSearch) {
      // Effectuer la recherche seulement après 500ms d'inactivité
      searchClients(debouncedSearch);
    }
  }, [debouncedSearch]);

  return (
    <input
      type="text"
      value={searchTerm}
      onChange={(e) => setSearchTerm(e.target.value)}
      placeholder="Rechercher..."
    />
  );
}

4. Optimistic UI Updates

async function handleDelete(id) {
  // Supprimer immédiatement de l'UI
  setClients(prev => prev.filter(c => c.id !== id));

  try {
    await deleteClient(id);
  } catch (error) {
    // En cas d'erreur, restaurer
    loadClients();
    alert('Erreur lors de la suppression');
  }
}

Conclusion

L'intégration de Delphi avec des frameworks JavaScript modernes offre le meilleur des deux mondes :

Backend solide avec Delphi - Performance, fiabilité, accès données
Frontend moderne avec React/Vue/Angular - UX exceptionnelle
Architecture découplée - Évolution indépendante
Scalabilité - Chaque partie peut être optimisée séparément
Écosystème riche - Profiter des deux communautés

Cette approche est idéale pour :

  • Moderniser des applications Delphi existantes
  • Créer de nouvelles applications avec interface web moderne
  • Permettre à des développeurs JavaScript de travailler sur le frontend
  • Conserver l'expertise Delphi pour le backend critique

Dans la section suivante, nous explorerons les Progressive Web Apps (PWA), qui permettent de transformer vos applications web en applications installables fonctionnant hors ligne.

⏭️ Progressive Web Apps (PWA)