From 3e46394be1b1ea468eeaeaca5962d6733d684d47 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 29 May 2026 14:15:41 +0000 Subject: [PATCH] [ERP-72] Paginer toutes les collections API + regle pagination obligatoire (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Contexte Ticket Lesstime : [#72](https://lesstime.malio.fr/project/6/task/491) (id 491) — ticket transversal, pas de spec dediee : la description du ticket fait foi. ## Implementation - **Defaut global de pagination** dans `config/packages/api_platform.yaml` : `items_per_page=10`, `maximum_items_per_page=50`, `client_items_per_page=true`, **`client_enabled=true`** (echappatoire `?pagination=false` pour alimenter les `` ou un drawer RBAC (Role, Permission, Site, CategoryType), le front passe : + +```ts +useApi().get('/api/roles?pagination=false') +``` + +Le serveur retourne toute la collection, sans `view`. C'est l'echappatoire prevue par `pagination_client_enabled: true`. Sur les ressources a forte volumetrie, preferer une saisie assistee (recherche serveur via `?q=`) — a planifier dans un ticket dedie. + +Les tests fonctionnels qui exercent ce comportement doivent egalement passer `?pagination=false` (cf. `CategoryListTest`, `PermissionApiTest`). + +### Providers customs et pagination + +Un provider custom qui retourne un `array` brut sur une `CollectionOperationInterface` **court-circuite la pagination Hydra** (pas de `totalItems`, pas de `view`). Patterns supportes : + +- **ORM** : injecter `ApiPlatform\State\Pagination\Pagination`, wrap un `Doctrine\ORM\Tools\Pagination\Paginator` dans `ApiPlatform\Doctrine\Orm\Paginator`. Exemple : `CategoryProvider`. +- **DBAL** : implementer un paginator local conforme a `PaginatorInterface`. Exemple : `DbalPaginator` (Core) + `AuditLogProvider`. + +Gerer l'echappatoire `?pagination=false` : + +```php +if (!$this->pagination->isEnabled($operation, $context)) { + return $qb->getQuery()->getResult(); // tout retourner +} +``` + +### Garde-fou architecture + +`tests/Architecture/CollectionsArePaginatedTest` scanne reflexivement toutes les classes `#[ApiResource]` sous `src/` et echoue si une `GetCollection` pose `paginationEnabled: false` hors whitelist `EXCLUDED`. Ajouter une entree a la whitelist requiert une justification courte + un ticket Lesstime ouvert. + ## Repositories - Interface : `*RepositoryInterface` dans `Domain/Repository/` diff --git a/CLAUDE.md b/CLAUDE.md index df64645..934a27e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md 10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement. 11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison. 12. **Toujours documenter chaque colonne BDD via `COMMENT ON COLUMN`** dans la migration qui la cree ou la modifie. Description en francais, courte (≤ 200 caracteres), explique la semantique metier + contraintes implicites (unicite partielle, FK importante, lien RG). Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` echoue si une colonne `public` n'a pas de description (`col_description IS NULL`). Details et exemples : @.claude/rules/backend.md § Migrations Doctrine. +13. **Toujours paginer toute collection exposee par l'API.** Aucun retour de collection complete (pas de provider qui retourne un array brut). Standard pose dans `config/packages/api_platform.yaml` : 10 items par defaut, max 50, le client peut basculer entre 10/25/50 et peut envoyer `?pagination=false` pour alimenter un select. Garde-fou : `tests/Architecture/CollectionsArePaginatedTest` echoue si une `GetCollection` desactive la pagination sans whitelist. Details et exemples : @.claude/rules/backend.md § Pagination. ## Conventions @.claude/rules/architecture.md @@ -53,6 +54,7 @@ Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique ## A NE PAS faire - Pas de controller Symfony custom sous `/api/` sans `priority: 1` sur `#[Route]` (conflit API Platform `{id}`). +- Pas de provider API Platform qui retourne un array brut sur une `GetCollection` — court-circuite la pagination Hydra (`totalItems` / `view` absents). Utiliser `ApiPlatform\Doctrine\Orm\Paginator` (ORM) ou un paginator implementant `PaginatorInterface` (DBAL — cf. `DbalPaginator`). - Pas de `getClientMimeType()` pour valider un upload — utiliser `$file->getMimeType()` (serveur). - Pas de hardcode de la sidebar cote front, pas de `modules-loader.ts` ni `.module.ts`. - Pas d'edition manuelle de `extends` dans `frontend/nuxt.config.ts` — les layers sont scannes automatiquement. diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 26cb836..5689fb5 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -21,3 +21,18 @@ api_platform: stateless: true cache_headers: vary: ['Content-Type', 'Authorization', 'Origin'] + # === Pagination Hydra (regle projet : toute collection DOIT etre paginee) === + # Standard datatable : 10 items par defaut, choix client 10 / 25 / 50. + # Borne dure cote serveur a 50 pour prevenir tout `?itemsPerPage=999999` + # (attaque memoire / deep-fetch). Le client peut neanmoins desactiver la + # pagination via `?pagination=false` pour alimenter un . + if (!$this->pagination->isEnabled($operation, $context)) { + return $qb->getQuery()->getResult(); + } + + // Branche paginee standard : on applique offset/limit via Pagination, + // puis on enveloppe dans le Paginator ORM (fetchJoinCollection: true + // pour que Doctrine compte correctement avec les JOINs futurs). + $limit = $this->pagination->getLimit($operation, $context); + $page = max(1, $this->pagination->getPage($context)); + $offset = ($page - 1) * $limit; + + $qb->setFirstResult($offset)->setMaxResults($limit); + + return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true)); } // Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete. diff --git a/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php b/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php index 23361d3..4879894 100644 --- a/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php +++ b/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php @@ -29,18 +29,16 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogProvider; * ?performed_at[after]=2026-04-01T00:00:00Z * ?performed_at[before]=2026-04-30T23:59:59Z * - * La pagination est assuree par le provider via DbalPaginator (implementant - * ApiPlatform\State\Pagination\PaginatorInterface), ce qui genere - * automatiquement hydra:view — aucune construction manuelle. + * La pagination herite du standard global (10 items / page, max 50, cf. + * `config/packages/api_platform.yaml`). Elle est materialisee par le + * DbalPaginator du provider qui implemente PaginatorInterface — API Platform + * genere automatiquement hydra:view sans construction manuelle. */ #[ApiResource( shortName: 'AuditLog', operations: [ new GetCollection( uriTemplate: '/audit-logs', - paginationItemsPerPage: 30, - paginationClientItemsPerPage: true, - paginationMaximumItemsPerPage: 50, security: "is_granted('core.audit_log.view')", provider: AuditLogProvider::class, ), diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php index c0a4aa2..7cc3126 100644 --- a/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php @@ -68,6 +68,13 @@ final readonly class AuditLogProvider implements ProviderInterface */ private function provideCollection(Operation $operation, array $context): DbalPaginator { + // Contrairement aux ressources ORM (cf. CategoryProvider), ce provider + // ne gere PAS l'echappatoire `?pagination=false` : la pagination y est + // toujours forcee. `audit_log` est une table append-only a croissance + // infinie — la dumper entierement saturerait memoire/reseau et n'a aucun + // usage front (pas de ) et n'exerce donc plus le defaut + * paginE. On seed 11 roles de test pour depasser une page (10) et verifier + * que, sans parametre, la page est bornee a 10 et expose `view`. + */ + public function testDefaultCollectionIsPaginatedToGlobalDefault(): void + { + $em = $this->getEm(); + for ($i = 1; $i <= 11; ++$i) { + $em->persist(new Role(sprintf('test_pg_%d', $i), sprintf('Role pagination %d (test)', $i), false)); + } + $em->flush(); + $em->clear(); + + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/roles'); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + + // La page par defaut ne doit jamais depasser le maximum global (10). + self::assertLessThanOrEqual(10, count($data['member']), 'La page par defaut doit etre bornee a 10 items.'); + // 11 roles de test + 2 systeme + editor + viewer => total > 10. + self::assertGreaterThan(10, $data['totalItems']); + // `view` n'est present que lorsque la collection est reellement paginee. + self::assertArrayHasKey('view', $data, 'La collection doit exposer view quand totalItems > itemsPerPage.'); + } + public function testGetCollectionFilterByIsSystemTrue(): void { $client = $this->authenticatedClient('admin', 'admin');