Daily Actions: Relire vos données avec un Query DynamoDB derrière GET /actions

Daily Actions: Relire vos données avec un Query DynamoDB derrière GET /actions

Dans l’article précédent, nos utilisateurs ont appris à écrire : un utilisateur connecté envoie une action à POST /actions, une Lambda la stocke dans DynamoDB, et les requêtes anonymes sont rejetées. Mais si vous ajoutez une tâche puis rafraîchissez la page /todo, la liste reste vide. Rien ne relit les données.

Dans cet article, nous ajoutons la moitié manquante : un endpoint GET /actions qui renvoie uniquement les actions de l’utilisateur connecté, grâce à un Query DynamoDB.

Prérequis

Query ou Scan ?

DynamoDB propose deux façons de lire plusieurs éléments :

ScanQuery
Ce qui est luToute la table, puis filtrageUniquement les éléments partageant une clé de partition
Coût et vitesseCroît avec la taille de la tableCroît avec la taille du résultat
Notre casLirait les données de tous les utilisateursLit la partition d’un seul utilisateur

Rappelez-vous nos clés : userId est la clé de partition et actionId la clé de tri. Toutes les actions d’un utilisateur vivent dans la même partition : un seul Query sur userId renvoie exactement ce qu’il nous faut, et rien d’autre.

  sequenceDiagram
    actor U as Navigateur (utilisateur connecté)
    participant G as API Gateway
    participant C as Cognito authorizer
    participant L as Lambda list-actions
    participant D as DynamoDB

    U->>G: GET /actions + Authorization: ID token
    G->>C: Vérifier le jeton
    C-->>G: Claims (sub, email...)
    G->>L: Invoquer avec les claims
    L->>D: Query userId = sub
    D-->>L: Les éléments de l'utilisateur
    L-->>U: 200 { actions: [...] }

1. Partager l’helper de réponse

Les deux Lambdas ont besoin de la même fonction reply (corps JSON et en-tête CORS). Plutôt que de la copier, extrayez-la dans infrastructure/lambda/http.ts :

TS
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";

const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? "").split(",");

export const reply = (
  event: APIGatewayProxyEvent,
  statusCode: number,
  body: unknown,
): APIGatewayProxyResult => {
  const origin = event.headers?.origin ?? event.headers?.Origin ?? "";
  return {
    statusCode,
    headers: {
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin": ALLOWED_ORIGINS.includes(origin)
        ? origin
        : ALLOWED_ORIGINS[0],
    },
    body: JSON.stringify(body),
  };
};
Cliquez pour développer et voir plus

Dans create-action.ts, supprimez les ALLOWED_ORIGINS et reply locaux, et importez-la à la place :

TS
import { reply } from "./http";
Cliquez pour développer et voir plus

NodejsFunction empaquette chaque handler avec esbuild : le fichier partagé est donc inclus automatiquement dans les deux fonctions.

2. La fonction Lambda

Créez infrastructure/lambda/list-actions.ts :

TS
import {
  DynamoDBClient,
  QueryCommand,
  type AttributeValue,
} from "@aws-sdk/client-dynamodb";
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { reply } from "./http";

const client = new DynamoDBClient({});
const TABLE_NAME = process.env.TABLE_NAME!;

export const handler = async (
  event: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
  // L'identité vient du jeton vérifié : un utilisateur ne lit que sa partition
  const userId = event.requestContext.authorizer?.claims?.sub;
  if (!userId) {
    return reply(event, 401, { message: "Unauthorized" });
  }

  try {
    const items: Record<string, AttributeValue>[] = [];
    let lastKey: Record<string, AttributeValue> | undefined;

    // Un Query renvoie au plus 1 Mo : on suit LastEvaluatedKey jusqu'à ce qu'il soit vide
    do {
      const result = await client.send(
        new QueryCommand({
          TableName: TABLE_NAME,
          KeyConditionExpression: "userId = :userId",
          ExpressionAttributeValues: { ":userId": { S: userId } },
          ExclusiveStartKey: lastKey,
        }),
      );
      items.push(...(result.Items ?? []));
      lastKey = result.LastEvaluatedKey;
    } while (lastKey);

    // On convertit les types DynamoDB ({ S: ... }, { BOOL: ... }) en JSON simple
    const actions = items
      .map((item) => ({
        userId: item.userId.S!,
        actionId: item.actionId.S!,
        title: item.title.S!,
        done: item.done?.BOOL ?? false,
        createdAt: item.createdAt.S!,
        ...(item.dueDate?.S && { dueDate: item.dueDate.S }),
      }))
      .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); // plus récentes d'abord

    return reply(event, 200, { actions });
  } catch (error) {
    console.error("Error listing actions:", error);
    return reply(event, 500, { message: "Failed to list actions" });
  }
};
Cliquez pour développer et voir plus

Ce qui compte ici :

3. Ajouter la route dans la stack

Dans infrastructure/lib/infrastructure-stack.ts, créez la fonction après createActionFunction :

TS
const listActionsFunction = new lambdaNodejs.NodejsFunction(
  this,
  "ListActionsFunction",
  {
    entry: "lambda/list-actions.ts",
    handler: "handler",
    runtime: lambda.Runtime.NODEJS_22_X,
    environment: {
      TABLE_NAME: actionsTable.tableName,
      ALLOWED_ORIGINS: allowedOrigins.join(","),
    },
  },
);

// Lecture seule : Query suffit, pas de Scan
actionsTable.grant(listActionsFunction, "dynamodb:Query");
Cliquez pour développer et voir plus

Autorisez GET dans le preflight CORS :

TS
allowMethods: ["GET", "POST"],
Cliquez pour développer et voir plus

puis rattachez la méthode à la ressource /actions existante, avec le même authorizer :

TS
actions.addMethod(
  "GET",
  new apigateway.LambdaIntegration(listActionsFunction),
  {
    authorizer,
    authorizationType: apigateway.AuthorizationType.COGNITO,
  },
);
Cliquez pour développer et voir plus

Remarquez les permissions : create-action ne peut faire que PutItem, list-actions ne peut faire que Query. Aucune des deux ne peut supprimer, modifier ou scanner. Si l’une d’elles est un jour compromise, les dégâts se limitent à ce dont elle a besoin.

4. Afficher les actions dans Next.js

Le front-end a déjà la bonne structure : TodoServices parle à l’API, useTodoQuery l’enveloppe avec TanStack Query, et la page se contente d’afficher todos. Nous ne changeons que le chemin de lecture.

Dans app/todo/services/TodoServices.ts, extrayez la conversion du format de l’API (title / done) vers notre modèle (text / completed) dans une fonction utilisée par addTodo et fetchTodos :

TS
function toTodo(action: {
  actionId: string;
  userId: string;
  title: string;
  done: boolean;
  dueDate?: string;
  createdAt: string;
}): Todo {
  return {
    id: action.actionId,
    userId: action.userId,
    text: action.title,
    completed: action.done,
    dueDate: action.dueDate,
    createdAt: Date.parse(action.createdAt),
  };
}
Cliquez pour développer et voir plus

Remplacez ensuite fetchTodos, qui visait encore localhost:3000 :

TS
public async fetchTodos(apiUrl: string, idToken: string): Promise<Todo[]> {
  const response = await fetch(`${apiUrl}actions`, {
    headers: { Authorization: idToken },
  })
  if (!response.ok) {
    const { message } = await response
      .json()
      .catch(() => ({ message: response.statusText }))
    throw new Error(message)
  }

  const { actions } = await response.json()
  return actions.map(toTodo)
}
Cliquez pour développer et voir plus

et dans addTodo, remplacez la conversion manuelle par return toTodo(action).

Enfin, dans app/todo/services/todoQuery.ts, donnez à la requête l’URL de l’API et l’ID token. Notez aussi enabled : la requête ne part que lorsque le jeton existe.

TS
const todosQuery = useQuery({
  queryKey: ["todos"],
  queryFn: async () => todoServices.fetchTodos(apiUrl, idToken!),
  enabled: !!idToken,
});
Cliquez pour développer et voir plus

addTodoMutation appelle déjà invalidateQueries({ queryKey: ["todos"] }) en cas de succès. C’est là que cette structure paie : après l’ajout d’une tâche, TanStack Query relance automatiquement la liste via GET /actions, et la nouvelle tâche apparaît sans code supplémentaire.

5. Déployer

BASH
cd infrastructure
npm run deploy:all
Cliquez pour développer et voir plus

CDK ajoute la nouvelle fonction et la méthode GET à l’API existante. La sortie ApiUrl ne change pas.

6. Tester

Une requête anonyme doit échouer

BASH
curl -i "https://xxxx.execute-api.<region>.amazonaws.com/prod/actions"
Cliquez pour développer et voir plus

Vous devez obtenir HTTP/2 401, et aucun log pour ListActionsFunction dans CloudWatch.

Une requête connectée renvoie vos actions

Connectez-vous sur l’URL CloudFront et ouvrez /todo : les tâches créées dans l’article précédent s’affichent. Ajoutez-en une autre, elle apparaît immédiatement.

Page Todo listant les actions de l’utilisateur connecté
Ajout d’une tâche : la liste se rafraîchit via GET /actions

Pour rejouer l’appel avec curl, copiez l’ID token (DevTools → Application → Session Storage → clé commençant par oidc.user:, champ id_token) :

BASH
curl -i "https://xxxx.execute-api.<region>.amazonaws.com/prod/actions" \
  -H "Authorization: <id_token>"
Cliquez pour développer et voir plus

Attendu : HTTP/2 200 et {"actions":[...]}, les plus récentes d’abord.

Chaque utilisateur ne voit que ses données

Créez un second compte (une autre adresse e-mail), connectez-vous et ouvrez /todo : la liste est vide, alors que la table contient les éléments du premier utilisateur. Vérifiez-le :

BASH
aws dynamodb scan --table-name DailyActions
Cliquez pour développer et voir plus

La table contient les éléments des deux utilisateurs, mais chacun ne reçoit que sa propre partition.

7. Nettoyage

SH
cd infrastructure
cdk destroy
Cliquez pour développer et voir plus

Commentaires

Commencer la recherche

Saisissez des mots-clés pour rechercher des articles

↑↓
↵
ESC
⌘K Raccourci