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.
Important
Cet article fait suite à la série Daily Actions. Partez du projet tel que nous l’avons laissé dans l’article précédent (branche feat/create-action-lambda).
Objectif du tutoriel
- Écrire une fonction Lambda qui lit les actions d’un utilisateur avec un
Query. - L’exposer en
GET /actionsderrière le même authorizer Cognito. - Partager l’helper de réponse entre nos deux Lambdas.
- Afficher les actions dans la page
/todode Next.js. - Vérifier que chaque utilisateur ne voit que ses propres données.
Prérequis
- Avoir lu Daily Actions: Enregistrer des données dans DynamoDB via une API Gateway et une Lambda sécurisées par Cognito
- Jeter un œil à How to Design a High-Performance DynamoDB Table pour comprendre le choix de nos clés.
Query ou Scan ?
DynamoDB propose deux façons de lire plusieurs éléments :
Scan | Query | |
|---|---|---|
| Ce qui est lu | Toute la table, puis filtrage | Uniquement les éléments partageant une clé de partition |
| Coût et vitesse | Croît avec la taille de la table | Croît avec la taille du résultat |
| Notre cas | Lirait les données de tous les utilisateurs | Lit 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 :
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),
};
};Dans create-action.ts, supprimez les ALLOWED_ORIGINS et reply locaux, et importez-la à la place :
import { reply } from "./http";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 :
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" });
}
};Ce qui compte ici :
- Le
userIdde la condition de clé vient du jeton, exactement comme dans l’endpoint d’écriture. Il n’y a aucun paramètre d’URL à falsifier : un utilisateur ne peut pas demander la partition de quelqu’un d’autre. KeyConditionExpressioncible la clé de partition.:userIdest un paramètre dont la valeur est passée séparément, avec son type DynamoDB ({ S: ... }).- La pagination. Un seul appel
Queryrenvoie au plus 1 Mo de données. S’il y en a davantage, DynamoDB renseigneLastEvaluatedKey, que l’on repasse enExclusiveStartKeypour obtenir la page suivante. L’ignorer fonctionne dans un tutoriel et perd silencieusement des données en production. - Le tri. Les éléments reviennent ordonnés par la clé de tri (
actionId), un UUID aléatoire : cet ordre ne signifie rien pour un utilisateur. Nous trions donc parcreatedAtdans la Lambda, du plus récent au plus ancien.
Note
Trier dans la Lambda convient pour une liste de tâches personnelle. Pour de très grandes listes, on choisirait une clé de tri qui donne déjà l’ordre voulu, par exemple un identifiant ordonné dans le temps, et DynamoDB trierait gratuitement. C’est la réflexion sur les patterns d’accès de l’article sur le design de table.
3. Ajouter la route dans la stack
Dans infrastructure/lib/infrastructure-stack.ts, créez la fonction après createActionFunction :
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");Autorisez GET dans le preflight CORS :
allowMethods: ["GET", "POST"],puis rattachez la méthode à la ressource /actions existante, avec le même authorizer :
actions.addMethod(
"GET",
new apigateway.LambdaIntegration(listActionsFunction),
{
authorizer,
authorizationType: apigateway.AuthorizationType.COGNITO,
},
);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 :
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),
};
}Remplacez ensuite fetchTodos, qui visait encore localhost:3000 :
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)
}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.
const todosQuery = useQuery({
queryKey: ["todos"],
queryFn: async () => todoServices.fetchTodos(apiUrl, idToken!),
enabled: !!idToken,
});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.
Note
updateTodo et deleteTodo visent encore l’ancien backend. Cocher une tâche ou la supprimer ne fonctionne donc pas encore : ce sera le sujet du prochain article.
5. Déployer
cd infrastructure
npm run deploy:allCDK 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
curl -i "https://xxxx.execute-api.<region>.amazonaws.com/prod/actions"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.

Pour rejouer l’appel avec curl, copiez l’ID token (DevTools → Application → Session Storage → clé commençant par oidc.user:, champ id_token) :
curl -i "https://xxxx.execute-api.<region>.amazonaws.com/prod/actions" \
-H "Authorization: <id_token>"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 :
aws dynamodb scan --table-name DailyActionsLa table contient les éléments des deux utilisateurs, mais chacun ne reçoit que sa propre partition.
7. Nettoyage
cd infrastructure
cdk destroyConclusion
Dans cet article, nous avons :
- Lu les données d’un utilisateur avec un
QueryDynamoDB plutôt qu’unScan - Géré la pagination avec
LastEvaluatedKey - Exposé
GET /actionsderrière le même authorizer Cognito - Conservé le moindre privilège : une fonction ne peut qu’écrire, l’autre ne peut qu’interroger
- Partagé l’helper de réponse entre les Lambdas
- Affiché les actions dans Next.js, avec une liste qui se rafraîchit après chaque ajout
- Projet GitLab (branche feat/list-actions-lambda)
Note
Nos utilisateurs peuvent maintenant enregistrer et relire leurs actions. L’étape suivante complète le CRUD : PUT /actions/{id} pour cocher une tâche et DELETE /actions/{id} pour la supprimer, en vérifiant que l’élément appartient bien à l’appelant.

Commentaires