feat: debut du domain Shared avec Contracts et Jobs + rules pour cursor
This commit is contained in:
parent
19a697c712
commit
ca9a74fe69
@@ -3,19 +3,16 @@ description:
|
|||||||
globs:
|
globs:
|
||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
# API Platform dans Mangarr
|
|
||||||
|
|
||||||
## Structure de l'API
|
|
||||||
L'API est organisée dans la couche Infrastructure de chaque domaine :
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Domain/Manga/Infrastructure/ApiPlatform/
|
Domain/Manga/Infrastructure/ApiPlatform/
|
||||||
├── Resource/ # Configuration des ressources API
|
├── Resource/ # Resources API par opération
|
||||||
│ └── MangaResource.php
|
│ └── GetMangaResource.php # Resources pour l'opération Get
|
||||||
├── State/ # Providers et Processors
|
│ └── CreateMangaResource.php # Resources pour l'opération Create
|
||||||
├── Provider/ # State Providers
|
├── State/ # Providers et Processors par opération
|
||||||
└── Processor/ # State Processors
|
├── Provider/ # State Providers
|
||||||
|
│ └── GetMangaStateProvider.php
|
||||||
|
└── Processor/ # State Processors
|
||||||
|
└── CreateMangaStateProcessor.php
|
||||||
```
|
```
|
||||||
|
|
||||||
## Règles d'Organisation
|
## Règles d'Organisation
|
||||||
@@ -23,18 +20,30 @@ Domain/Manga/Infrastructure/ApiPlatform/
|
|||||||
### 1. Resources
|
### 1. Resources
|
||||||
- Localisation : `Infrastructure/ApiPlatform/Resource/`
|
- Localisation : `Infrastructure/ApiPlatform/Resource/`
|
||||||
- Principes :
|
- Principes :
|
||||||
- Une classe = une ressource API
|
- Une Resource par Operation
|
||||||
|
- Validation des données avec les attributs Symfony dans la Resource
|
||||||
- Documentation exhaustive avec les attributs PHP 8
|
- Documentation exhaustive avec les attributs PHP 8
|
||||||
- Validation contraintes avec les attributs Symfony
|
- Nommage : `{Operation}Resource`
|
||||||
- Nommage : `{Nom}Resource`
|
- Contient tous les attributs nécessaires en public
|
||||||
|
- Doit implémenter les interfaces de validation appropriées
|
||||||
|
|
||||||
### 2. State Providers/Processors
|
### 2. State Providers
|
||||||
- Localisation : `Infrastructure/ApiPlatform/State/`
|
- Localisation : `Infrastructure/ApiPlatform/State/Provider/`
|
||||||
- Principes :
|
- Principes :
|
||||||
- Utiliser les cas d'utilisation du domaine (Commands/Queries)
|
- Un Provider par Operation de type Query
|
||||||
- Ne pas contenir de logique métier
|
- Utilise les QueryHandler du domaine
|
||||||
- Conversion Resource ↔ Command/Query
|
- Convertit la Response du QueryHandler en Resource
|
||||||
- Nommage : `{Action}{Resource}StateProvider/Processor`
|
- Renvoie toujours une Resource
|
||||||
|
- Nommage : `{Operation}StateProvider`
|
||||||
|
|
||||||
|
### 3. State Processors
|
||||||
|
- Localisation : `Infrastructure/ApiPlatform/State/Processor/`
|
||||||
|
- Principes :
|
||||||
|
- Un Processor par Operation de type Command
|
||||||
|
- Utilise les CommandHandler du domaine
|
||||||
|
- Convertit la Resource en Command
|
||||||
|
- Renvoie uniquement un code HTTP
|
||||||
|
- Nommage : `{Operation}StateProcessor`
|
||||||
|
|
||||||
## Exemples de Code
|
## Exemples de Code
|
||||||
|
|
||||||
@@ -44,9 +53,7 @@ namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
use ApiPlatform\Metadata\Post;
|
|
||||||
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\GetMangaStateProvider;
|
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\GetMangaStateProvider;
|
||||||
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\CreateMangaStateProcessor;
|
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
@@ -55,84 +62,44 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
new Get(
|
new Get(
|
||||||
uriTemplate: '/mangas/{id}',
|
uriTemplate: '/mangas/{id}',
|
||||||
provider: GetMangaStateProvider::class,
|
provider: GetMangaStateProvider::class,
|
||||||
description: 'Récupère un manga par son identifiant',
|
output: GetMangaResource::class,
|
||||||
openapi: [
|
description: 'Récupère un manga par son identifiant'
|
||||||
'summary' => 'Récupère un manga',
|
|
||||||
'200' => [
|
|
||||||
'description' => 'Manga trouvé',
|
|
||||||
'content' => [
|
|
||||||
'application/json' => [
|
|
||||||
'schema' => [
|
|
||||||
'type' => 'object',
|
|
||||||
'properties' => [
|
|
||||||
'id' => ['type' => 'string', 'format' => 'uuid'],
|
|
||||||
'title' => ['type' => 'string'],
|
|
||||||
'description' => ['type' => 'string', 'nullable' => true],
|
|
||||||
'authors' => [
|
|
||||||
'type' => 'array',
|
|
||||||
'items' => ['type' => 'string']
|
|
||||||
],
|
|
||||||
'coverUrl' => ['type' => 'string', 'format' => 'uri', 'nullable' => true]
|
|
||||||
],
|
|
||||||
'required' => ['id', 'title', 'authors']
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
],
|
|
||||||
'404' => [
|
|
||||||
'description' => 'Manga non trouvé'
|
|
||||||
]
|
|
||||||
]
|
|
||||||
),
|
|
||||||
new Post(
|
|
||||||
uriTemplate: '/mangas',
|
|
||||||
processor: CreateMangaStateProcessor::class,
|
|
||||||
description: 'Crée un nouveau manga',
|
|
||||||
openapi: [
|
|
||||||
'requestBody' => [
|
|
||||||
'content' => [
|
|
||||||
'application/json' => [
|
|
||||||
'schema' => [
|
|
||||||
'type' => 'object',
|
|
||||||
'properties' => [
|
|
||||||
'title' => ['type' => 'string'],
|
|
||||||
'description' => ['type' => 'string', 'nullable' => true],
|
|
||||||
'authors' => [
|
|
||||||
'type' => 'array',
|
|
||||||
'items' => ['type' => 'string']
|
|
||||||
],
|
|
||||||
'coverUrl' => ['type' => 'string', 'format' => 'uri', 'nullable' => true]
|
|
||||||
],
|
|
||||||
'required' => ['title']
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
],
|
|
||||||
'responses' => [
|
|
||||||
'201' => [
|
|
||||||
'description' => 'Manga créé'
|
|
||||||
],
|
|
||||||
'400' => [
|
|
||||||
'description' => 'Données invalides'
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
class MangaResource
|
class GetMangaResource
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
#[Assert\Uuid]
|
||||||
|
public readonly string $id,
|
||||||
|
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
public readonly string $title,
|
||||||
|
|
||||||
|
public readonly ?string $description = null,
|
||||||
|
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
#[Assert\All([
|
||||||
|
new Assert\Type('string')
|
||||||
|
])]
|
||||||
|
public readonly array $authors = [],
|
||||||
|
|
||||||
|
#[Assert\Url]
|
||||||
|
public readonly ?string $coverUrl = null
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. State Provider
|
### 2. State Provider
|
||||||
```php
|
```php
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource\CreateManga;
|
||||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider;
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\Domain\Manga\Application\Query\GetMangaByIdQuery;
|
use App\Domain\Manga\Application\Query\GetMangaByIdQuery;
|
||||||
use App\Domain\Shared\Contract\Response;
|
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\GetMangaResource;
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
class GetMangaStateProvider implements ProviderInterface
|
class GetMangaStateProvider implements ProviderInterface
|
||||||
@@ -141,7 +108,7 @@ class GetMangaStateProvider implements ProviderInterface
|
|||||||
private readonly MessageBusInterface $queryBus
|
private readonly MessageBusInterface $queryBus
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?Response
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?GetMangaResource
|
||||||
{
|
{
|
||||||
$query = new GetMangaByIdQuery($uriVariables['id']);
|
$query = new GetMangaByIdQuery($uriVariables['id']);
|
||||||
$response = $this->queryBus->dispatch($query);
|
$response = $this->queryBus->dispatch($query);
|
||||||
@@ -150,18 +117,72 @@ class GetMangaStateProvider implements ProviderInterface
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $response;
|
return new GetMangaResource(
|
||||||
|
id: $response->id,
|
||||||
|
title: $response->title,
|
||||||
|
description: $response->description,
|
||||||
|
authors: $response->authors,
|
||||||
|
coverUrl: $response->coverUrl
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. State Processor
|
### 3. Resource CreateManga
|
||||||
|
```php
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\CreateMangaStateProcessor;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'Manga',
|
||||||
|
operations: [
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/mangas',
|
||||||
|
processor: CreateMangaStateProcessor::class,
|
||||||
|
input: CreateMangaResource::class,
|
||||||
|
status: 201,
|
||||||
|
description: 'Crée un nouveau manga'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
class CreateMangaResource
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Assert\NotBlank(message: 'Le titre est obligatoire')]
|
||||||
|
#[Assert\Length(min: 1, max: 255)]
|
||||||
|
public readonly string $title,
|
||||||
|
|
||||||
|
#[Assert\Length(max: 1000)]
|
||||||
|
public readonly ?string $description = null,
|
||||||
|
|
||||||
|
#[Assert\NotNull]
|
||||||
|
#[Assert\Count(min: 1, max: 10)]
|
||||||
|
#[Assert\All([
|
||||||
|
new Assert\Type('string'),
|
||||||
|
new Assert\Length(min: 1, max: 100)
|
||||||
|
])]
|
||||||
|
public readonly array $authors = [],
|
||||||
|
|
||||||
|
#[Assert\Url]
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
public readonly ?string $coverUrl = null
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. State Processor
|
||||||
```php
|
```php
|
||||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\Domain\Manga\Application\Command\CreateMangaCommand;
|
use App\Domain\Manga\Application\Command\CreateMangaCommand;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\CreateMangaResource;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
class CreateMangaStateProcessor implements ProcessorInterface
|
class CreateMangaStateProcessor implements ProcessorInterface
|
||||||
@@ -170,9 +191,9 @@ class CreateMangaStateProcessor implements ProcessorInterface
|
|||||||
private readonly MessageBusInterface $commandBus
|
private readonly MessageBusInterface $commandBus
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): int
|
||||||
{
|
{
|
||||||
assert($data instanceof MangaResource);
|
assert($data instanceof CreateMangaResource);
|
||||||
|
|
||||||
$command = new CreateMangaCommand(
|
$command = new CreateMangaCommand(
|
||||||
title: $data->title,
|
title: $data->title,
|
||||||
@@ -182,6 +203,8 @@ class CreateMangaStateProcessor implements ProcessorInterface
|
|||||||
);
|
);
|
||||||
|
|
||||||
$this->commandBus->dispatch($command);
|
$this->commandBus->dispatch($command);
|
||||||
|
|
||||||
|
return Response::HTTP_CREATED;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -195,19 +218,7 @@ class CreateMangaStateProcessor implements ProcessorInterface
|
|||||||
- Documentation des codes d'erreur
|
- Documentation des codes d'erreur
|
||||||
|
|
||||||
### 2. Validation
|
### 2. Validation
|
||||||
- Validation stricte des entrées
|
- Validation dans les Resources uniquement
|
||||||
- Groupes de validation par contexte
|
- Groupes de validation par contexte
|
||||||
- Messages d'erreur explicites
|
- Messages d'erreur explicites
|
||||||
- Validation des types et formats
|
- Validation des types et formats
|
||||||
|
|
||||||
### 3. Sécurité
|
|
||||||
- Définition claire des accès
|
|
||||||
- Validation des permissions
|
|
||||||
- Sanitization des entrées
|
|
||||||
- Gestion des erreurs sécurisée
|
|
||||||
|
|
||||||
### 4. Performance
|
|
||||||
- Pagination par défaut
|
|
||||||
- Sélection des champs (sparse fieldsets)
|
|
||||||
- Gestion des includes (relationships)
|
|
||||||
- Cache approprié
|
|
||||||
163
.cursor/rules/jobs.mdc
Normal file
163
.cursor/rules/jobs.mdc
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
---
|
||||||
|
description: need to create or find a job
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# Architecture des Jobs dans Mangarr
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
Le système de jobs de Mangarr est conçu pour gérer les tâches asynchrones et de longue durée de manière uniforme à travers tous les domaines. Il est basé sur une architecture centralisée dans le domaine `Shared` et peut être étendu par chaque domaine spécifique.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/Domain/Shared/
|
||||||
|
├── Domain/
|
||||||
|
│ ├── Model/
|
||||||
|
│ │ ├── Job.php # Classe abstraite de base
|
||||||
|
│ │ ├── JobStatus.php # États possibles d'un job
|
||||||
|
│ │ └── FailedJob.php # Représentation d'un job échoué
|
||||||
|
│ ├── Contract/
|
||||||
|
│ │ ├── JobRepositoryInterface.php
|
||||||
|
│ │ └── FailedJobRepositoryInterface.php
|
||||||
|
│ └── Exception/
|
||||||
|
│ ├── JobNotFoundException.php
|
||||||
|
│ └── JobNotRetryableException.php
|
||||||
|
└── Infrastructure/
|
||||||
|
├── Persistence/
|
||||||
|
│ ├── Entity/
|
||||||
|
│ │ ├── JobEntity.php
|
||||||
|
│ │ └── FailedJobEntity.php
|
||||||
|
│ └── Repository/
|
||||||
|
│ ├── DoctrineJobRepository.php
|
||||||
|
│ └── DoctrineFailedJobRepository.php
|
||||||
|
└── Service/
|
||||||
|
└── JobRetryService.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cycle de Vie d'un Job
|
||||||
|
|
||||||
|
### États Possibles
|
||||||
|
```php
|
||||||
|
enum JobStatus: string
|
||||||
|
{
|
||||||
|
case PENDING = 'pending'; // Job créé, en attente d'exécution
|
||||||
|
case IN_PROGRESS = 'in_progress';// Job en cours d'exécution
|
||||||
|
case COMPLETED = 'completed'; // Job terminé avec succès
|
||||||
|
case FAILED = 'failed'; // Job échoué définitivement
|
||||||
|
case CANCELLED = 'cancelled'; // Job annulé manuellement
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transitions d'États
|
||||||
|
1. `PENDING` → `IN_PROGRESS` : Lors du démarrage du job
|
||||||
|
2. `IN_PROGRESS` → `COMPLETED` : Lorsque le job se termine avec succès
|
||||||
|
3. `IN_PROGRESS` → `FAILED` : Lorsque le job échoue et atteint le nombre maximum de tentatives
|
||||||
|
4. `IN_PROGRESS` → `PENDING` : Lorsque le job échoue mais peut être réessayé
|
||||||
|
5. Tout état → `CANCELLED` : Lorsque le job est annulé manuellement
|
||||||
|
|
||||||
|
## Création d'un Nouveau Type de Job
|
||||||
|
|
||||||
|
1. **Créer une classe de job spécifique**
|
||||||
|
```php
|
||||||
|
class MyCustomJob extends Job
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $id,
|
||||||
|
public readonly string $someData,
|
||||||
|
public readonly array $additionalData = []
|
||||||
|
) {
|
||||||
|
parent::__construct($id, 'my_custom_job');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Définir le Handler**
|
||||||
|
```php
|
||||||
|
class MyCustomJobHandler
|
||||||
|
{
|
||||||
|
public function __invoke(MyCustomJob $job): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$job->start();
|
||||||
|
// Logique métier
|
||||||
|
$job->complete();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$job->fail($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gestion des Échecs
|
||||||
|
|
||||||
|
### Retry Automatique
|
||||||
|
- Un job peut être réessayé tant que `$attempts < $maxAttempts`
|
||||||
|
- Lors d'un échec, si des tentatives sont encore possibles, le statut redevient `PENDING`
|
||||||
|
- Les informations d'échec sont conservées dans `FailedJob`
|
||||||
|
|
||||||
|
### Informations de Debug
|
||||||
|
Chaque job contient :
|
||||||
|
- `failureReason` : La raison de l'échec
|
||||||
|
- `attempts` : Nombre de tentatives effectuées
|
||||||
|
- `context` : Données contextuelles pour le debug
|
||||||
|
- `createdAt`, `startedAt`, `completedAt` : Timestamps pour le suivi
|
||||||
|
|
||||||
|
## Bonnes Pratiques
|
||||||
|
|
||||||
|
### 1. Création de Jobs
|
||||||
|
```php
|
||||||
|
$job = new MyCustomJob(
|
||||||
|
id: Uuid::v4(),
|
||||||
|
someData: 'data',
|
||||||
|
additionalData: ['key' => 'value']
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Gestion du Contexte
|
||||||
|
```php
|
||||||
|
$job->context['important_info'] = 'value';
|
||||||
|
$job->context['debug_data'] = $debugInfo;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Retry Manuel
|
||||||
|
```php
|
||||||
|
if ($failedJob->canBeRetried()) {
|
||||||
|
$job->attempts = 0;
|
||||||
|
$job->status = JobStatus::PENDING;
|
||||||
|
$jobRepository->save($job);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Monitoring
|
||||||
|
- Utiliser `findByStatus()` pour surveiller les jobs par état
|
||||||
|
- Utiliser `findFailedJobs()` pour vérifier les échecs
|
||||||
|
- Consulter `FailedJob` pour les détails des échecs
|
||||||
|
|
||||||
|
## Règles Importantes
|
||||||
|
|
||||||
|
1. **Idempotence**
|
||||||
|
- Les jobs doivent être idempotents
|
||||||
|
- Gérer les cas de réexécution
|
||||||
|
- Vérifier l'état avant les opérations
|
||||||
|
|
||||||
|
2. **Contexte**
|
||||||
|
- Toujours fournir un contexte utile
|
||||||
|
- Inclure les IDs des entités concernées
|
||||||
|
- Ajouter des informations de debug pertinentes
|
||||||
|
|
||||||
|
3. **Durée**
|
||||||
|
- Les jobs doivent être de longue durée
|
||||||
|
- Pour les opérations courtes, utiliser des appels directs
|
||||||
|
- Prévoir des timeouts appropriés
|
||||||
|
|
||||||
|
4. **Statut**
|
||||||
|
- Ne jamais modifier le statut directement
|
||||||
|
- Utiliser les méthodes `start()`, `complete()`, `fail()`, `cancel()`
|
||||||
|
- Toujours sauvegarder après un changement de statut
|
||||||
|
|
||||||
|
5. **Échecs**
|
||||||
|
- Capturer et logger toutes les exceptions
|
||||||
|
- Fournir des messages d'erreur explicites
|
||||||
|
- Conserver le contexte d'échec pour le debug
|
||||||
302
.cursor/rules/persistence.mdc
Normal file
302
.cursor/rules/persistence.mdc
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
# Persistence dans l'Architecture Hexagonale
|
||||||
|
|
||||||
|
## Structure de la Persistence
|
||||||
|
|
||||||
|
```
|
||||||
|
Domain/Manga/
|
||||||
|
└── Infrastructure/
|
||||||
|
└── Persistence/
|
||||||
|
├── Repository/ # Implémentations des repositories
|
||||||
|
│ └── DoctrineMangaRepository.php
|
||||||
|
├── Entity/ # Entités Doctrine
|
||||||
|
│ └── MangaEntity.php
|
||||||
|
└── Mapper/ # Mappers Domain <-> Entity
|
||||||
|
└── MangaMapper.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Règles d'Organisation
|
||||||
|
|
||||||
|
### 1. Repositories
|
||||||
|
- Localisation : `Infrastructure/Persistence/Repository/`
|
||||||
|
- Principes :
|
||||||
|
- Un repository par agrégat du domaine
|
||||||
|
- Implémente l'interface du domaine
|
||||||
|
- Utilise un mapper dédié
|
||||||
|
- Gère uniquement la persistence
|
||||||
|
- Pas de logique métier
|
||||||
|
- Nommage : `Doctrine{Aggregate}Repository`
|
||||||
|
|
||||||
|
### 2. Entités
|
||||||
|
- Localisation : `Infrastructure/Persistence/Entity/`
|
||||||
|
- Principes :
|
||||||
|
- Une entité par agrégat du domaine
|
||||||
|
- Uniquement des getters/setters
|
||||||
|
- Pas de logique métier
|
||||||
|
- Nommage : `{Aggregate}Entity`
|
||||||
|
- Suffixe `Entity` obligatoire pour éviter la confusion avec les modèles du domaine
|
||||||
|
|
||||||
|
### 3. Mappers
|
||||||
|
- Localisation : `Infrastructure/Persistence/Mapper/`
|
||||||
|
- Principes :
|
||||||
|
- Un mapper par agrégat
|
||||||
|
- Conversion bidirectionnelle Domain <-> Entity
|
||||||
|
- Gestion des Value Objects
|
||||||
|
- Nommage : `{Aggregate}Mapper`
|
||||||
|
|
||||||
|
## Exemples de Code
|
||||||
|
|
||||||
|
### 1. Query et Repository
|
||||||
|
```php
|
||||||
|
namespace App\Domain\Manga\Application\Query;
|
||||||
|
|
||||||
|
readonly class GetMangaListQuery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $page = 1,
|
||||||
|
public int $limit = 20,
|
||||||
|
public string $sortBy = 'title',
|
||||||
|
public string $sortOrder = 'asc',
|
||||||
|
public ?string $search = null,
|
||||||
|
public array $genres = []
|
||||||
|
) {
|
||||||
|
if ($this->page < 1) {
|
||||||
|
throw new \InvalidArgumentException('Page must be greater than 0');
|
||||||
|
}
|
||||||
|
if ($this->limit < 1) {
|
||||||
|
throw new \InvalidArgumentException('Limit must be greater than 0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOffset(): int
|
||||||
|
{
|
||||||
|
return ($this->page - 1) * $this->limit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Domain\Repository;
|
||||||
|
|
||||||
|
interface MangaRepositoryInterface
|
||||||
|
{
|
||||||
|
public function findByQuery(GetMangaListQuery $query): array;
|
||||||
|
public function count(GetMangaListQuery $query): int;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\Persistence\Repository;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Model\Manga;
|
||||||
|
use App\Domain\Manga\Domain\Repository\MangaRepositoryInterface;
|
||||||
|
use App\Domain\Manga\Infrastructure\Persistence\Entity\MangaEntity;
|
||||||
|
use App\Domain\Manga\Infrastructure\Persistence\Mapper\MangaMapper;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
readonly class DoctrineMangaRepository implements MangaRepositoryInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
private MangaMapper $mapper
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function findByQuery(GetMangaListQuery $query): array
|
||||||
|
{
|
||||||
|
$qb = $this->entityManager->createQueryBuilder()
|
||||||
|
->select('m')
|
||||||
|
->from(MangaEntity::class, 'm');
|
||||||
|
|
||||||
|
if ($query->search) {
|
||||||
|
$qb->andWhere('m.title LIKE :search')
|
||||||
|
->setParameter('search', '%' . $query->search . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($query->genres)) {
|
||||||
|
$qb->andWhere('m.genres && :genres')
|
||||||
|
->setParameter('genres', $query->genres);
|
||||||
|
}
|
||||||
|
|
||||||
|
$qb->orderBy('m.' . $query->sortBy, $query->sortOrder)
|
||||||
|
->setFirstResult($query->getOffset())
|
||||||
|
->setMaxResults($query->limit);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
fn (MangaEntity $entity) => $this->mapper->toDomain($entity),
|
||||||
|
$qb->getQuery()->getResult()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function count(GetMangaListQuery $query): int
|
||||||
|
{
|
||||||
|
$qb = $this->entityManager->createQueryBuilder()
|
||||||
|
->select('COUNT(m.id)')
|
||||||
|
->from(MangaEntity::class, 'm');
|
||||||
|
|
||||||
|
if ($query->search) {
|
||||||
|
$qb->andWhere('m.title LIKE :search')
|
||||||
|
->setParameter('search', '%' . $query->search . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($query->genres)) {
|
||||||
|
$qb->andWhere('m.genres && :genres')
|
||||||
|
->setParameter('genres', $query->genres);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qb->getQuery()->getSingleScalarResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(string $id): ?Manga
|
||||||
|
{
|
||||||
|
$entity = $this->entityManager->find(MangaEntity::class, $id);
|
||||||
|
|
||||||
|
return $entity ? $this->mapper->toDomain($entity) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Manga $manga): void
|
||||||
|
{
|
||||||
|
$entity = $this->mapper->toEntity($manga);
|
||||||
|
|
||||||
|
$this->entityManager->persist($entity);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
// Met à jour l'ID du modèle du domaine si nécessaire
|
||||||
|
if ($entity->getId() && $manga->getId() === null) {
|
||||||
|
$manga->updateId($entity->getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Entity
|
||||||
|
```php
|
||||||
|
namespace App\Domain\Manga\Infrastructure\Persistence\Entity;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'manga')]
|
||||||
|
class MangaEntity
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private string $title;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $description = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $authors = [];
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $coverUrl = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private \DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
public function getId(): ?int { return $this->id; }
|
||||||
|
public function getTitle(): string { return $this->title; }
|
||||||
|
public function getDescription(): ?string { return $this->description; }
|
||||||
|
public function getAuthors(): array { return $this->authors; }
|
||||||
|
public function getCoverUrl(): ?string { return $this->coverUrl; }
|
||||||
|
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
|
||||||
|
|
||||||
|
// Setters (fluent interface)
|
||||||
|
public function setTitle(string $title): self
|
||||||
|
{
|
||||||
|
$this->title = $title;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDescription(?string $description): self
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... autres setters
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Mapper
|
||||||
|
```php
|
||||||
|
namespace App\Domain\Manga\Infrastructure\Persistence\Mapper;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Model\Manga;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\Title;
|
||||||
|
use App\Domain\Manga\Infrastructure\Persistence\Entity\MangaEntity;
|
||||||
|
|
||||||
|
readonly class MangaMapper
|
||||||
|
{
|
||||||
|
public function toDomain(MangaEntity $entity): Manga
|
||||||
|
{
|
||||||
|
return new Manga(
|
||||||
|
id: new MangaId((string) $entity->getId()),
|
||||||
|
title: new Title($entity->getTitle()),
|
||||||
|
description: $entity->getDescription(),
|
||||||
|
authors: $entity->getAuthors(),
|
||||||
|
coverUrl: $entity->getCoverUrl(),
|
||||||
|
createdAt: $entity->getCreatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toEntity(Manga $manga): MangaEntity
|
||||||
|
{
|
||||||
|
$entity = new MangaEntity();
|
||||||
|
|
||||||
|
$entity->setTitle($manga->getTitle()->value())
|
||||||
|
->setDescription($manga->getDescription())
|
||||||
|
->setAuthors($manga->getAuthors())
|
||||||
|
->setCoverUrl($manga->getCoverUrl());
|
||||||
|
|
||||||
|
return $entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bonnes Pratiques
|
||||||
|
|
||||||
|
### 1. Gestion des Erreurs
|
||||||
|
- Convertir les exceptions Doctrine en exceptions du domaine
|
||||||
|
- Ne pas exposer les détails de l'infrastructure
|
||||||
|
- Gérer les cas d'erreur spécifiques (contraintes uniques, etc.)
|
||||||
|
|
||||||
|
```php
|
||||||
|
namespace App\Domain\Manga\Infrastructure\Persistence\Exception;
|
||||||
|
|
||||||
|
class PersistenceException extends \RuntimeException
|
||||||
|
{
|
||||||
|
public static function entityNotFound(string $id): self
|
||||||
|
{
|
||||||
|
return new self(sprintf('Entity with id %s not found', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function uniqueConstraintViolation(string $field): self
|
||||||
|
{
|
||||||
|
return new self(sprintf('Entity with %s already exists', $field));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Performance
|
||||||
|
- Utiliser les bonnes stratégies de chargement (EAGER vs LAZY)
|
||||||
|
- Optimiser les requêtes avec des QueryBuilder
|
||||||
|
- Paginer les résultats
|
||||||
|
- Utiliser le cache quand nécessaire
|
||||||
|
|
||||||
|
### 3. Tests
|
||||||
|
- Créer des repositories In-Memory pour les tests
|
||||||
|
- Utiliser SQLite en mémoire pour les tests d'intégration
|
||||||
|
- Tester les cas d'erreur
|
||||||
|
- Vérifier les contraintes de base de données
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Domain\Shared\Contract;
|
namespace App\Domain\Shared\Domain\Contract;
|
||||||
|
|
||||||
interface CommandHandlerInterface
|
interface CommandHandlerInterface
|
||||||
{
|
{
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Domain\Shared\Contract;
|
namespace App\Domain\Shared\Domain\Contract;
|
||||||
|
|
||||||
interface CommandInterface
|
interface CommandInterface
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Shared\Domain\Contract;
|
||||||
|
|
||||||
|
use App\Domain\Shared\Domain\Model\FailedJob;
|
||||||
|
|
||||||
|
interface FailedJobRepositoryInterface
|
||||||
|
{
|
||||||
|
public function save(FailedJob $failedJob): void;
|
||||||
|
public function get(string $id): ?FailedJob;
|
||||||
|
public function delete(string $id): void;
|
||||||
|
public function findAll(): array;
|
||||||
|
public function findByJobType(string $jobType): array;
|
||||||
|
public function findByJobId(string $jobId): array;
|
||||||
|
public function findRetryableJobs(): array;
|
||||||
|
}
|
||||||
17
src/Domain/Shared/Domain/Contract/JobRepositoryInterface.php
Normal file
17
src/Domain/Shared/Domain/Contract/JobRepositoryInterface.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Shared\Domain\Contract;
|
||||||
|
|
||||||
|
use App\Domain\Shared\Domain\Model\Job;
|
||||||
|
use App\Domain\Shared\Domain\Model\JobStatus;
|
||||||
|
|
||||||
|
interface JobRepositoryInterface
|
||||||
|
{
|
||||||
|
public function save(Job $job): void;
|
||||||
|
public function get(string $id): ?Job;
|
||||||
|
public function findByStatus(JobStatus $status): array;
|
||||||
|
public function findByType(string $type): array;
|
||||||
|
public function findPendingJobs(): array;
|
||||||
|
public function findInProgressJobs(): array;
|
||||||
|
public function findFailedJobs(): array;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Domain\Shared\Contract;
|
namespace App\Domain\Shared\Domain\Contract;
|
||||||
|
|
||||||
interface QueryHandlerInterface
|
interface QueryHandlerInterface
|
||||||
{
|
{
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Domain\Shared\Contract;
|
namespace App\Domain\Shared\Domain\Contract;
|
||||||
|
|
||||||
interface QueryInterface
|
interface QueryInterface
|
||||||
{
|
{
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Domain\Shared\Contract;
|
namespace App\Domain\Shared\Domain\Contract;
|
||||||
|
|
||||||
interface ResponseInterface
|
interface ResponseInterface
|
||||||
{
|
{
|
||||||
16
src/Domain/Shared/Domain/Exception/JobNotFoundException.php
Normal file
16
src/Domain/Shared/Domain/Exception/JobNotFoundException.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Shared\Domain\Exception;
|
||||||
|
|
||||||
|
class JobNotFoundException extends \DomainException
|
||||||
|
{
|
||||||
|
public static function withId(string $id): self
|
||||||
|
{
|
||||||
|
return new self(sprintf('Job with id "%s" not found', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function withJobId(string $jobId): self
|
||||||
|
{
|
||||||
|
return new self(sprintf('Failed job with job id "%s" not found', $jobId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Shared\Domain\Exception;
|
||||||
|
|
||||||
|
class JobNotRetryableException extends \DomainException
|
||||||
|
{
|
||||||
|
public static function maxAttemptsReached(string $jobId, int $maxAttempts): self
|
||||||
|
{
|
||||||
|
return new self(sprintf('Job "%s" has reached its maximum number of attempts (%d)', $jobId, $maxAttempts));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function notFailed(string $jobId): self
|
||||||
|
{
|
||||||
|
return new self(sprintf('Cannot retry job "%s" because it is not in failed status', $jobId));
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/Domain/Shared/Domain/Model/FailedJob.php
Normal file
29
src/Domain/Shared/Domain/Model/FailedJob.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Shared\Domain\Model;
|
||||||
|
|
||||||
|
class FailedJob
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $id,
|
||||||
|
public readonly string $jobId,
|
||||||
|
public readonly string $jobType,
|
||||||
|
public readonly string $failureReason,
|
||||||
|
public readonly array $context,
|
||||||
|
public readonly \DateTimeImmutable $failedAt,
|
||||||
|
public readonly int $attempt
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromJob(Job $job): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
id: uniqid('failed_', true),
|
||||||
|
jobId: $job->id,
|
||||||
|
jobType: $job->type,
|
||||||
|
failureReason: $job->failureReason ?? 'Unknown error',
|
||||||
|
context: $job->context,
|
||||||
|
failedAt: new \DateTimeImmutable(),
|
||||||
|
attempt: $job->attempts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/Domain/Shared/Domain/Model/Job.php
Normal file
56
src/Domain/Shared/Domain/Model/Job.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Shared\Domain\Model;
|
||||||
|
|
||||||
|
abstract class Job
|
||||||
|
{
|
||||||
|
public JobStatus $status;
|
||||||
|
public \DateTimeImmutable $createdAt;
|
||||||
|
public ?\DateTimeImmutable $startedAt = null;
|
||||||
|
public ?\DateTimeImmutable $completedAt = null;
|
||||||
|
public ?string $failureReason = null;
|
||||||
|
public int $attempts = 0;
|
||||||
|
public int $maxAttempts = 3;
|
||||||
|
public array $context = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $id,
|
||||||
|
public readonly string $type
|
||||||
|
) {
|
||||||
|
$this->status = JobStatus::PENDING;
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(): void
|
||||||
|
{
|
||||||
|
$this->status = JobStatus::IN_PROGRESS;
|
||||||
|
$this->startedAt = new \DateTimeImmutable();
|
||||||
|
$this->attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(): void
|
||||||
|
{
|
||||||
|
$this->status = JobStatus::COMPLETED;
|
||||||
|
$this->completedAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fail(string $reason): void
|
||||||
|
{
|
||||||
|
$this->failureReason = $reason;
|
||||||
|
$this->status = $this->attempts >= $this->maxAttempts
|
||||||
|
? JobStatus::FAILED
|
||||||
|
: JobStatus::PENDING;
|
||||||
|
$this->completedAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(): void
|
||||||
|
{
|
||||||
|
$this->status = JobStatus::CANCELLED;
|
||||||
|
$this->completedAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canBeRetried(): bool
|
||||||
|
{
|
||||||
|
return $this->status === JobStatus::FAILED && $this->attempts < $this->maxAttempts;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/Domain/Shared/Domain/Model/JobStatus.php
Normal file
12
src/Domain/Shared/Domain/Model/JobStatus.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Shared\Domain\Model;
|
||||||
|
|
||||||
|
enum JobStatus: string
|
||||||
|
{
|
||||||
|
case PENDING = 'pending';
|
||||||
|
case IN_PROGRESS = 'in_progress';
|
||||||
|
case COMPLETED = 'completed';
|
||||||
|
case FAILED = 'failed';
|
||||||
|
case CANCELLED = 'cancelled';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user