feat: ajout de la gestion des sources de contenu avec des commandes et des gestionnaires pour l'importation, la mise à jour et l'exportation, ainsi que la création des ressources API correspondantes.

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-06-26 23:24:13 +02:00
parent ebcca466a9
commit 32b4e4fbb2
30 changed files with 1783 additions and 0 deletions

View File

@@ -28,6 +28,7 @@ api_platform:
- '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto' - '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto'
- '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Resource'
- '%kernel.project_dir%/src/Domain/Manga/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Manga/Infrastructure/ApiPlatform/Resource'
- '%kernel.project_dir%/src/Domain/Setting/Infrastructure/ApiPlatform/Resource'
- '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource'
- '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource'
patch_formats: patch_formats:

View File

@@ -12,6 +12,558 @@
} }
], ],
"paths": { "paths": {
"/api/content-sources": {
"get": {
"operationId": "api_content-sources_get_collection",
"tags": [
"ContentSource"
],
"responses": {
"200": {
"description": "ContentSource collection",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ContentSource"
}
}
},
"application/ld+json": {
"schema": {
"type": "object",
"properties": {
"hydra:member": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ContentSource.jsonld"
}
},
"hydra:totalItems": {
"type": "integer",
"minimum": 0
},
"hydra:view": {
"type": "object",
"properties": {
"@id": {
"type": "string",
"format": "iri-reference"
},
"@type": {
"type": "string"
},
"hydra:first": {
"type": "string",
"format": "iri-reference"
},
"hydra:last": {
"type": "string",
"format": "iri-reference"
},
"hydra:previous": {
"type": "string",
"format": "iri-reference"
},
"hydra:next": {
"type": "string",
"format": "iri-reference"
}
},
"example": {
"@id": "string",
"type": "string",
"hydra:first": "string",
"hydra:last": "string",
"hydra:previous": "string",
"hydra:next": "string"
}
},
"hydra:search": {
"type": "object",
"properties": {
"@type": {
"type": "string"
},
"hydra:template": {
"type": "string"
},
"hydra:variableRepresentation": {
"type": "string"
},
"hydra:mapping": {
"type": "array",
"items": {
"type": "object",
"properties": {
"@type": {
"type": "string"
},
"variable": {
"type": "string"
},
"property": {
"type": [
"string",
"null"
]
},
"required": {
"type": "boolean"
}
}
}
}
}
}
},
"required": [
"hydra:member"
]
}
},
"text/html": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ContentSource"
}
}
},
"application/hal+json": {
"schema": {
"type": "object",
"properties": {
"_embedded": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ContentSource.jsonhal"
}
},
"totalItems": {
"type": "integer",
"minimum": 0
},
"itemsPerPage": {
"type": "integer",
"minimum": 0
},
"_links": {
"type": "object",
"properties": {
"self": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
},
"first": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
},
"last": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
},
"next": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
},
"previous": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
}
}
}
},
"required": [
"_links",
"_embedded"
]
}
}
}
}
},
"summary": "Retrieves the collection of ContentSource resources.",
"description": "Retrieves the collection of ContentSource resources.",
"parameters": [
{
"name": "page",
"in": "query",
"description": "The collection page number",
"required": false,
"deprecated": false,
"allowEmptyValue": true,
"schema": {
"type": "integer",
"default": 1
},
"style": "form",
"explode": false,
"allowReserved": false
}
],
"deprecated": false
},
"post": {
"operationId": "api_content-sources_post",
"tags": [
"ContentSource"
],
"responses": {
"201": {
"description": "ContentSource resource created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContentSource"
}
},
"application/ld+json": {
"schema": {
"$ref": "#/components/schemas/ContentSource.jsonld"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/ContentSource"
}
},
"application/hal+json": {
"schema": {
"$ref": "#/components/schemas/ContentSource.jsonhal"
}
}
},
"links": {}
},
"400": {
"description": "Invalid input"
},
"422": {
"description": "Unprocessable entity"
}
},
"summary": "Creates a ContentSource resource.",
"description": "Creates a ContentSource resource.",
"parameters": [],
"requestBody": {
"description": "The new ContentSource resource",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContentSource"
}
},
"application/ld+json": {
"schema": {
"$ref": "#/components/schemas/ContentSource.jsonld"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/ContentSource"
}
},
"application/hal+json": {
"schema": {
"$ref": "#/components/schemas/ContentSource.jsonhal"
}
}
},
"required": true
},
"deprecated": false
},
"parameters": []
},
"/api/content-sources/export": {
"get": {
"operationId": "api_content-sourcesexport_get",
"tags": [
"ContentSourceExport"
],
"responses": {
"200": {
"description": "ContentSourceExport resource",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContentSourceExport"
}
},
"application/ld+json": {
"schema": {
"$ref": "#/components/schemas/ContentSourceExport.jsonld"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/ContentSourceExport"
}
},
"application/hal+json": {
"schema": {
"$ref": "#/components/schemas/ContentSourceExport.jsonhal"
}
}
}
},
"404": {
"description": "Resource not found"
}
},
"summary": "Retrieves a ContentSourceExport resource.",
"description": "Retrieves a ContentSourceExport resource.",
"parameters": [],
"deprecated": false
},
"parameters": []
},
"/api/content-sources/import": {
"post": {
"operationId": "api_content-sourcesimport_post",
"tags": [
"ContentSourceImport"
],
"responses": {
"201": {
"description": "ContentSourceImport resource created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContentSourceImport"
}
},
"application/ld+json": {
"schema": {
"$ref": "#/components/schemas/ContentSourceImport.jsonld"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/ContentSourceImport"
}
},
"application/hal+json": {
"schema": {
"$ref": "#/components/schemas/ContentSourceImport.jsonhal"
}
}
},
"links": {}
},
"400": {
"description": "Invalid input"
},
"422": {
"description": "Unprocessable entity"
}
},
"summary": "Creates a ContentSourceImport resource.",
"description": "Creates a ContentSourceImport resource.",
"parameters": [],
"requestBody": {
"description": "The new ContentSourceImport resource",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContentSourceImport"
}
},
"application/ld+json": {
"schema": {
"$ref": "#/components/schemas/ContentSourceImport.jsonld"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/ContentSourceImport"
}
},
"application/hal+json": {
"schema": {
"$ref": "#/components/schemas/ContentSourceImport.jsonhal"
}
}
},
"required": true
},
"deprecated": false
},
"parameters": []
},
"/api/content-sources/{id}": {
"get": {
"operationId": "api_content-sources_id_get",
"tags": [
"ContentSource"
],
"responses": {
"200": {
"description": "ContentSource resource",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContentSource"
}
},
"application/ld+json": {
"schema": {
"$ref": "#/components/schemas/ContentSource.jsonld"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/ContentSource"
}
},
"application/hal+json": {
"schema": {
"$ref": "#/components/schemas/ContentSource.jsonhal"
}
}
}
},
"404": {
"description": "Resource not found"
}
},
"summary": "Retrieves a ContentSource resource.",
"description": "Retrieves a ContentSource resource.",
"parameters": [
{
"name": "id",
"in": "path",
"description": "GetContentSourceResource identifier",
"required": true,
"deprecated": false,
"allowEmptyValue": false,
"schema": {
"type": "string"
},
"style": "simple",
"explode": false,
"allowReserved": false
}
],
"deprecated": false
},
"put": {
"operationId": "api_content-sources_id_put",
"tags": [
"ContentSource"
],
"responses": {
"200": {
"description": "ContentSource resource updated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContentSource"
}
},
"application/ld+json": {
"schema": {
"$ref": "#/components/schemas/ContentSource.jsonld"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/ContentSource"
}
},
"application/hal+json": {
"schema": {
"$ref": "#/components/schemas/ContentSource.jsonhal"
}
}
},
"links": {}
},
"400": {
"description": "Invalid input"
},
"422": {
"description": "Unprocessable entity"
},
"404": {
"description": "Resource not found"
}
},
"summary": "Replaces the ContentSource resource.",
"description": "Replaces the ContentSource resource.",
"parameters": [
{
"name": "id",
"in": "path",
"description": "UpsertContentSourceResource identifier",
"required": true,
"deprecated": false,
"allowEmptyValue": false,
"schema": {
"type": "string"
},
"style": "simple",
"explode": false,
"allowReserved": false
}
],
"requestBody": {
"description": "The updated ContentSource resource",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContentSource"
}
},
"application/ld+json": {
"schema": {
"$ref": "#/components/schemas/ContentSource.jsonld"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/ContentSource"
}
},
"application/hal+json": {
"schema": {
"$ref": "#/components/schemas/ContentSource.jsonhal"
}
}
},
"required": true
},
"deprecated": false
},
"parameters": []
},
"/api/jobs": { "/api/jobs": {
"get": { "get": {
"operationId": "api_jobs_get_collection", "operationId": "api_jobs_get_collection",
@@ -1846,6 +2398,352 @@
} }
} }
}, },
"ContentSource": {
"type": "object",
"description": "R\u00e9cup\u00e8re une source de contenu par son identifiant",
"deprecated": false,
"required": [
"id"
],
"properties": {
"id": {
"type": "integer"
},
"baseUrl": {
"type": "string"
},
"chapterUrlFormat": {
"type": "string"
},
"scrapingType": {
"type": "string"
},
"imageSelector": {
"type": [
"string",
"null"
]
},
"nextPageSelector": {
"type": [
"string",
"null"
]
},
"chapterSelector": {
"type": [
"string",
"null"
]
},
"cleanBaseUrl": {
"type": "string"
}
}
},
"ContentSource.jsonhal": {
"type": "object",
"description": "R\u00e9cup\u00e8re une source de contenu par son identifiant",
"deprecated": false,
"required": [
"id"
],
"properties": {
"_links": {
"type": "object",
"properties": {
"self": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
}
}
},
"id": {
"type": "integer"
},
"baseUrl": {
"type": "string"
},
"chapterUrlFormat": {
"type": "string"
},
"scrapingType": {
"type": "string"
},
"imageSelector": {
"type": [
"string",
"null"
]
},
"nextPageSelector": {
"type": [
"string",
"null"
]
},
"chapterSelector": {
"type": [
"string",
"null"
]
},
"cleanBaseUrl": {
"type": "string"
}
}
},
"ContentSource.jsonld": {
"type": "object",
"description": "R\u00e9cup\u00e8re une source de contenu par son identifiant",
"deprecated": false,
"required": [
"id"
],
"properties": {
"@context": {
"readOnly": true,
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"@vocab": {
"type": "string"
},
"hydra": {
"type": "string",
"enum": [
"http://www.w3.org/ns/hydra/core#"
]
}
},
"required": [
"@vocab",
"hydra"
],
"additionalProperties": true
}
]
},
"@id": {
"readOnly": true,
"type": "string"
},
"@type": {
"readOnly": true,
"type": "string"
},
"id": {
"type": "integer"
},
"baseUrl": {
"type": "string"
},
"chapterUrlFormat": {
"type": "string"
},
"scrapingType": {
"type": "string"
},
"imageSelector": {
"type": [
"string",
"null"
]
},
"nextPageSelector": {
"type": [
"string",
"null"
]
},
"chapterSelector": {
"type": [
"string",
"null"
]
},
"cleanBaseUrl": {
"type": "string"
}
}
},
"ContentSourceExport": {
"type": "object",
"description": "Exporte toutes les sources de contenu au format JSON",
"deprecated": false
},
"ContentSourceExport.jsonhal": {
"type": "object",
"description": "Exporte toutes les sources de contenu au format JSON",
"deprecated": false,
"properties": {
"_links": {
"type": "object",
"properties": {
"self": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
}
}
}
}
},
"ContentSourceExport.jsonld": {
"type": "object",
"description": "Exporte toutes les sources de contenu au format JSON",
"deprecated": false,
"properties": {
"@context": {
"readOnly": true,
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"@vocab": {
"type": "string"
},
"hydra": {
"type": "string",
"enum": [
"http://www.w3.org/ns/hydra/core#"
]
}
},
"required": [
"@vocab",
"hydra"
],
"additionalProperties": true
}
]
},
"@id": {
"readOnly": true,
"type": "string"
},
"@type": {
"readOnly": true,
"type": "string"
}
}
},
"ContentSourceImport": {
"type": "object",
"description": "Importe des sources de contenu depuis un tableau JSON",
"deprecated": false,
"required": [
"contentSources"
],
"properties": {
"contentSources": {
"minItems": 1,
"type": "array",
"items": {
"type": "string"
}
}
}
},
"ContentSourceImport.jsonhal": {
"type": "object",
"description": "Importe des sources de contenu depuis un tableau JSON",
"deprecated": false,
"required": [
"contentSources"
],
"properties": {
"_links": {
"type": "object",
"properties": {
"self": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
}
}
},
"contentSources": {
"minItems": 1,
"type": "array",
"items": {
"type": "string"
}
}
}
},
"ContentSourceImport.jsonld": {
"type": "object",
"description": "Importe des sources de contenu depuis un tableau JSON",
"deprecated": false,
"required": [
"contentSources"
],
"properties": {
"@context": {
"readOnly": true,
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"@vocab": {
"type": "string"
},
"hydra": {
"type": "string",
"enum": [
"http://www.w3.org/ns/hydra/core#"
]
}
},
"required": [
"@vocab",
"hydra"
],
"additionalProperties": true
}
]
},
"@id": {
"readOnly": true,
"type": "string"
},
"@type": {
"readOnly": true,
"type": "string"
},
"contentSources": {
"minItems": 1,
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Job": { "Job": {
"type": "object", "type": "object",
"description": "Liste des jobs", "description": "Liste des jobs",

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Domain\Setting\Application\Command;
readonly class ImportContentSourceCommand
{
public function __construct(
public array $contentSources
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domain\Setting\Application\Command;
readonly class UpsertContentSourceCommand
{
public function __construct(
public ?int $id,
public string $baseUrl,
public string $chapterUrlFormat,
public string $scrapingType,
public ?string $imageSelector = null,
public ?string $nextPageSelector = null,
public ?string $chapterSelector = null,
) {}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Domain\Setting\Application\CommandHandler;
use App\Domain\Setting\Application\Command\ImportContentSourceCommand;
use App\Domain\Setting\Domain\Contract\Repository\ContentSourceRepositoryInterface;
use App\Domain\Setting\Domain\Model\ContentSource;
use InvalidArgumentException;
readonly class ImportContentSourceCommandHandler
{
public function __construct(
private ContentSourceRepositoryInterface $contentSourceRepository
) {}
public function handle(ImportContentSourceCommand $command): void
{
// Validation des données d'import
if (!is_array($command->contentSources)) {
throw new InvalidArgumentException('Content sources must be an array');
}
$contentSourcesToImport = [];
foreach ($command->contentSources as $data) {
if (!$this->isValidContentSourceData($data)) {
throw new InvalidArgumentException('Invalid content source data provided');
}
$contentSource = ContentSource::create(
baseUrl: $data['baseUrl'],
chapterUrlFormat: $data['chapterUrlFormat'],
scrapingType: $data['scrapingType'],
imageSelector: $data['imageSelector'] ?? null,
nextPageSelector: $data['nextPageSelector'] ?? null,
chapterSelector: $data['chapterSelector'] ?? null,
);
$contentSourcesToImport[] = $contentSource;
}
// Supprime tous les content sources existants puis importe les nouveaux
$this->contentSourceRepository->deleteAll();
$this->contentSourceRepository->saveMultiple($contentSourcesToImport);
}
private function isValidContentSourceData(mixed $data): bool
{
if (!is_array($data)) {
return false;
}
$requiredFields = ['baseUrl', 'chapterUrlFormat', 'scrapingType'];
foreach ($requiredFields as $field) {
if (!isset($data[$field]) || !is_string($data[$field]) || empty($data[$field])) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Domain\Setting\Application\CommandHandler;
use App\Domain\Setting\Application\Command\UpsertContentSourceCommand;
use App\Domain\Setting\Domain\Contract\Repository\ContentSourceRepositoryInterface;
use App\Domain\Setting\Domain\Model\ContentSource;
readonly class UpsertContentSourceCommandHandler
{
public function __construct(
private ContentSourceRepositoryInterface $contentSourceRepository
) {}
public function handle(UpsertContentSourceCommand $command): void
{
if ($command->id) {
// Update existing
$contentSource = $this->contentSourceRepository->findById($command->id);
if ($contentSource) {
$contentSource->update(
baseUrl: $command->baseUrl,
chapterUrlFormat: $command->chapterUrlFormat,
scrapingType: $command->scrapingType,
imageSelector: $command->imageSelector,
nextPageSelector: $command->nextPageSelector,
chapterSelector: $command->chapterSelector,
);
$this->contentSourceRepository->save($contentSource);
}
} else {
// Create new
$contentSource = ContentSource::create(
baseUrl: $command->baseUrl,
chapterUrlFormat: $command->chapterUrlFormat,
scrapingType: $command->scrapingType,
imageSelector: $command->imageSelector,
nextPageSelector: $command->nextPageSelector,
chapterSelector: $command->chapterSelector,
);
$this->contentSourceRepository->save($contentSource);
}
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Domain\Setting\Application\Query;
readonly class ExportContentSourceQuery
{
public function __construct()
{
// Pas de paramètres pour cette query simple
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Domain\Setting\Application\Query;
readonly class GetContentSourceQuery
{
public function __construct(
public int $id
) {}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Domain\Setting\Application\Query;
readonly class ListContentSourceQuery
{
public function __construct()
{
// Pas de paramètres pour cette query simple
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domain\Setting\Application\QueryHandler;
use App\Domain\Setting\Application\Query\ExportContentSourceQuery;
use App\Domain\Setting\Application\Response\ExportContentSourceResponse;
use App\Domain\Setting\Domain\Contract\Repository\ContentSourceRepositoryInterface;
use DateTimeImmutable;
readonly class ExportContentSourceQueryHandler
{
public function __construct(
private ContentSourceRepositoryInterface $contentSourceRepository
) {}
public function handle(ExportContentSourceQuery $query): ExportContentSourceResponse
{
$contentSources = $this->contentSourceRepository->findAll();
$exportData = array_map(function ($contentSource) {
return [
'baseUrl' => $contentSource->getBaseUrl(),
'chapterUrlFormat' => $contentSource->getChapterUrlFormat(),
'scrapingType' => $contentSource->getScrapingType(),
'imageSelector' => $contentSource->getImageSelector(),
'nextPageSelector' => $contentSource->getNextPageSelector(),
'chapterSelector' => $contentSource->getChapterSelector(),
];
}, $contentSources);
return new ExportContentSourceResponse(
contentSources: $exportData,
exportDate: (new DateTimeImmutable())->format('Y-m-d H:i:s')
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domain\Setting\Application\QueryHandler;
use App\Domain\Setting\Application\Query\GetContentSourceQuery;
use App\Domain\Setting\Application\Response\ContentSourceResponse;
use App\Domain\Setting\Domain\Contract\Repository\ContentSourceRepositoryInterface;
use App\Domain\Setting\Domain\Exception\ContentSourceNotFoundException;
readonly class GetContentSourceQueryHandler
{
public function __construct(
private ContentSourceRepositoryInterface $contentSourceRepository
) {}
public function handle(GetContentSourceQuery $query): ContentSourceResponse
{
$contentSource = $this->contentSourceRepository->findById($query->id);
if (!$contentSource) {
throw new ContentSourceNotFoundException($query->id);
}
return ContentSourceResponse::fromDomain($contentSource);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domain\Setting\Application\QueryHandler;
use App\Domain\Setting\Application\Query\ListContentSourceQuery;
use App\Domain\Setting\Application\Response\ContentSourceListResponse;
use App\Domain\Setting\Application\Response\ContentSourceResponse;
use App\Domain\Setting\Domain\Contract\Repository\ContentSourceRepositoryInterface;
readonly class ListContentSourceQueryHandler
{
public function __construct(
private ContentSourceRepositoryInterface $contentSourceRepository
) {}
public function handle(ListContentSourceQuery $query): ContentSourceListResponse
{
$contentSources = $this->contentSourceRepository->findAll();
$responses = array_map(
fn($contentSource) => ContentSourceResponse::fromDomain($contentSource),
$contentSources
);
return new ContentSourceListResponse(
contentSources: $responses,
total: count($responses)
);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Domain\Setting\Application\Response;
readonly class ContentSourceListResponse
{
/**
* @param ContentSourceResponse[] $contentSources
*/
public function __construct(
public array $contentSources,
public int $total,
) {}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Domain\Setting\Application\Response;
use App\Domain\Setting\Domain\Model\ContentSource;
readonly class ContentSourceResponse
{
public function __construct(
public int $id,
public string $baseUrl,
public string $chapterUrlFormat,
public string $scrapingType,
public ?string $imageSelector,
public ?string $nextPageSelector,
public ?string $chapterSelector,
public string $cleanBaseUrl,
) {}
public static function fromDomain(ContentSource $contentSource): self
{
return new self(
id: $contentSource->getId(),
baseUrl: $contentSource->getBaseUrl(),
chapterUrlFormat: $contentSource->getChapterUrlFormat(),
scrapingType: $contentSource->getScrapingType(),
imageSelector: $contentSource->getImageSelector(),
nextPageSelector: $contentSource->getNextPageSelector(),
chapterSelector: $contentSource->getChapterSelector(),
cleanBaseUrl: $contentSource->getCleanBaseUrl(),
);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Domain\Setting\Application\Response;
readonly class ExportContentSourceResponse
{
public function __construct(
public array $contentSources,
public string $exportDate,
) {}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Domain\Setting\Domain\Contract\Repository;
use App\Domain\Setting\Domain\Model\ContentSource;
interface ContentSourceRepositoryInterface
{
public function findAll(): array;
public function findById(int $id): ?ContentSource;
public function save(ContentSource $contentSource): void;
public function delete(ContentSource $contentSource): void;
public function deleteAll(): void;
/**
* @param ContentSource[] $contentSources
*/
public function saveMultiple(array $contentSources): void;
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Domain\Setting\Domain\Exception;
use RuntimeException;
class ContentSourceNotFoundException extends RuntimeException
{
public function __construct(int $id)
{
parent::__construct(sprintf('ContentSource with id %d not found', $id));
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Domain\Setting\Domain\Model;
final class ContentSource
{
public function __construct(
private ?int $id,
private string $baseUrl,
private string $chapterUrlFormat,
private string $scrapingType,
private ?string $imageSelector = null,
private ?string $nextPageSelector = null,
private ?string $chapterSelector = null,
) {}
public function getId(): ?int
{
return $this->id;
}
public function getBaseUrl(): string
{
return $this->baseUrl;
}
public function getChapterUrlFormat(): string
{
return $this->chapterUrlFormat;
}
public function getScrapingType(): string
{
return $this->scrapingType;
}
public function getImageSelector(): ?string
{
return $this->imageSelector;
}
public function getNextPageSelector(): ?string
{
return $this->nextPageSelector;
}
public function getChapterSelector(): ?string
{
return $this->chapterSelector;
}
public function updateId(int $id): void
{
$this->id = $id;
}
public function getCleanBaseUrl(): string
{
return preg_replace(
'/^(https?:\/\/)?(www\.)?|\/+$/',
'',
$this->baseUrl
);
}
public static function create(
string $baseUrl,
string $chapterUrlFormat,
string $scrapingType,
?string $imageSelector = null,
?string $nextPageSelector = null,
?string $chapterSelector = null,
): self {
return new self(
id: null,
baseUrl: $baseUrl,
chapterUrlFormat: $chapterUrlFormat,
scrapingType: $scrapingType,
imageSelector: $imageSelector,
nextPageSelector: $nextPageSelector,
chapterSelector: $chapterSelector,
);
}
public function update(
string $baseUrl,
string $chapterUrlFormat,
string $scrapingType,
?string $imageSelector = null,
?string $nextPageSelector = null,
?string $chapterSelector = null,
): void {
$this->baseUrl = $baseUrl;
$this->chapterUrlFormat = $chapterUrlFormat;
$this->scrapingType = $scrapingType;
$this->imageSelector = $imageSelector;
$this->nextPageSelector = $nextPageSelector;
$this->chapterSelector = $chapterSelector;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domain\Setting\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Domain\Setting\Infrastructure\ApiPlatform\State\Provider\ExportContentSourceStateProvider;
#[ApiResource(
shortName: 'ContentSourceExport',
operations: [
new Get(
uriTemplate: '/content-sources/export',
provider: ExportContentSourceStateProvider::class,
description: 'Exporte toutes les sources de contenu au format JSON'
)
]
)]
class ExportContentSourceResource
{
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Domain\Setting\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Domain\Setting\Infrastructure\ApiPlatform\State\Provider\GetContentSourceStateProvider;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'ContentSource',
operations: [
new Get(
uriTemplate: '/content-sources/{id}',
provider: GetContentSourceStateProvider::class,
description: 'Récupère une source de contenu par son identifiant'
)
]
)]
class GetContentSourceResource
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Type('integer')]
public readonly int $id,
public readonly string $baseUrl,
public readonly string $chapterUrlFormat,
public readonly string $scrapingType,
public readonly ?string $imageSelector,
public readonly ?string $nextPageSelector,
public readonly ?string $chapterSelector,
public readonly string $cleanBaseUrl,
) {}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domain\Setting\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Domain\Setting\Infrastructure\ApiPlatform\State\Processor\ImportContentSourceStateProcessor;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'ContentSourceImport',
operations: [
new Post(
uriTemplate: '/content-sources/import',
processor: ImportContentSourceStateProcessor::class,
input: ImportContentSourceResource::class,
status: 201,
description: 'Importe des sources de contenu depuis un tableau JSON'
)
]
)]
class ImportContentSourceResource
{
public function __construct(
#[Assert\NotBlank(message: 'Les sources de contenu sont obligatoires')]
#[Assert\Type('array', message: 'Les sources de contenu doivent être un tableau')]
#[Assert\Count(min: 1, minMessage: 'Au moins une source de contenu doit être fournie')]
public readonly array $contentSources = []
) {}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domain\Setting\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Domain\Setting\Infrastructure\ApiPlatform\State\Provider\ListContentSourceStateProvider;
#[ApiResource(
shortName: 'ContentSource',
operations: [
new GetCollection(
uriTemplate: '/content-sources',
provider: ListContentSourceStateProvider::class,
description: 'Récupère la liste des sources de contenu'
)
]
)]
class ListContentSourceResource
{
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Domain\Setting\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Domain\Setting\Infrastructure\ApiPlatform\State\Processor\UpsertContentSourceStateProcessor;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'ContentSource',
operations: [
new Post(
uriTemplate: '/content-sources',
processor: UpsertContentSourceStateProcessor::class,
input: UpsertContentSourceResource::class,
status: 201,
description: 'Crée une nouvelle source de contenu'
),
new Put(
uriTemplate: '/content-sources/{id}',
processor: UpsertContentSourceStateProcessor::class,
input: UpsertContentSourceResource::class,
description: 'Met à jour une source de contenu existante'
)
]
)]
class UpsertContentSourceResource
{
public function __construct(
public readonly ?int $id = null,
#[Assert\NotBlank(message: 'L\'URL de base est obligatoire')]
#[Assert\Url(message: 'L\'URL de base doit être une URL valide')]
public readonly string $baseUrl = '',
#[Assert\NotBlank(message: 'Le format d\'URL de chapitre est obligatoire')]
public readonly string $chapterUrlFormat = '',
#[Assert\NotBlank(message: 'Le type de scraping est obligatoire')]
#[Assert\Choice(choices: ['html', 'javascript'], message: 'Le type de scraping doit être html ou javascript')]
public readonly string $scrapingType = '',
public readonly ?string $imageSelector = null,
public readonly ?string $nextPageSelector = null,
public readonly ?string $chapterSelector = null,
) {}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domain\Setting\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Setting\Application\Command\ImportContentSourceCommand;
use App\Domain\Setting\Application\CommandHandler\ImportContentSourceCommandHandler;
use App\Domain\Setting\Infrastructure\ApiPlatform\Resource\ImportContentSourceResource;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use InvalidArgumentException;
readonly class ImportContentSourceStateProcessor implements ProcessorInterface
{
public function __construct(
private ImportContentSourceCommandHandler $handler
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): int
{
assert($data instanceof ImportContentSourceResource);
try {
$command = new ImportContentSourceCommand(
contentSources: $data->contentSources
);
$this->handler->handle($command);
return Response::HTTP_CREATED;
} catch (InvalidArgumentException $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Domain\Setting\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Setting\Application\Command\UpsertContentSourceCommand;
use App\Domain\Setting\Application\CommandHandler\UpsertContentSourceCommandHandler;
use App\Domain\Setting\Infrastructure\ApiPlatform\Resource\UpsertContentSourceResource;
use Symfony\Component\HttpFoundation\Response;
readonly class UpsertContentSourceStateProcessor implements ProcessorInterface
{
public function __construct(
private UpsertContentSourceCommandHandler $handler
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): int
{
assert($data instanceof UpsertContentSourceResource);
$id = $uriVariables['id'] ?? $data->id ?? null;
$command = new UpsertContentSourceCommand(
id: $id,
baseUrl: $data->baseUrl,
chapterUrlFormat: $data->chapterUrlFormat,
scrapingType: $data->scrapingType,
imageSelector: $data->imageSelector,
nextPageSelector: $data->nextPageSelector,
chapterSelector: $data->chapterSelector,
);
$this->handler->handle($command);
return $id ? Response::HTTP_OK : Response::HTTP_CREATED;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domain\Setting\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Setting\Application\Query\ExportContentSourceQuery;
use App\Domain\Setting\Application\QueryHandler\ExportContentSourceQueryHandler;
readonly class ExportContentSourceStateProvider implements ProviderInterface
{
public function __construct(
private ExportContentSourceQueryHandler $handler
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$query = new ExportContentSourceQuery();
$response = $this->handler->handle($query);
return [
'contentSources' => $response->contentSources,
'exportDate' => $response->exportDate,
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Domain\Setting\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Setting\Application\Query\GetContentSourceQuery;
use App\Domain\Setting\Application\QueryHandler\GetContentSourceQueryHandler;
use App\Domain\Setting\Domain\Exception\ContentSourceNotFoundException;
use App\Domain\Setting\Infrastructure\ApiPlatform\Resource\GetContentSourceResource;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
readonly class GetContentSourceStateProvider implements ProviderInterface
{
public function __construct(
private GetContentSourceQueryHandler $handler
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?GetContentSourceResource
{
try {
$query = new GetContentSourceQuery($uriVariables['id']);
$response = $this->handler->handle($query);
return new GetContentSourceResource(
id: $response->id,
baseUrl: $response->baseUrl,
chapterUrlFormat: $response->chapterUrlFormat,
scrapingType: $response->scrapingType,
imageSelector: $response->imageSelector,
nextPageSelector: $response->nextPageSelector,
chapterSelector: $response->chapterSelector,
cleanBaseUrl: $response->cleanBaseUrl,
);
} catch (ContentSourceNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domain\Setting\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Setting\Application\Query\ListContentSourceQuery;
use App\Domain\Setting\Application\QueryHandler\ListContentSourceQueryHandler;
use App\Domain\Setting\Infrastructure\ApiPlatform\Resource\GetContentSourceResource;
readonly class ListContentSourceStateProvider implements ProviderInterface
{
public function __construct(
private ListContentSourceQueryHandler $handler
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$query = new ListContentSourceQuery();
$response = $this->handler->handle($query);
return array_map(
fn($contentSourceResponse) => new GetContentSourceResource(
id: $contentSourceResponse->id,
baseUrl: $contentSourceResponse->baseUrl,
chapterUrlFormat: $contentSourceResponse->chapterUrlFormat,
scrapingType: $contentSourceResponse->scrapingType,
imageSelector: $contentSourceResponse->imageSelector,
nextPageSelector: $contentSourceResponse->nextPageSelector,
chapterSelector: $contentSourceResponse->chapterSelector,
cleanBaseUrl: $contentSourceResponse->cleanBaseUrl,
),
$response->contentSources
);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domain\Setting\Infrastructure\Persistence\Mapper;
use App\Domain\Setting\Domain\Model\ContentSource;
use App\Entity\ContentSource as ContentSourceEntity;
readonly class ContentSourceMapper
{
public function toDomain(ContentSourceEntity $entity): ContentSource
{
return new ContentSource(
id: $entity->getId(),
baseUrl: $entity->getBaseUrl(),
chapterUrlFormat: $entity->getChapterUrlFormat(),
scrapingType: $entity->getScrapingType(),
imageSelector: $entity->getImageSelector(),
nextPageSelector: $entity->getNextPageSelector(),
chapterSelector: $entity->getChapterSelector(),
);
}
public function toEntity(ContentSource $contentSource): ContentSourceEntity
{
$entity = new ContentSourceEntity();
$entity->setBaseUrl($contentSource->getBaseUrl())
->setChapterUrlFormat($contentSource->getChapterUrlFormat())
->setScrapingType($contentSource->getScrapingType())
->setImageSelector($contentSource->getImageSelector())
->setNextPageSelector($contentSource->getNextPageSelector())
->setChapterSelector($contentSource->getChapterSelector());
return $entity;
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Domain\Setting\Infrastructure\Persistence\Repository;
use App\Domain\Setting\Domain\Contract\Repository\ContentSourceRepositoryInterface;
use App\Domain\Setting\Domain\Model\ContentSource;
use App\Domain\Setting\Infrastructure\Persistence\Mapper\ContentSourceMapper;
use App\Entity\ContentSource as ContentSourceEntity;
use Doctrine\ORM\EntityManagerInterface;
readonly class DoctrineContentSourceRepository implements ContentSourceRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private ContentSourceMapper $mapper
) {}
public function findAll(): array
{
$entities = $this->entityManager->getRepository(ContentSourceEntity::class)->findAll();
return array_map(
fn(ContentSourceEntity $entity) => $this->mapper->toDomain($entity),
$entities
);
}
public function findById(int $id): ?ContentSource
{
$entity = $this->entityManager->find(ContentSourceEntity::class, $id);
return $entity ? $this->mapper->toDomain($entity) : null;
}
public function save(ContentSource $contentSource): void
{
$entity = $this->mapper->toEntity($contentSource);
$this->entityManager->persist($entity);
$this->entityManager->flush();
// Met à jour l'ID du modèle du domaine si nécessaire
if ($entity->getId() && $contentSource->getId() === null) {
$contentSource->updateId($entity->getId());
}
}
public function delete(ContentSource $contentSource): void
{
if ($contentSource->getId()) {
$entity = $this->entityManager->find(ContentSourceEntity::class, $contentSource->getId());
if ($entity) {
$this->entityManager->remove($entity);
$this->entityManager->flush();
}
}
}
public function deleteAll(): void
{
$this->entityManager->createQuery('DELETE FROM ' . ContentSourceEntity::class)->execute();
}
public function saveMultiple(array $contentSources): void
{
foreach ($contentSources as $contentSource) {
$entity = $this->mapper->toEntity($contentSource);
$this->entityManager->persist($entity);
}
$this->entityManager->flush();
}
}