feat: ajout de la gestion des jobs avec création, récupération et filtrage via l'API, incluant des entités et des mappers pour les échecs et les jobs

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-03-29 15:15:14 +01:00
parent d7088b14c2
commit d7ccc1e603
33 changed files with 1113 additions and 595 deletions

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Application\Query;
use App\Domain\Shared\Domain\Contract\QueryInterface;
use App\Domain\Shared\Domain\Model\JobStatus;
readonly class ListJobsQuery implements QueryInterface
{
public function __construct(
public ?JobStatus $status = null,
public ?string $type = null,
public ?\DateTimeImmutable $createdAfter = null,
public ?\DateTimeImmutable $createdBefore = null,
public ?int $page = 1,
public ?int $limit = 20,
public ?string $sortBy = 'createdAt',
public ?string $sortOrder = 'DESC'
) {
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;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Application\QueryHandler;
use App\Domain\Shared\Application\Query\ListJobsQuery;
use App\Domain\Shared\Application\Response\JobListResponse;
use App\Domain\Shared\Domain\Contract\QueryHandlerInterface;
use App\Domain\Shared\Domain\Contract\QueryInterface;
use App\Domain\Shared\Domain\Contract\ResponseInterface;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
readonly class ListJobsQueryHandler implements QueryHandlerInterface
{
public function __construct(
private JobRepositoryInterface $jobRepository
) {}
public function handle(QueryInterface $query): ResponseInterface
{
if (!$query instanceof ListJobsQuery) {
throw new \InvalidArgumentException(sprintf(
'Query must be instance of %s, %s given',
ListJobsQuery::class,
get_class($query)
));
}
$criteria = [
'status' => $query->status,
'type' => $query->type,
'createdAfter' => $query->createdAfter,
'createdBefore' => $query->createdBefore,
'sortBy' => $query->sortBy,
'sortOrder' => $query->sortOrder,
'offset' => $query->getOffset(),
'limit' => $query->limit
];
$jobs = $this->jobRepository->findByCriteria($criteria);
$total = $this->jobRepository->countByCriteria($criteria);
return JobListResponse::fromJobs(
jobs: $jobs,
total: $total,
page: $query->page,
limit: $query->limit
);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Application\Response;
use App\Domain\Shared\Domain\Contract\ResponseInterface;
use App\Domain\Shared\Domain\Model\Job;
readonly class JobListResponse implements ResponseInterface
{
/**
* @param Job[] $items
*/
public function __construct(
public array $items,
public int $total,
public int $page,
public int $limit,
public int $pages
) {}
public static function fromJobs(array $jobs, int $total, int $page, int $limit): self
{
return new self(
items: $jobs,
total: $total,
page: $page,
limit: $limit,
pages: (int) ceil($total / $limit)
);
}
}

View File

@@ -11,6 +11,5 @@ interface FailedJobRepositoryInterface
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;
}
}

View File

@@ -14,4 +14,29 @@ interface JobRepositoryInterface
public function findPendingJobs(): array;
public function findInProgressJobs(): array;
public function findFailedJobs(): array;
}
/**
* @param array{
* status?: ?JobStatus,
* type?: ?string,
* createdAfter?: ?\DateTimeImmutable,
* createdBefore?: ?\DateTimeImmutable,
* sortBy?: string,
* sortOrder?: string,
* offset?: int,
* limit?: int
* } $criteria
* @return Job[]
*/
public function findByCriteria(array $criteria): array;
/**
* @param array{
* status?: ?JobStatus,
* type?: ?string,
* createdAfter?: ?\DateTimeImmutable,
* createdBefore?: ?\DateTimeImmutable
* } $criteria
*/
public function countByCriteria(array $criteria): int;
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Domain\Shared\Domain\Model\JobStatus;
use App\Domain\Shared\Infrastructure\ApiPlatform\State\Provider\GetJobListStateProvider;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'Job',
operations: [
new GetCollection(
uriTemplate: '/jobs',
provider: GetJobListStateProvider::class,
output: GetJobListResource::class,
description: 'Liste des jobs',
openapiContext: [
'parameters' => [
[
'name' => 'status',
'in' => 'query',
'description' => 'Filtrer par status',
'required' => false,
'schema' => [
'type' => 'string',
'enum' => ['pending', 'in_progress', 'completed', 'failed'],
'example' => 'pending'
]
],
[
'name' => 'type',
'in' => 'query',
'description' => 'Filtrer par type de job (ex: scraping_job)',
'required' => false,
'schema' => ['type' => 'string']
],
[
'name' => 'createdAfter',
'in' => 'query',
'description' => 'Date de création minimum (format ISO8601)',
'required' => false,
'schema' => ['type' => 'string', 'format' => 'date-time']
],
[
'name' => 'createdBefore',
'in' => 'query',
'description' => 'Date de création maximum (format ISO8601)',
'required' => false,
'schema' => ['type' => 'string', 'format' => 'date-time']
],
[
'name' => 'page',
'in' => 'query',
'description' => 'Numéro de la page',
'required' => false,
'schema' => ['type' => 'integer', 'default' => 1, 'minimum' => 1]
],
[
'name' => 'limit',
'in' => 'query',
'description' => 'Nombre d\'éléments par page',
'required' => false,
'schema' => ['type' => 'integer', 'default' => 20, 'minimum' => 1]
],
[
'name' => 'sortBy',
'in' => 'query',
'description' => 'Champ de tri',
'required' => false,
'schema' => [
'type' => 'string',
'enum' => ['createdAt', 'type', 'status'],
'default' => 'createdAt'
]
],
[
'name' => 'sortOrder',
'in' => 'query',
'description' => 'Ordre de tri',
'required' => false,
'schema' => [
'type' => 'string',
'enum' => ['ASC', 'DESC'],
'default' => 'DESC'
]
]
]
]
)
]
)]
class GetJobListResource
{
public function __construct(
#[ApiProperty(
identifier: true,
description: 'Identifiant unique du job'
)]
public readonly string $id,
#[ApiProperty(description: 'Type du job (ex: scraping_job)')]
#[Assert\NotBlank]
public readonly string $type,
#[ApiProperty(
description: 'Status du job',
openapiContext: ['enum' => ['pending', 'in_progress', 'completed', 'failed', 'cancelled']]
)]
#[Assert\NotBlank]
public readonly string $status,
#[ApiProperty(description: 'Date de création du job')]
#[Assert\NotNull]
public readonly \DateTimeImmutable $createdAt,
#[ApiProperty(description: 'Date de début d\'exécution du job')]
public readonly ?\DateTimeImmutable $startedAt = null,
#[ApiProperty(description: 'Date de fin d\'exécution du job')]
public readonly ?\DateTimeImmutable $completedAt = null,
#[ApiProperty(description: 'Raison de l\'échec si le job a échoué')]
public readonly ?string $failureReason = null,
#[ApiProperty(description: 'Nombre de tentatives effectuées')]
#[Assert\GreaterThanOrEqual(0)]
public readonly int $attempts = 0,
#[ApiProperty(description: 'Nombre maximum de tentatives autorisées')]
#[Assert\GreaterThan(0)]
public readonly int $maxAttempts = 3,
#[ApiProperty(description: 'Données contextuelles du job')]
public readonly array $context = []
) {}
public static function fromJob(\App\Domain\Shared\Domain\Model\Job $job): self
{
return new self(
id: $job->id,
type: $job->type,
status: $job->status->value,
createdAt: $job->createdAt,
startedAt: $job->startedAt,
completedAt: $job->completedAt,
failureReason: $job->failureReason,
attempts: $job->attempts,
maxAttempts: $job->maxAttempts,
context: $job->context
);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Shared\Application\Query\ListJobsQuery;
use App\Domain\Shared\Application\QueryHandler\ListJobsQueryHandler;
use App\Domain\Shared\Domain\Model\JobStatus;
use App\Domain\Shared\Infrastructure\ApiPlatform\Resource\GetJobListResource;
readonly class GetJobListStateProvider implements ProviderInterface
{
public function __construct(
private ListJobsQueryHandler $handler
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$filters = $context['filters'] ?? [];
$query = new ListJobsQuery(
status: isset($filters['status']) ? JobStatus::from($filters['status']) : null,
type: $filters['type'] ?? null,
createdAfter: isset($filters['createdAfter']) ? new \DateTimeImmutable($filters['createdAfter']) : null,
createdBefore: isset($filters['createdBefore']) ? new \DateTimeImmutable($filters['createdBefore']) : null,
page: (int) ($filters['page'] ?? 1),
limit: (int) ($filters['limit'] ?? 20),
sortBy: $filters['sortBy'] ?? 'createdAt',
sortOrder: $filters['sortOrder'] ?? 'DESC'
);
$response = $this->handler->handle($query);
return [
'items' => array_map(
fn($job) => GetJobListResource::fromJob($job),
$response->items
),
'total' => $response->total,
'page' => $response->page,
'limit' => $response->limit,
'pages' => $response->pages
];
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Domain\Shared\Infrastructure\Persistence\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'failed_job')]
class FailedJobEntity
{
#[ORM\Id]
#[ORM\Column]
private string $id;
#[ORM\Column(type: 'string')]
private string $type;
#[ORM\Column(type: 'text')]
private string $failureReason;
#[ORM\Column]
private \DateTimeImmutable $failedAt;
#[ORM\Column(type: 'json')]
private array $context = [];
public function getId(): string
{
return $this->id;
}
public function setId(string $id): self
{
$this->id = $id;
return $this;
}
public function getType(): string
{
return $this->type;
}
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getFailureReason(): string
{
return $this->failureReason;
}
public function setFailureReason(string $failureReason): self
{
$this->failureReason = $failureReason;
return $this;
}
public function getFailedAt(): \DateTimeImmutable
{
return $this->failedAt;
}
public function setFailedAt(\DateTimeImmutable $failedAt): self
{
$this->failedAt = $failedAt;
return $this;
}
public function getContext(): array
{
return $this->context;
}
public function setContext(array $context): self
{
$this->context = $context;
return $this;
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Domain\Shared\Infrastructure\Persistence\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'job')]
class JobEntity
{
#[ORM\Id]
#[ORM\Column]
private string $id;
#[ORM\Column(type: 'string')]
private string $type;
#[ORM\Column(type: 'string')]
private string $status;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $startedAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $completedAt = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $failureReason = null;
#[ORM\Column(type: 'integer')]
private int $attempts = 0;
#[ORM\Column(type: 'integer')]
private int $maxAttempts = 3;
#[ORM\Column(type: 'json')]
private array $context = [];
public function getId(): string
{
return $this->id;
}
public function setId(string $id): self
{
$this->id = $id;
return $this;
}
public function getType(): string
{
return $this->type;
}
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function getStartedAt(): ?\DateTimeImmutable
{
return $this->startedAt;
}
public function setStartedAt(?\DateTimeImmutable $startedAt): self
{
$this->startedAt = $startedAt;
return $this;
}
public function getCompletedAt(): ?\DateTimeImmutable
{
return $this->completedAt;
}
public function setCompletedAt(?\DateTimeImmutable $completedAt): self
{
$this->completedAt = $completedAt;
return $this;
}
public function getFailureReason(): ?string
{
return $this->failureReason;
}
public function setFailureReason(?string $failureReason): self
{
$this->failureReason = $failureReason;
return $this;
}
public function getAttempts(): int
{
return $this->attempts;
}
public function setAttempts(int $attempts): self
{
$this->attempts = $attempts;
return $this;
}
public function getMaxAttempts(): int
{
return $this->maxAttempts;
}
public function setMaxAttempts(int $maxAttempts): self
{
$this->maxAttempts = $maxAttempts;
return $this;
}
public function getContext(): array
{
return $this->context;
}
public function setContext(array $context): self
{
$this->context = $context;
return $this;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Domain\Shared\Infrastructure\Persistence\Mapper;
use App\Domain\Shared\Domain\Model\FailedJob;
use App\Domain\Shared\Infrastructure\Persistence\Entity\FailedJobEntity;
readonly class FailedJobMapper
{
public function toEntity(FailedJob $job): FailedJobEntity
{
$entity = new FailedJobEntity();
$entity->setId($job->id)
->setType($job->jobType)
->setFailureReason($job->failureReason)
->setFailedAt($job->failedAt)
->setContext($job->context);
return $entity;
}
public function toDomain(FailedJobEntity $entity): FailedJob
{
return new FailedJob(
id: $entity->getId(),
jobId: $entity->getId(), // On utilise le même ID car on n'a pas de référence au job original
jobType: $entity->getType(),
failureReason: $entity->getFailureReason(),
context: $entity->getContext(),
failedAt: $entity->getFailedAt(),
attempt: 1 // Par défaut car on n'a pas cette info dans l'entité
);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Domain\Shared\Infrastructure\Persistence\Mapper;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Shared\Domain\Model\Job;
use App\Domain\Shared\Domain\Model\JobStatus;
use App\Domain\Shared\Infrastructure\Persistence\Entity\JobEntity;
readonly class JobMapper
{
public function toEntity(Job $job): JobEntity
{
$entity = new JobEntity();
$entity->setId($job->id)
->setType($job->type)
->setStatus($job->status->value)
->setCreatedAt($job->createdAt)
->setStartedAt($job->startedAt)
->setCompletedAt($job->completedAt)
->setFailureReason($job->failureReason)
->setAttempts($job->attempts)
->setMaxAttempts($job->maxAttempts)
->setContext($job->context);
return $entity;
}
public function toDomain(JobEntity $entity): Job
{
$job = match($entity->getType()) {
'scraping_job' => new ScrapingJob(
$entity->getId(),
$entity->getContext()['mangaId'],
$entity->getContext()['chapterNumber'],
$entity->getContext()['sourceId']
),
default => throw new \RuntimeException(sprintf('Unknown job type: %s', $entity->getType()))
};
$job->status = JobStatus::from($entity->getStatus());
$job->createdAt = $entity->getCreatedAt();
$job->startedAt = $entity->getStartedAt();
$job->completedAt = $entity->getCompletedAt();
$job->failureReason = $entity->getFailureReason();
$job->attempts = $entity->getAttempts();
$job->maxAttempts = $entity->getMaxAttempts();
$job->context = $entity->getContext();
return $job;
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Domain\Shared\Infrastructure\Persistence\Repository;
use App\Domain\Shared\Domain\Contract\FailedJobRepositoryInterface;
use App\Domain\Shared\Domain\Model\FailedJob;
use App\Domain\Shared\Domain\Model\Job;
use App\Domain\Shared\Infrastructure\Persistence\Entity\FailedJobEntity;
use App\Domain\Shared\Infrastructure\Persistence\Mapper\FailedJobMapper;
use Doctrine\ORM\EntityManagerInterface;
readonly class DoctrineFailedJobRepository implements FailedJobRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private FailedJobMapper $mapper
) {
}
public function save(FailedJob $job): void
{
$entity = $this->mapper->toEntity($job);
$this->entityManager->persist($entity);
$this->entityManager->flush();
}
public function get(string $id): FailedJob
{
$job = $this->findById($id);
if (null === $job) {
throw new \RuntimeException(sprintf('Failed job with id %s not found', $id));
}
return $job;
}
public function delete(string $id): void
{
$entity = $this->entityManager->find(FailedJobEntity::class, $id);
if ($entity) {
$this->entityManager->remove($entity);
$this->entityManager->flush();
}
}
public function findAll(): array
{
$entities = $this->entityManager->createQueryBuilder()
->select('j')
->from(FailedJobEntity::class, 'j')
->getQuery()
->getResult();
return array_map(fn(FailedJobEntity $entity) => $this->mapper->toDomain($entity), $entities);
}
public function findById(string $id): ?FailedJob
{
$entity = $this->entityManager->find(FailedJobEntity::class, $id);
if (null === $entity) {
return null;
}
return $this->mapper->toDomain($entity);
}
public function findByJobType(string $type): array
{
return $this->findByType($type);
}
public function findRetryableJobs(): array
{
$entities = $this->entityManager->createQueryBuilder()
->select('j')
->from(FailedJobEntity::class, 'j')
->getQuery()
->getResult();
return array_map(
fn(FailedJobEntity $entity) => $this->mapper->toDomain($entity),
array_filter(
$entities,
fn(FailedJobEntity $entity) => $this->mapper->toDomain($entity)->attempt < 3
)
);
}
private function findByType(string $type): array
{
$entities = $this->entityManager->createQueryBuilder()
->select('j')
->from(FailedJobEntity::class, 'j')
->where('j.type = :type')
->setParameter('type', $type)
->getQuery()
->getResult();
return array_map(fn(FailedJobEntity $entity) => $this->mapper->toDomain($entity), $entities);
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace App\Domain\Shared\Infrastructure\Persistence\Repository;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
use App\Domain\Shared\Domain\Exception\JobNotFoundException;
use App\Domain\Shared\Domain\Model\Job;
use App\Domain\Shared\Domain\Model\JobStatus;
use App\Domain\Shared\Infrastructure\Persistence\Entity\JobEntity;
use App\Domain\Shared\Infrastructure\Persistence\Mapper\JobMapper;
use Doctrine\ORM\EntityManagerInterface;
readonly class DoctrineJobRepository implements JobRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private JobMapper $mapper
) {
}
public function save(Job $job): void
{
dump('save', $job);
/** @var JobEntity|null $existingJobEntity */
$existingJobEntity = $this->entityManager->find(JobEntity::class, $job->id);
if ($existingJobEntity) {
dump('existingJobEntity', $existingJobEntity);
$existingJobEntity->setStatus($job->status->value);
$existingJobEntity->setStartedAt($job->startedAt);
$existingJobEntity->setCompletedAt($job->completedAt);
$existingJobEntity->setFailureReason($job->failureReason);
$existingJobEntity->setAttempts($job->attempts);
$existingJobEntity->setContext($job->context);
$this->entityManager->persist($existingJobEntity);
dump('updated', $existingJobEntity);
} else {
$entity = $this->mapper->toEntity($job);
$this->entityManager->persist($entity);
dump('created', $entity);
}
$this->entityManager->flush();
dump('flushed');
}
public function get(string $id): Job
{
$job = $this->findById($id);
if (null === $job) {
throw JobNotFoundException::withId($id);
}
return $job;
}
public function findById(string $id): ?Job
{
$entity = $this->entityManager->find(JobEntity::class, $id);
if (null === $entity) {
return null;
}
return $this->mapper->toDomain($entity);
}
public function findByStatus(JobStatus $status): array
{
$entities = $this->entityManager->createQueryBuilder()
->select('j')
->from(JobEntity::class, 'j')
->where('j.status = :status')
->setParameter('status', $status->value)
->getQuery()
->getResult();
return array_map(fn(JobEntity $entity) => $this->mapper->toDomain($entity), $entities);
}
public function findPendingJobs(): array
{
return $this->findByStatus(JobStatus::PENDING);
}
public function findInProgressJobs(): array
{
return $this->findByStatus(JobStatus::IN_PROGRESS);
}
public function findFailedJobs(): array
{
return $this->findByStatus(JobStatus::FAILED);
}
public function findByType(string $type): array
{
$entities = $this->entityManager->createQueryBuilder()
->select('j')
->from(JobEntity::class, 'j')
->where('j.type = :type')
->setParameter('type', $type)
->getQuery()
->getResult();
return array_map(fn(JobEntity $entity) => $this->mapper->toDomain($entity), $entities);
}
public function findByCriteria(array $criteria): array
{
$qb = $this->entityManager->createQueryBuilder()
->select('j')
->from(JobEntity::class, 'j');
if (isset($criteria['status'])) {
$qb->andWhere('j.status = :status')
->setParameter('status', $criteria['status']->value);
}
if (isset($criteria['type'])) {
$qb->andWhere('j.type = :type')
->setParameter('type', $criteria['type']);
}
if (isset($criteria['createdAfter'])) {
$qb->andWhere('j.createdAt >= :createdAfter')
->setParameter('createdAfter', $criteria['createdAfter']);
}
if (isset($criteria['createdBefore'])) {
$qb->andWhere('j.createdAt <= :createdBefore')
->setParameter('createdBefore', $criteria['createdBefore']);
}
if (isset($criteria['sortBy'])) {
$qb->orderBy('j.' . $criteria['sortBy'], $criteria['sortOrder'] ?? 'ASC');
}
if (isset($criteria['offset'])) {
$qb->setFirstResult($criteria['offset']);
}
if (isset($criteria['limit'])) {
$qb->setMaxResults($criteria['limit']);
}
$entities = $qb->getQuery()->getResult();
return array_map(fn(JobEntity $entity) => $this->mapper->toDomain($entity), $entities);
}
public function countByCriteria(array $criteria): int
{
$qb = $this->entityManager->createQueryBuilder()
->select('COUNT(j.id)')
->from(JobEntity::class, 'j');
if (isset($criteria['status'])) {
$qb->andWhere('j.status = :status')
->setParameter('status', $criteria['status']->value);
}
if (isset($criteria['type'])) {
$qb->andWhere('j.type = :type')
->setParameter('type', $criteria['type']);
}
if (isset($criteria['createdAfter'])) {
$qb->andWhere('j.createdAt >= :createdAfter')
->setParameter('createdAfter', $criteria['createdAfter']);
}
if (isset($criteria['createdBefore'])) {
$qb->andWhere('j.createdAt <= :createdBefore')
->setParameter('createdBefore', $criteria['createdBefore']);
}
return (int) $qb->getQuery()->getSingleScalarResult();
}
}