Créer un Barman IA Sans Serveur - Partie 2 : Enregistrement des invités et mises à jour en temps réel des commandes

2026-02-04
This post cover image
#aws
#cloud
#serverless
#appsync

Ce fichier a été traduit automatiquement par IA, des erreurs peuvent survenir

Dans la Partie 1, j’ai posé les bases de l’assistant cocktail IA. Les invités peuvent parcourir le menu des boissons, je peux gérer les boissons et télécharger des images, et le système génère des prompts d’images IA via EventBridge. Mais il y a un problème : les invités ne peuvent pas encore commander quoi que ce soit.

C’est ce que nous allons construire dans cet article. J’ai besoin de trois choses pour compléter le flux de commande. Tout d’abord, les invités doivent pouvoir s’enregistrer sans créer de comptes complets ; rappelez-vous, ce projet a été conçu pour une petite fête à la maison, et demander aux gens de vérifier leurs e-mails et de définir des mots de passe est excessif. Deuxièmement, nous avons toujours besoin d’un flux de commande authentifié afin que les gens puissent soumettre et suivre leurs commandes de boissons. Troisièmement, je veux des mises à jour en temps réel afin que, lorsque je marque une boisson comme prête, les invités la voient instantanément sur leur téléphone au lieu de devoir actualiser constamment.

Alors commençons à construire !

Pourquoi ne pas simplement laisser n’importe qui commander ?

La solution la plus simple serait de rendre l’endpoint de commande complètement public, avec une clé API. N’importe qui peut soumettre des commandes sans authentification. Mais cela crée des problèmes que je ne veux pas avoir.

Sans identification des utilisateurs, je ne peux pas suivre qui a commandé quoi. Si trois personnes commandent un Negroni, comment puis-je savoir lequel est lequel lorsqu’ils sont prêts ?

D’un point de vue sécurité, un endpoint complètement ouvert est une invitation aux problèmes. N’importe qui sur Internet pourrait spammer des commandes. Même lors d’une fête à la maison, je veux un contrôle de base.

La solution doit équilibrer sécurité et commodité. Les invités ne devraient pas sauter des obstacles, mais j’ai besoin de suffisamment de contrôle pour rendre le système utilisable.

Enregistrement des invités avec des codes d’invitation

Pour les administrateurs, c’est-à-dire moi, j’utilise Amazon Cognito avec une authentification complète : e-mail, mot de passe, jetons JWT, tout ça. Cela a du sens pour la gestion du menu des boissons et la visualisation de toutes les commandes. Mais c’est un excès pour les invités de la fête.

Je voulais que ce soit aussi simple que possible, comme une invitation à une fête. L’idée était que les invités entrent un code d’enregistrement, entrent leur nom, et soient prêts à partir.

Alors, comment ai-je créé cette solution ? Avant la fête, je génère un code d’enregistrement. Ce code a une date d’expiration, valide pour le jour de la fête, et un nombre défini d’utilisations possibles, ce qui permet de créer un code valide pour le nombre attendu d’invités plus quelques supplémentaires. Je peux imprimer le code sous forme de QR code scannable, ce qui rend l’enregistrement encore plus facile. Lorsque les invités arrivent, ils scannent ou entrent le code avec le nom de leur choix. Le système valide le code, vérifie qu’il n’a pas atteint son nombre maximal d’utilisations ou qu’il n’est pas expiré, crée un compte utilisateur, incrémente le compte d’utilisations du code et retourne des jetons JWT. L’invité peut maintenant passer des commandes. Mais attends — des jetons JWT ? Je n’ai pas dit qu’il n’y avait pas de jetons JWT et d’inscription ? Prenez patience, j’y viens.

Voici comment l’enregistrement a été construit.

Image de l’architecture d’enregistrement

Le schéma de la base de données

Le système d’enregistrement ajoute deux nouvelles tables au schéma : app_users pour les invités et registration_codes pour les codes d’invitation.

CREATE TABLE cocktails.app_users (
    user_key UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    username VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_login TIMESTAMP,
    is_active BOOLEAN DEFAULT true,
    metadata TEXT DEFAULT '{}'
);

CREATE TABLE cocktails.registration_codes (
    code UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    created_by VARCHAR(255) NOT NULL,
    expires_at TIMESTAMP NOT NULL,
    max_uses INTEGER NOT NULL,
    use_count INTEGER DEFAULT 0,
    notes TEXT
);

J’ai nommé la table app_users au lieu de simplement users car cocktails.users était déjà utilisée pour l’authentification admin. Les garder séparées rend les choses beaucoup plus claires.

Le user_key est un UUID qui identifie l’invité, afin que nous puissions avoir deux « Sam » à la fête.

Les codes d’enregistrement utilisent également des UUID comme valeur code ; cela les rend « indevinables » — bon, c’est peut-être une exagération, mais difficiles à deviner de toute façon. Chaque code a une limite max_uses définie au moment de la création, permettant au même code d’enregistrer plusieurs invités. Le use_count suit le nombre de fois où le code a été utilisé.

Création des codes d’enregistrement

Moi, en tant qu’admin, je crée des codes d’enregistrement avant la fête via un endpoint admin. La fonction Lambda génère un UUID et définit l’heure d’expiration fournie.

def handler(event, context):
    """Admin endpoint - Create registration code."""
    body = json.loads(event["body"])
    expires_at = datetime.fromisoformat(body["expires_at"])
    max_uses = body.get("max_uses", 1)
    
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                """INSERT INTO cocktails.registration_codes
                   (created_by, expires_at, max_uses, notes)
                   VALUES (%s, %s, %s, %s)
                   RETURNING code, expires_at, max_uses""",
                [event["requestContext"]["authorizer"]["userId"], 
                 expires_at, max_uses, body.get("notes", "")]
            )
            result = cur.fetchone()
        conn.commit()
    
    return response(201, {
        "code": str(result["code"]),
        "expires_at": result["expires_at"].isoformat(),
        "max_uses": result["max_uses"]
    })

Le code généré ressemble à ceci : f47ac10b-58cc-4372-a567-0e02b2c3d479. Maintenant, je peux le partager avec les invités comme je veux : message texte, e-mail ou imprimé sous forme de QR code. Pour une fête à la maison avec 20 invités, je pourrais créer un code avec max_uses: 25 pour tenir compte des invités supplémentaires. Pour un événement plus important, je pourrais créer plusieurs codes par table ou section.

Le flux d’enregistrement

Lorsqu’un invité s’enregistre, le système vérifie le code fourni et crée le compte. La partie intéressante ici est que cette validation se produit en deux étapes différentes. Elle commence par un autorisateur Lambda qui s’exécute avant même que la requête n’atteigne la logique principale, puis l’endpoint lui-même vérifie tout à nouveau. Le travail principal de l’autorisateur est de s’assurer que le code est réel et qu’il n’a pas encore atteint sa limite d’utilisation.

def handler(event, context):
    """Registration code Lambda authorizer."""
    code = event["headers"].get("X-Registration-Code", "")
    if not code:
        return generate_policy("unknown", "Deny", event["methodArn"])
    
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                """SELECT code FROM cocktails.registration_codes
                   WHERE code = %s AND use_count < max_uses AND expires_at > NOW()""",
                [code]
            )
            if not cur.fetchone():
                return generate_policy("unknown", "Deny", event["methodArn"])
    
    return generate_policy(code, "Allow", event["methodArn"],
                          context={"registrationCode": code})

Comme l’autorisateur fonctionne au niveau d’API Gateway, un mauvais code n’atteindra même pas le Lambda d’enregistrement. Cette configuration est efficace car elle empêche l’exécution du Lambda d’enregistrement pour des inscriptions factices, évitant les inondations. Après cette vérification initiale, l’endpoint d’enregistrement termine le travail en créant l’utilisateur dans la table et en augmentant le compte d’utilisation du code.

def handler(event, context):
    """POST /register - Create new guest user."""
    code = event["requestContext"]["authorizer"]["registrationCode"]
    username = json.loads(event["body"]).get("username", "").strip()
    
    # Validate username
    if not username or len(username) < 2 or len(username) > 50:
        return response(400, {"error": "Username must be 2-50 characters"})
    
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                """INSERT INTO cocktails.app_users (username)
                   VALUES (%s) RETURNING user_key, username""",
                [username]
            )
            user = cur.fetchone()
            
            # Increment code usage
            cur.execute(
                "UPDATE cocktails.registration_codes SET use_count = use_count + 1 WHERE code = %s",
                [code]
            )
        conn.commit()
    
    return response(201, {
        "user": {"user_key": str(user["user_key"]), "username": user["username"]},
        "access_token": generate_access_token(user),
        "refresh_token": generate_refresh_token(user)
    })

L’endpoint d’enregistrement est idempotent de manière importante. Si l’autorisateur passe, nous savons que le code est valide et qu’il n’a pas atteint sa limite. Si le Lambda échoue à mi-chemin à cause d’un timeout ou d’une erreur, le compte d’utilisation n’est pas incrémenté, donc l’invité peut réessayer. Une fois que le Lambda valide la transaction, le compte d’utilisation augmente. Lorsque use_count atteint max_uses, l’autorisateur rejettera les futures tentatives d’enregistrement avec ce code.

Jetons JWT auto-signés

Maintenant, revenons aux jetons JWT — je vous avais dit que nous y revenions.

Après un enregistrement réussi, les invités reçoivent des jetons JWT. Mais contrairement au flux admin, qui utilise des jetons émis par Cognito, les jetons des invités sont auto-signés. Je les génère moi-même à l’aide de paires de clés RSA stockées dans AWS Secrets Manager.

Pourquoi auto-signés au lieu de Cognito ?

Cognito est un excellent service, mais il est conçu pour des utilisateurs de longue durée. Si je l’utilisais ici, je devrais créer des comptes de pool d’utilisateurs, gérer des mots de passe et gérer les vérifications par e-mail. C’est beaucoup de surcharge pour un invité qui n’est là que pour quelques heures.

Choisir des JWT auto-signés me donne la flexibilité de contrôler exactement ce qui se trouve dans le jeton et combien de temps il reste actif. Les invités n’ont pas besoin de choses comme l’authentification multifacteur ou la réinitialisation des mots de passe. Ils ont juste besoin d’une façon de prouver qu’ils se sont inscrits pour pouvoir commander pendant la soirée.

Le piège est que je dois gérer la validation moi-même. Je dois gérer une paire de clés RSA et écrire la logique de vérification dans un autorisateur Lambda. Cependant, le processus n’est pas si compliqué, et j’apprécie d’avoir ce niveau de contrôle sur l’ensemble du flux.

Création des jetons d’accès et de rafraîchissement

Le Lambda d’enregistrement génère deux jetons : un jeton d’accès et un jeton de rafraîchissement.

def generate_access_token(user: dict) -> str:
    """Generate RS256 signed JWT access token."""
    payload = {
        "token_type": "access",
        "user_key": str(user["user_key"]),
        "username": user["username"],
        "exp": int(time.time()) + (4 * 60 * 60)  # 4 heures
    }
    return jwt.encode(payload, get_private_key_from_secrets_manager(), algorithm="RS256")

def generate_refresh_token(user: dict) -> str:
    """Generate cryptographically random refresh token."""
    token = secrets.token_urlsafe(32)
    token_hash = hashlib.sha256(token.encode()).hexdigest()
    
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                """INSERT INTO cocktails.refresh_tokens (user_key, token_hash, expires_at)
                   VALUES (%s, %s, %s)""",
                [user["user_key"], token_hash, datetime.utcnow() + timedelta(days=2)]
            )
        conn.commit()
    return token

Le jeton d’accès est un JWT avec une expiration de 4 heures. Quatre heures suffisent pour une fête mais sont suffisamment courtes pour que les jetons volés ne durent pas éternellement. La charge utile inclut user_key et username afin que les fonctions Lambda en aval sachent qui fait les requêtes.

Le jeton de rafraîchissement est différent. Ce n’est pas un JWT, juste une chaîne cryptographique aléatoire. Je stocke le hachage SHA-256 dans la base de données avec son expiration, 2 jours. Cela permet aux invités de maintenir leur session active lors de plusieurs réouvertures de l’application sans avoir à se réinscrire.

La table des jetons de rafraîchissement

Les jetons de rafraîchissement sont stockés séparément des utilisateurs.

CREATE TABLE cocktails.refresh_tokens (
    token_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_key UUID NOT NULL,
    token_hash VARCHAR(64) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP NOT NULL,
    last_used_at TIMESTAMP,
    is_revoked BOOLEAN DEFAULT false,
    revoked_at TIMESTAMP,
    device_info TEXT
);

Pourquoi hacher le jeton de rafraîchissement au lieu de le stocker directement ? Parce que les jetons de rafraîchissement sont de longue durée — si on peut appeler 2 jours de longue durée. Si quelqu’un accède à la base de données, il pourrait utiliser les jetons stockés pour usurper les utilisateurs. En hachant, nous nous assurons que même avec un accès à la base de données, l’attaquant ne peut pas reconstruire les jetons originaux.

Le champ is_revoked me permet d’invalider les jetons si nécessaire. Le champ device_info stocke des métadonnées comme l’agent utilisateur du navigateur, utile pour le débogage ou pour montrer aux utilisateurs où ils sont connectés.

Endpoint de rafraîchissement des jetons

Lorsque le jeton d’accès expire, le frontend appelle l’endpoint de rafraîchissement avec le jeton de rafraîchissement.

def handler(event, context):
    """POST /auth/refresh - Exchange refresh token for new access token."""
    refresh_token = json.loads(event["body"]).get("refresh_token", "")
    if not refresh_token:
        return response(400, {"error": "refresh_token required"})
    
    token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
    
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                """SELECT rt.user_key, u.username FROM cocktails.refresh_tokens rt
                   JOIN cocktails.app_users u ON rt.user_key = u.user_key
                   WHERE rt.token_hash = %s AND NOT rt.is_revoked AND rt.expires_at > NOW()""",
                [token_hash]
            )
            token_record = cur.fetchone()
            if not token_record:
                return response(401, {"error": "Invalid or expired refresh token"})
            
            cur.execute(
                "UPDATE cocktails.refresh_tokens SET last_used_at = NOW() WHERE token_hash = %s",
                [token_hash]
            )
        conn.commit()
    
    return response(200, {
        "access_token": generate_access_token({
            "user_key": token_record["user_key"],
            "username": token_record["username"]
        })
    })

Ce modèle est un rafraîchissement de jeton standard de style OAuth2. Le jeton de rafraîchissement ne expire jamais tant qu’il est utilisé périodiquement. Le jeton d’accès expire fréquemment mais peut être renouvelé sans réauthentification.

L’autorisateur utilisateur

Les endpoints de commande nécessitent une authentification. Le Lambda autorisateur utilisateur valide les jetons d’accès JWT auto-signés.

def handler(event, context):
    """User JWT Lambda authorizer - validates self-signed tokens."""
    try:
        token = extract_bearer_token(event)
        claims = jwt.decode(
            token,
            get_public_key_from_secrets_manager(),
            algorithms=["RS256"],
            options={"verify_signature": True, "verify_exp": True}
        )
        
        if claims.get("token_type") != "access":
            return generate_policy("unknown", "Deny", event["methodArn"])
        
        return generate_policy(
            claims["user_key"], "Allow", event["methodArn"],
            context={"userId": claims["user_key"], "username": claims["username"]}
        )
    except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
        return generate_policy("unknown", "Deny", event["methodArn"])

Comme je l’ai dit, vous pouvez voir, ce n’est pas si complexe. L’autorisateur Lambda, invoqué par API Gateway, charge la clé publique depuis Secrets Manager, vérifie la signature du jeton avec la bibliothèque PyJWT et vérifie l’expiration. Si tout passe, il extrait user_key et username et les passe à la fonction Lambda en aval via le contexte de l’autorisateur.

Les fonctions Lambda en aval n’ont pas besoin de parser les jetons JWT elles-mêmes. Le contexte est disponible à event["requestContext"]["authorizer"].

Mise en cache de la clé publique

Une optimisation à noter est la mise en cache de la clé publique. La charger depuis Secrets Manager à chaque requête serait lent et coûteux. À la place, je la mise en cache dans une variable globale.

_jwt_public_key = None

def get_public_key_from_secrets_manager() -> str:
    """Get JWT public key from Secrets Manager with caching."""
    global _jwt_public_key
    
    if _jwt_public_key is not None:
        return _jwt_public_key
    
    client = boto3.client("secretsmanager")
    response = client.get_secret_value(SecretId="drink-assistant/jwt-keys")
    secret = json.loads(response["SecretString"])
    _jwt_public_key = secret["public_key"]
    
    return _jwt_public_key

Les environnements d’exécution Lambda sont réutilisés entre les invocations. La première invocation charge la clé et la met en cache. Les invocations suivantes dans le même environnement d’exécution réutilisent la clé mise en cache. Cela réduit les appels API à Secrets Manager et améliore la latence dans de nombreux cas — ce n’est pas parfait mais c’est suffisant.

Passer des commandes

Avec l’enregistrement et l’authentification terminés, les invités peuvent maintenant passer des commandes. L’endpoint de commande est protégé par l’autorisateur utilisateur.

La table des commandes

Les commandes sont stockées dans la table cocktails.orders.

CREATE TABLE cocktails.orders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    drink_id UUID NOT NULL,
    user_key UUID,
    status VARCHAR(50) NOT NULL DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    completed_at TIMESTAMP
);

Le user_key fait référence à app_users.user_key. Le champ status suit le cycle de vie de la commande : pending, preparing, ready, ou served. Le timestamp completed_at est défini lorsque le statut atteint served.

Création d’une commande

Le Lambda de création de commande est simple. Il vérifie que la boisson est disponible — je ne veux pas que les invités commandent un Negroni si je n’ai plus de Campari. Ensuite, il crée l’enregistrement de commande et retourne les détails de la commande.

def handler(event, context):
    """POST /orders - Create new order for authenticated user."""
    user_key = event["requestContext"]["authorizer"]["userId"]
    drink_id = json.loads(event["body"]).get("drink_id")
    
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                "SELECT id, name FROM cocktails.drinks WHERE id = %s AND is_active = true",
                [drink_id]
            )
            drink = cur.fetchone()
            if not drink:
                return response(404, {"error": "Drink not found"})
            
            cur.execute(
                """INSERT INTO cocktails.orders (drink_id, user_key, status)
                   VALUES (%s, %s, 'pending') RETURNING id, status, created_at""",
                [drink_id, user_key]
            )
            order = cur.fetchone()
        conn.commit()
    
    publish_order_created(user_key, event["requestContext"]["authorizer"]["username"], 
                         order, drink["name"])
    
    return response(201, {
        "order": {
            "id": str(order["id"]),
            "drink_name": drink["name"],
            "status": order["status"],
            "created_at": order["created_at"].isoformat()
        }
    })

Liste des commandes utilisateur

Les invités peuvent également récupérer leur historique de commandes complet.

def handler(event, context):
    """GET /orders - List orders for authenticated user."""
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                """SELECT o.id, d.name AS drink_name, o.status, o.created_at
                   FROM cocktails.orders o JOIN cocktails.drinks d ON o.drink_id = d.id
                   WHERE o.user_key = %s ORDER BY o.created_at DESC""",
                [event["requestContext"]["authorizer"]["userId"]]
            )
            return response(200, {"orders": cur.fetchall()})

La requête joint orders avec drinks pour inclure le nom de la boisson dans la réponse. Les invités voient « Negroni - en attente » au lieu d’un UUID.

Mises à jour de statut de commande

C’est la partie qui m’excite beaucoup et l’un des éléments clés de cette partie. Pendant que je construisais l’application, j’ai réalisé qu’il y avait un problème potentiel qui créerait une mauvaise expérience utilisateur. Si vous passez une commande et n’avez aucun moyen de connaître l’avancement, vous finissez par venir me déranger pour demander votre boisson. Ce genre de vérification manuelle déjoue complètement l’utilité de l’application.

Je savais que j’avais besoin d’une forme de système en temps réel depuis le début. Au moment où je marque une boisson comme prête de mon côté, l’application sur le téléphone de l’invité doit se mettre à jour et notifier. Pas de sondage et pas de bouton de rafraîchissement nécessaire.

Les mises à jour en temps réel sont généralement juste pub/sous

Lorsque les gens parlent de « mises à jour en temps réel », ce qu’ils veulent généralement dire d’un point de vue technique est un modèle Pub/Sub (Publish/Subscribe). C’est exactement pourquoi j’ai choisi AWS AppSync Events — c’est un service pub/sous puissant qui me permettra de pousser des mises à jour aux utilisateurs.

Dans une application web standard, le client doit demander des données (requête/réponse). Dans une configuration pub/sous, le client s’abonne à un sujet spécifique, comme orders/{user_id}. Ensuite, chaque fois qu’un changement se produit sur le serveur, le backend publie un message sur ce sujet. Le courtier pub/sous (dans notre cas, AppSync Events) gère le gros du travail pour déterminer qui écoute et pousser ce message instantanément vers eux.

AppSync Events vs EventBridge

Dans la Partie 1, j’ai utilisé Amazon EventBridge pour les flux d’événements comme la génération de prompts d’images IA. EventBridge est un excellent service lorsqu’on construit des applications pilotées par les événements. Mais EventBridge ne pousse pas d’événements vers les applications clientes.

Pour implémenter une configuration pub/sous client, j’aurai besoin d’utiliser des WebSockets sous une forme ou une autre. La manière la plus simple que j’ai trouvée pour obtenir une configuration pub/sous sur les WebSockets est d’utiliser AppSync Events — ne vous laissez pas tromper par le nom AppSync. C’est très différent de l’API AppSync, qui est une implémentation gérée d’API GraphQL. C’est un peu malheureux, je pense, que l’implémentation pub/sous soit également arrivée sous AppSync.

AppSync Events gère les connexions, les abonnements et la diffusion automatiquement. Je publie simplement des événements depuis Lambda, et AppSync les route vers les clients abonnés.

Concepts d’AppSync Events

AppSync Events est construit autour de trois concepts.

Les canaux sont comme des sujets dans un système pub/sous. Les clients s’abonnent à des canaux pour recevoir des événements. Dans mon cas, chaque utilisateur s’abonne à orders/{user_key} pour obtenir des mises à jour sur ses commandes.

Les espaces de noms de canaux regroupent des canaux liés. Je crée un espace de noms appelé orders, et les canaux sont créés dynamiquement en son sein. Lorsqu’un utilisateur s’abonne à orders/abc-123, AppSync crée automatiquement ce canal s’il n’existe pas.

Les événements sont des messages publiés sur des canaux. Lorsque je mets à jour le statut d’une commande, je publie un événement sur le canal de cet utilisateur. AppSync le diffuse à tous les clients connectés abonnés à ce canal.

Aperçu de la conception pub/sous

La configuration pub/sous avec AppSync Events ressemble à ceci.

Image de l’architecture d’enregistrement

Configuration d’AppSync Events

CloudFormation crée une API AppSync Events avec des espaces de noms de canaux.

Resources:
  EventsApi:
    Type: AWS::AppSync::Api
    Properties:
      Name: !Sub "${AWS::StackName}-events-api"
      EventConfig:
        AuthProviders:
          - AuthType: API_KEY
        ConnectionAuthModes:
          - AuthType: API_KEY
        DefaultPublishAuthModes:
          - AuthType: API_KEY
        DefaultSubscribeAuthModes:
          - AuthType: API_KEY

  EventsApiKey:
    Type: AWS::AppSync::ApiKey
    Properties:
      ApiId: !GetAtt EventsApi.ApiId
      Description: API Key for AppSync Events
      Expires: 1767225600  # Far future expiration

  OrdersChannelNamespace:
    Type: AWS::AppSync::ChannelNamespace
    Properties:
      ApiId: !GetAtt EventsApi.ApiId
      Name: orders
      SubscribeAuthModes:
        - AuthType: API_KEY
      PublishAuthModes:
        - AuthType: API_KEY

  AdminChannelNamespace:
    Type: AWS::AppSync::ChannelNamespace
    Properties:
      ApiId: !GetAtt EventsApi.ApiId
      Name: admin
      SubscribeAuthModes:
        - AuthType: API_KEY
      PublishAuthModes:
        - AuthType: API_KEY

J’utilise l’authentification par clé API pour plus de simplicité. En production, vous utiliseriez Cognito ou IAM pour vous assurer que les utilisateurs ne peuvent s’abonner qu’à leurs propres canaux. Mais pour une fête à la maison, une clé API intégrée dans le frontend est suffisante.

Je crée deux espaces de noms de canaux. L’espace de noms orders est pour les mises à jour de commande spécifiques aux utilisateurs. Les invités s’abonnent à orders/{their_user_key}. L’espace de noms admin est pour les mises à jour du tableau de bord admin. Je m’abonne à admin/new-orders pour voir quand de nouvelles commandes arrivent.

Publication d’événements depuis Lambda

Lorsque le statut d’une commande change, le Lambda publie un événement à AppSync.

def publish_order_update(user_key: str, order: dict, drink_name: str):
    """Publish order status update to user's AppSync Events channel."""
    try:
        appsync_client.post(
            action="publish",
            apiId=os.environ["APPSYNC_API_ID"],
            payload=json.dumps({
                "channel": f"orders/{user_key}",
                "events": [json.dumps({
                    "type": "ORDER_STATUS_CHANGED",
                    "order_id": str(order["id"]),
                    "drink_name": drink_name,
                    "status": order["status"],
                    "updated_at": order["updated_at"].isoformat()
                })]
            })
        )
    except Exception as e:
        logger.error(f"Failed to publish: {e}")

La publication est du type « feu et oublie ». Si elle échoue, je journalise l’erreur mais je ne fais pas échouer toute la mise à jour de commande. Le statut de commande est enregistré dans la base de données quoi qu’il arrive. Le pire cas est que l’invité ne reçoit pas une notification en temps réel et doit actualiser manuellement.

Publication d’événements de nouvelles commandes vers le tableau de bord admin

Lorsqu’un invité crée une commande, je publie également sur le canal admin pour que je sois immédiatement informé.

def publish_order_created(user_key: str, username: str, order: dict, drink_name: str):
    """Publish new order event to admin channel."""
    try:
        appsync_client.post(
            action="publish",
            apiId=os.environ["APPSYNC_API_ID"],
            payload=json.dumps({
                "channel": "admin/new-orders",
                "events": [json.dumps({
                    "type": "NEW_ORDER",
                    "order_id": str(order["id"]),
                    "user_key": user_key,
                    "username": username,
                    "drink_name": drink_name,
                    "status": "pending",
                    "created_at": order["created_at"].isoformat()
                })]
            })
        )
    except Exception as e:
        logger.error(f"Failed to publish: {e}")

Maintenant, l’application invité et le tableau de bord admin reçoivent des mises à jour en temps réel. Les invités voient le statut de leur commande changer. Je vois les nouvelles commandes apparaître instantanément.

Abonnement depuis le frontend

Sur le frontend, j’utilise la bibliothèque AWS Amplify pour m’abonner aux canaux AppSync Events.

Tout d’abord, configurez Amplify avec les détails de l’API AppSync Events.

import { Amplify } from 'aws-amplify';

Amplify.configure({
  API: {
    Events: {
      endpoint: 'https://abc123.appsync-api.us-east-1.amazonaws.com/event',
      region: 'us-east-1',
      defaultAuthMode: 'apiKey',
      apiKey: 'da2-xxxxxxxxxxxxxxxxxxxxxxxxxx'
    }
  }
});

Ensuite, abonnez-vous au canal de commande de l’utilisateur.

import { events } from '@aws-amplify/api';

function subscribeToOrderUpdates(userKey: string) {
  const channel = events.channel(`orders/${userKey}`);
  
  const subscription = channel.subscribe({
    next: (event) => {
      console.log('Order update received:', event);
      
      // Update UI based on event type
      if (event.type === 'ORDER_STATUS_CHANGED') {
        updateOrderInUI(event.order_id, event.status);
        
        if (event.status === 'ready') {
          showNotification(`Your ${event.drink_name} is ready!`);
        }
      }
    },
    error: (error) => {
      console.error('AppSync Events error:', error);
    }
  });
  
  // Return unsubscribe function
  return () => subscription.unsubscribe();
}

L’abonnement au canal utilise WebSockets sous le capot. Lorsque le Lambda publie un événement, AppSync le pousse à tous les clients connectés abonnés à ce canal. Le rappel next est déclenché immédiatement lorsque les événements arrivent.

Crochet React pour les commandes en temps réel

J’ai enveloppé la logique d’abonnement dans un crochet React qui gère le cycle de vie de la connexion.

import { useEffect, useState } from 'react';
import { events } from '@aws-amplify/api';

interface Order {
  id: string;
  drink_id: string;
  drink_name: string;
  status: 'pending' | 'preparing' | 'ready' | 'served';
  created_at: string;
  updated_at: string;
}

export function useRealtimeOrders(userKey: string) {
  const [orders, setOrders] = useState<Order[]>([]);
  const [connected, setConnected] = useState(false);

  useEffect(() => {
    if (!userKey) return;

    const channel = events.channel(`orders/${userKey}`);

    const subscription = channel.subscribe({
      next: (event) => {
        if (event.type === 'ORDER_STATUS_CHANGED') {
          // Update the specific order in state
          setOrders((prevOrders) => 
            prevOrders.map((order) =>
              order.id === event.order_id
                ? { ...order, status: event.status, updated_at: event.updated_at }
                : order
            )
          );

          // Show notification for ready orders
          if (event.status === 'ready') {
            new Notification(`${event.drink_name} is ready!`);
          }
        }
      },
      error: (error) => {
        console.error('Subscription error:', error);
        setConnected(false);
      }
    });

    setConnected(true);

    // Cleanup on unmount
    return () => {
      subscription.unsubscribe();
      setConnected(false);
    };
  }, [userKey]);

  return { orders, setOrders, connected };
}

Utiliser le crochet dans un composant est simple.

function OrdersPage({ userKey }: { userKey: string }) {
  const { orders, setOrders, connected } = useRealtimeOrders(userKey);

  // Fetch initial orders on mount
  useEffect(() => {
    async function loadOrders() {
      const response = await fetch('/api/orders', {
        headers: { 'Authorization': `Bearer ${accessToken}` }
      });
      const data = await response.json();
      setOrders(data.orders);
    }
    loadOrders();
  }, [userKey, setOrders]);

  return (
    <div>
      <div className="connection-status">
        {connected ? '🟢 Live' : '🔴 Offline'}
      </div>
      {orders.map(order => (
        <OrderCard key={order.id} order={order} />
      ))}
    </div>
  );
}

Le crochet gère automatiquement le cycle de vie de l’abonnement. Lorsque le composant monte, il s’abonne. Lorsqu’il se démonte, il se désabonne. L’état connected montre aux utilisateurs s’ils reçoivent des mises à jour en direct.

Abonnement du tableau de bord admin

Le tableau de bord admin s’abonne au canal admin/new-orders.

function AdminDashboard() {
  const [pendingOrders, setPendingOrders] = useState<Order[]>([]);

  useEffect(() => {
    const subscription = events.channel('admin/new-orders').subscribe({
      next: (event) => {
        if (event.type === 'NEW_ORDER') {
          setPendingOrders(prev => [{
            id: event.order_id,
            drink_name: event.drink_name,
            username: event.username,
            status: 'pending',
            created_at: event.created_at
          }, ...prev]);
        }
      }
    });
    return () => subscription.unsubscribe();
  }, []);

  // Render pending orders...
}

Les nouvelles commandes apparaissent instantanément sans sondage.

Reconnexion et fiabilité

AppSync Events gère la reconnexion automatiquement. Si un client perd la connectivité, AppSync essaie de se reconnecter. Lorsque la connexion est rétablie, l’abonnement reprend.

Mais voici une mise en garde importante : les événements publiés pendant que le client était déconnecté sont perdus. AppSync Events est éphémère. Il ne met pas en file d’attente les messages ni ne réécoute les événements manqués. Pour cette raison, je traite la base de données comme la source de vérité et AppSync comme la couche de vitesse.

Les mises à jour en temps réel sont là pour rendre l’UI réactive et réactive pendant que l’invité utilise activement l’application. Si la connexion tombe, la bibliothèque Amplify gère automatiquement la logique de reconnexion. Cependant, comme nous savons que les messages ne sont pas mis en file d’attente, l’application doit avoir un moyen de rattraper.

Chaque fois que l’application se remet d’un état hors connexion ou que l’invité actualise manuellement, le frontend doit déclencher une requête GET rapide /orders vers l’API REST. Cela garantit que même s’ils ont manqué une notification en direct, l’écran sera toujours synchronisé avec l’état réel de la base de données.

C’est une approche « le meilleur des deux mondes ». Vous obtenez le facteur « wow » instantané d’une mise à jour en direct lorsque tout fonctionne parfaitement, mais vous avez la fiabilité d’une base de données traditionnelle pour vous rabattre si le Wi-Fi à la fête devient un peu irrégulier.

Et après ?

Les bases et le flux de commande sont terminés. Les invités peuvent parcourir les boissons, s’enregistrer avec des codes d’invitation, passer des commandes et obtenir des mises à jour en temps réel lorsque leurs boissons sont prêtes. Je peux gérer le menu, voir les commandes et mettre à jour le statut des commandes. Tout fonctionne, et tout fonctionne bien.

Mais la pièce IA est toujours manquante. Dans la Partie 3, j’ajouterai les fonctionnalités qui font de cela un barman IA au lieu d’un simple menu numérique. Recommandations de boissons IA basées sur les préférences de goût. Commande conversationnelle via une interface de chat. Génération d’images pour des photos de boissons personnalisées utilisant Amazon Bedrock Nova Canvas. Tout intégré avec les bases que nous avons construites.

La Partie 3 arrive bientôt. Suivez-moi sur LinkedIn pour ne pas la manquer.

Derniers mots

Les mises à jour en temps réel étaient autrefois difficiles. WebSockets, gestion des connexions, diffusion, mise à l’échelle. Maintenant, AppSync Events s’en charge. Vous publiez depuis Lambda, les clients s’abonnent, et AppSync route les événements. C’est tout.

Le modèle JWT auto-signé est tout aussi simple. Générer une paire de clés RSA, signer des jetons dans un Lambda, les vérifier dans un autre. Pas de surcharge Cognito pour les utilisateurs temporaires, juste de la cryptographie simple.

Le résultat est un système de commande complet qui semble rapide et réactif. Les invités s’enregistrent en quelques secondes, passent des commandes en un seul tapotement et sont notifiés au moment où leur boisson est prête. Pas de sondage, pas d’actualisation manuelle, pas de friction.

Le code complet est sur GitHub. Consultez mes autres articles sur jimmydqv.com pour plus de modèles sans serveur.

Maintenant, allez construire !