From bbb3acc234535ec02124af66217582b2a88f86df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Mon, 14 Sep 2026 19:26:16 +0200 Subject: [PATCH] feat(dto): update DTOs, value objects, sorters, pagination and configuration --- src/Lib/ApiConfig.php | 26 ++- src/Lib/ApiDto/Dto/Brand.php | 15 +- src/Lib/ApiDto/Dto/Inco.php | 13 +- src/Lib/ApiDto/Dto/Inco/Additive.php | 7 +- src/Lib/ApiDto/Dto/Inco/Allergen.php | 5 +- src/Lib/ApiDto/Dto/Inco/Ingredients.php | 7 +- src/Lib/ApiDto/Dto/Inco/Nutriscore.php | 3 +- src/Lib/ApiDto/Dto/Inco/PortionAmount.php | 2 +- src/Lib/ApiDto/Dto/Inco/StorageCondition.php | 3 +- .../IncoCompo/AbstractIncoCompoPartDto.php | 10 +- ...eeSortColum.php => EmployeeSortColumn.php} | 4 +- src/Lib/ApiSorter/EmployeeSorter.php | 14 ++ src/Lib/ApiSorter/IncoSortColumn.php | 2 +- src/Lib/ApiSorter/IncoSorter.php | 6 +- src/Lib/ApiSorter/SortColumn.php | 2 +- src/Lib/ApiValueObject/ArcadiaEmployeeId.php | 5 +- src/Lib/ApiValueObject/BrandCode.php | 11 +- src/Lib/ApiValueObject/IncoCompoId.php | 3 +- src/Lib/ApiValueObject/IncoId.php | 3 +- src/Lib/LibMiddleware.php | 17 ++ src/Lib/LibProperties.php | 30 ++++ src/Lib/PageSize.php | 53 ++++++ src/Lib/Pagination.php | 14 +- src/Lib/Pagination/StreamOf.php | 165 ++++++++++++++++++ 24 files changed, 350 insertions(+), 70 deletions(-) rename src/Lib/ApiSorter/{EmployeeSortColum.php => EmployeeSortColumn.php} (61%) create mode 100644 src/Lib/ApiSorter/EmployeeSorter.php create mode 100644 src/Lib/LibMiddleware.php create mode 100644 src/Lib/LibProperties.php create mode 100644 src/Lib/PageSize.php create mode 100644 src/Lib/Pagination/StreamOf.php diff --git a/src/Lib/ApiConfig.php b/src/Lib/ApiConfig.php index 73d5180..47d2250 100644 --- a/src/Lib/ApiConfig.php +++ b/src/Lib/ApiConfig.php @@ -4,31 +4,39 @@ declare(strict_types=1); namespace Uwil\ArcadiaApiPhpClient\Lib; +use OpenTelemetry\API\Trace\TracerInterface; use Psr\Http\Message\UriInterface; +use Psr\Log\LoggerInterface; use Uwil\ArcadiaApiPhpClient\Auth\ClientIdentifier; use Uwil\ArcadiaApiPhpClient\Auth\ClientSecret; -readonly class ApiConfig +final readonly class ApiConfig { public function __construct( #[\SensitiveParameter] - public readonly UriInterface $endpoint, + public UriInterface $endpoint, #[\SensitiveParameter] - public readonly ClientIdentifier $apiKey, + public ClientIdentifier $apiKey, #[\SensitiveParameter] - public readonly ClientSecret $secret, - public readonly float $curlTimeOut = 20.0, - public readonly int $maxRetries = 0, - public readonly int $retryDelayMs = 500, + public ClientSecret $secret, + public float $curlTimeOut = 20.0, + public int $maxRetries = 0, + public int $retryDelaySeconds = 1, + public bool $ignoreSelfSignedCert = false, + public ?LoggerInterface $logger = null, + public ?TracerInterface $tracer = null, ) { + if (! in_array(strtolower($endpoint->getScheme()), ['http', 'https'])) { + throw new \InvalidArgumentException('endpoint must have http or https scheme'); + } if ($curlTimeOut < 0) { throw new \InvalidArgumentException('curlTimeOut must be non-negative'); } if ($maxRetries < 0) { throw new \InvalidArgumentException('maxRetries must be non-negative'); } - if ($retryDelayMs < 0) { - throw new \InvalidArgumentException('retryDelayMs must be non-negative'); + if ($retryDelaySeconds < 0) { + throw new \InvalidArgumentException('retryDelaySeconds must be non-negative'); } } } diff --git a/src/Lib/ApiDto/Dto/Brand.php b/src/Lib/ApiDto/Dto/Brand.php index 4263cfa..5a30de3 100644 --- a/src/Lib/ApiDto/Dto/Brand.php +++ b/src/Lib/ApiDto/Dto/Brand.php @@ -4,18 +4,19 @@ declare(strict_types=1); namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto; -use Uwil\ArcadiaApiPhpClient\Exception\DomainException; use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto; use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\BrandCode; final readonly class Brand implements Dto { public function __construct( - public readonly BrandCode $code, - public readonly string $label + public BrandCode $code, + public string $label, + public ?string $arcadiaId = null, + public ?\DateTimeInterface $createdAt = null, ) { if (empty(mb_trim($label))) { - throw new DomainException( + throw new \DomainException( "Invalid label '{$label}' should not be empty" ); } @@ -24,8 +25,10 @@ final readonly class Brand implements Dto public function jsonSerialize(): mixed { return [ - 'code' => $this->code->value, - 'label' => $this->label, + 'code' => $this->code->value, + 'label' => $this->label, + 'arcadiaId' => $this->arcadiaId, + 'createdAt' => $this->createdAt?->format(\DateTimeInterface::ATOM), ]; } } diff --git a/src/Lib/ApiDto/Dto/Inco.php b/src/Lib/ApiDto/Dto/Inco.php index 84a93ea..566819f 100644 --- a/src/Lib/ApiDto/Dto/Inco.php +++ b/src/Lib/ApiDto/Dto/Inco.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto; -use Uwil\ArcadiaApiPhpClient\Exception\UnexpectedErrorException; use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto; use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\Additive; use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\Allegations; @@ -46,17 +45,17 @@ final readonly class Inco implements Dto public ?StorageCondition $storageCondition, public array $additives, public ?IncoCompoId $compo, - public ?\DateTimeInterface $created_at, - public ?\DateTimeInterface $updated_at, + public ?\DateTimeInterface $createdAt, + public ?\DateTimeInterface $updatedAt, ) { foreach ($this->allergens as $allergen) { if (! is_object($allergen) || ! ($allergen instanceof Allergen)) { - throw new UnexpectedErrorException('Allergens shall be instance of ' . Allergen::class); + throw new \InvalidArgumentException('Allergens shall be instance of ' . Allergen::class); } } foreach ($this->additives as $additive) { if (! is_object($additive) || ! ($additive instanceof Additive)) { - throw new UnexpectedErrorException('Additives shall be instance of ' . Additive::class); + throw new \InvalidArgumentException('Additives shall be instance of ' . Additive::class); } } } @@ -86,8 +85,8 @@ final readonly class Inco implements Dto 'storageCondition' => $this->storageCondition, 'additives' => $this->additives, 'compo' => $this->compo?->value, - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, ]; } } diff --git a/src/Lib/ApiDto/Dto/Inco/Additive.php b/src/Lib/ApiDto/Dto/Inco/Additive.php index 3c92453..b929f21 100644 --- a/src/Lib/ApiDto/Dto/Inco/Additive.php +++ b/src/Lib/ApiDto/Dto/Inco/Additive.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco; -use Uwil\ArcadiaApiPhpClient\Exception\DomainException; use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto; /** @@ -25,16 +24,16 @@ final readonly class Additive implements Dto public readonly string $label, ) { $this->code = $code; - $trimmedLabel = trim($label); + $trimmedLabel = mb_trim($label); if (! preg_match('/^E\d+(?:[a-z]+(?:\([ivx]+\))?)?$/', $this->code)) { - throw new DomainException( + throw new \DomainException( "Invalid additive code '{$code}': must match E-number format (e.g. E300, E471a, E160ai, E160a(ii), E1404)" ); } if ($trimmedLabel === '') { - throw new DomainException( + throw new \DomainException( "Invalid label for additive '{$code}': must not be empty" ); } diff --git a/src/Lib/ApiDto/Dto/Inco/Allergen.php b/src/Lib/ApiDto/Dto/Inco/Allergen.php index c30d49d..0449f34 100644 --- a/src/Lib/ApiDto/Dto/Inco/Allergen.php +++ b/src/Lib/ApiDto/Dto/Inco/Allergen.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco; -use Uwil\ArcadiaApiPhpClient\Exception\DomainException; use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto; final readonly class Allergen implements Dto @@ -14,13 +13,13 @@ final readonly class Allergen implements Dto public readonly string $label ) { if ($code <= 0) { - throw new DomainException( + throw new \DomainException( "Invalid code '{$code}' shall be greater than 0" ); } if (empty(mb_trim($label))) { - throw new DomainException( + throw new \DomainException( "Invalid label '{$label}' should not be empty" ); } diff --git a/src/Lib/ApiDto/Dto/Inco/Ingredients.php b/src/Lib/ApiDto/Dto/Inco/Ingredients.php index cbf99e9..9890b98 100644 --- a/src/Lib/ApiDto/Dto/Inco/Ingredients.php +++ b/src/Lib/ApiDto/Dto/Inco/Ingredients.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco; -use Uwil\ArcadiaApiPhpClient\Exception\DomainException; use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto; /** @@ -27,20 +26,20 @@ final readonly class Ingredients implements Dto ) { foreach ($groups as $key => $lines) { if (! is_array($lines)) { - throw new DomainException( + throw new \DomainException( "Invalid ingredient group at key '{$key}': value must be an array of strings" ); } foreach ($lines as $i => $line) { if (! is_string($line)) { - throw new DomainException( + throw new \DomainException( "Invalid ingredient at group '{$key}', index '{$i}': value must be a string" ); } if (empty(mb_trim($line))) { - throw new DomainException( + throw new \DomainException( "Invalid ingredient at group '{$key}', index '{$i}': string should not be empty" ); } diff --git a/src/Lib/ApiDto/Dto/Inco/Nutriscore.php b/src/Lib/ApiDto/Dto/Inco/Nutriscore.php index 9f884b0..2ea0ed6 100644 --- a/src/Lib/ApiDto/Dto/Inco/Nutriscore.php +++ b/src/Lib/ApiDto/Dto/Inco/Nutriscore.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco; -use Uwil\ArcadiaApiPhpClient\Exception\DomainException; use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto; /** @@ -20,7 +19,7 @@ final readonly class Nutriscore implements Dto public readonly string $grade, ) { if (! in_array($this->grade, self::VALID_GRADES, true)) { - throw new DomainException( + throw new \DomainException( sprintf( "Invalid Nutri-Score grade '%s': must be one of %s", $grade, diff --git a/src/Lib/ApiDto/Dto/Inco/PortionAmount.php b/src/Lib/ApiDto/Dto/Inco/PortionAmount.php index 8cad78a..4ef95cb 100644 --- a/src/Lib/ApiDto/Dto/Inco/PortionAmount.php +++ b/src/Lib/ApiDto/Dto/Inco/PortionAmount.php @@ -197,6 +197,6 @@ final readonly class PortionAmount implements \JsonSerializable, \Stringable, Dt $decimals = self::ALLOWED_DECIMAL_SIZE; $format = number_format($value, $decimals, '.', ''); - return rtrim(rtrim($format, '0'), '.'); + return mb_rtrim(mb_rtrim($format, '0'), '.'); } } diff --git a/src/Lib/ApiDto/Dto/Inco/StorageCondition.php b/src/Lib/ApiDto/Dto/Inco/StorageCondition.php index abade13..018cda3 100644 --- a/src/Lib/ApiDto/Dto/Inco/StorageCondition.php +++ b/src/Lib/ApiDto/Dto/Inco/StorageCondition.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco; -use Uwil\ArcadiaApiPhpClient\Exception\DomainException; use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto; /** @@ -18,7 +17,7 @@ final readonly class StorageCondition implements Dto public readonly string $instructions, ) { if (empty(mb_trim($this->instructions))) { - throw new DomainException( + throw new \DomainException( 'Invalid instructions: must not be empty' ); } diff --git a/src/Lib/ApiDto/Dto/IncoCompo/AbstractIncoCompoPartDto.php b/src/Lib/ApiDto/Dto/IncoCompo/AbstractIncoCompoPartDto.php index 83be2c8..734e3f3 100644 --- a/src/Lib/ApiDto/Dto/IncoCompo/AbstractIncoCompoPartDto.php +++ b/src/Lib/ApiDto/Dto/IncoCompo/AbstractIncoCompoPartDto.php @@ -36,13 +36,13 @@ abstract readonly class AbstractIncoCompoPartDto implements IncoCompoPartDto */ public function toArray(): array { - // Reflection on public properties of the concrete child class. - $ref = new \ReflectionObject($this); + /** @var array $result */ $result = []; - foreach ($ref->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { - $name = $property->getName(); - $result[$name] = $property->getValue($this); + foreach (get_object_vars($this) as $name => $value) { + if (\is_float($value) || \is_string($value) || $value === null) { + $result[(string) $name] = $value; + } } return $result; diff --git a/src/Lib/ApiSorter/EmployeeSortColum.php b/src/Lib/ApiSorter/EmployeeSortColumn.php similarity index 61% rename from src/Lib/ApiSorter/EmployeeSortColum.php rename to src/Lib/ApiSorter/EmployeeSortColumn.php index cf70b06..3f47e90 100644 --- a/src/Lib/ApiSorter/EmployeeSortColum.php +++ b/src/Lib/ApiSorter/EmployeeSortColumn.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject; +namespace Uwil\ArcadiaApiPhpClient\Lib\ApiSorter; -enum EmployeeSortColum implements SortColumn +enum EmployeeSortColumn implements SortColumn { case arcadiaId; case startDate; diff --git a/src/Lib/ApiSorter/EmployeeSorter.php b/src/Lib/ApiSorter/EmployeeSorter.php new file mode 100644 index 0000000..b8751e1 --- /dev/null +++ b/src/Lib/ApiSorter/EmployeeSorter.php @@ -0,0 +1,14 @@ + Sans marque + * Unique public brand code (e.g. "LGL01", "ARC01"). */ final readonly class BrandCode implements ValueObjectId { public function __construct( - public readonly int $value + public string $value ) { - if ($value <= 0) { - throw new DomainException( - "Invalid code '{$value}' shall be greater than 0" + if (! preg_match('/^[A-Z]{3}\d{2}$/', $value)) { + throw new \DomainException( + "Invalid code '{$value}' does not match pattern /^[A-Z]{3}\d{2}$/" ); } } diff --git a/src/Lib/ApiValueObject/IncoCompoId.php b/src/Lib/ApiValueObject/IncoCompoId.php index 81156c4..530a2ea 100644 --- a/src/Lib/ApiValueObject/IncoCompoId.php +++ b/src/Lib/ApiValueObject/IncoCompoId.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject; -use DomainException; use Uwil\ArcadiaApiPhpClient\Lib\ValueObjectId; final readonly class IncoCompoId implements ValueObjectId @@ -15,7 +14,7 @@ final readonly class IncoCompoId implements ValueObjectId public readonly string $value ) { if (! preg_match(self::PATTERN, $value)) { - throw new DomainException( + throw new \DomainException( "Invalid value '{$value}' does not match pattern " . self::PATTERN ); } diff --git a/src/Lib/ApiValueObject/IncoId.php b/src/Lib/ApiValueObject/IncoId.php index 422206e..a6b45a9 100644 --- a/src/Lib/ApiValueObject/IncoId.php +++ b/src/Lib/ApiValueObject/IncoId.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject; -use Uwil\ArcadiaApiPhpClient\Exception\DomainException; use Uwil\ArcadiaApiPhpClient\Lib\ValueObjectId; final readonly class IncoId implements ValueObjectId @@ -15,7 +14,7 @@ final readonly class IncoId implements ValueObjectId public readonly string $value ) { if (! preg_match(self::PATTERN, $value)) { - throw new DomainException( + throw new \DomainException( "Invalid value '{$value}' does not match pattern " . self::PATTERN ); } diff --git a/src/Lib/LibMiddleware.php b/src/Lib/LibMiddleware.php new file mode 100644 index 0000000..b046cb1 --- /dev/null +++ b/src/Lib/LibMiddleware.php @@ -0,0 +1,17 @@ + $options */ + public function __invoke(RequestInterface $request, array $options): PromiseInterface; +} diff --git a/src/Lib/LibProperties.php b/src/Lib/LibProperties.php new file mode 100644 index 0000000..891b061 --- /dev/null +++ b/src/Lib/LibProperties.php @@ -0,0 +1,30 @@ +productName = self::PACKAGE_NAME; + $this->productVersion = $version; + } +} diff --git a/src/Lib/PageSize.php b/src/Lib/PageSize.php new file mode 100644 index 0000000..0b2c7d3 --- /dev/null +++ b/src/Lib/PageSize.php @@ -0,0 +1,53 @@ +value <= 0) { + throw new \InvalidArgumentException('Page size must be greater than 0'); + } + + if ($this->value > self::MAX_PAGE_SIZE) { + throw new \InvalidArgumentException('Page size must be less than or equal to ' . self::MAX_PAGE_SIZE); + } + } + + public static function of(int $value): self + { + return new self($value); + } + + public static function default(): self + { + return new self(self::DEFAULT_SIZE); + } + + public function value(): int + { + return $this->value; + } + + public function __toString(): string + { + return (string) $this->value; + } + + public function jsonSerialize(): int + { + return $this->value; + } +} diff --git a/src/Lib/Pagination.php b/src/Lib/Pagination.php index fccb648..2029625 100644 --- a/src/Lib/Pagination.php +++ b/src/Lib/Pagination.php @@ -10,7 +10,7 @@ namespace Uwil\ArcadiaApiPhpClient\Lib; final class Pagination { /** ERR_TOO_MANY_RECORDS fixed to 10k */ - public const MAX_PAGE_SIZE = 10000; + public const int MAX_PAGE_SIZE = PageSize::MAX_PAGE_SIZE; public function __construct( private readonly int $from, @@ -55,17 +55,19 @@ final class Pagination return new self($from, $to); } - public static function ofPage(int $pageNumber, int $pageSize): self + public static function ofPage(int $pageNumber, int | PageSize $pageSize): self { + $resolvedPageSize = $pageSize instanceof PageSize ? $pageSize->value : $pageSize; + if ($pageNumber <= 0) { throw new \InvalidArgumentException('Page number must be positive and non-zero'); } - if ($pageSize <= 0) { + if ($resolvedPageSize <= 0) { throw new \InvalidArgumentException('Page size must be positive and non-zero'); } - $fromIndex = ($pageNumber - 1) * $pageSize; - $toIndex = max($pageNumber * $pageSize - 1, 1); + $fromIndex = ($pageNumber - 1) * $resolvedPageSize; + $toIndex = max($pageNumber * $resolvedPageSize - 1, 1); return new self($fromIndex, $toIndex); } @@ -85,7 +87,7 @@ final class Pagination return new self($fromIndex, $toIndex); } - public static function firstPage(int $pageSize = 25): self + public static function firstPage(int | PageSize $pageSize = new PageSize()): self { return self::ofPage(1, $pageSize); } diff --git a/src/Lib/Pagination/StreamOf.php b/src/Lib/Pagination/StreamOf.php new file mode 100644 index 0000000..f794f04 --- /dev/null +++ b/src/Lib/Pagination/StreamOf.php @@ -0,0 +1,165 @@ + + */ +final class StreamOf implements \IteratorAggregate +{ + private ?ApiError $lastError = null; + + private ?PaginationDto $firstPagePagination = null; + + /** + * @param \Closure(Pagination):(\Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PageOf|ApiError) $pageFetcher + */ + public function __construct( + private readonly \Closure $pageFetcher, + private readonly PageSize $pageSize = new PageSize(), + ) { + } + + /** + * Iterate over individual items of type T. + * + * @return \Generator + */ + public function getIterator(): \Generator + { + foreach ($this->pages() as $page) { + foreach ($page->items() as $item) { + yield $item; + } + } + + return $this->lastError; + } + + /** + * Iterate page by page (useful for batching operations). + * + * @return \Generator, mixed, ?ApiError> + */ + public function pages(): \Generator + { + $this->lastError = null; + $pageNumber = 1; + + do { + $pagination = Pagination::ofPage($pageNumber, $this->pageSize); + $result = ($this->pageFetcher)($pagination); + + if ($result instanceof ApiError) { + $this->lastError = $result; + + return $result; + } + + if ($this->firstPagePagination === null) { + $this->firstPagePagination = $result->pagination(); + } + + yield $result; + + if (! $result->pagination()->hasNextPage()) { + break; + } + + $pageNumber++; + } while (true); + + return null; + } + + /** + * Execute a callback on each item. + * Stops and returns the ApiError if an error occurs, or stops if the callback returns false. + * + * @param callable(T): (bool|void) $callback + */ + public function each(callable $callback): ?ApiError + { + foreach ($this->getIterator() as $item) { + if ($callback($item) === false) { + break; + } + } + + return $this->lastError; + } + + /** + * Yield at most $limit items without requesting subsequent pages unnecessarily. + * + * @return \Generator + */ + public function take(int $limit): \Generator + { + if ($limit <= 0) { + return; + } + + $count = 0; + foreach ($this->getIterator() as $item) { + yield $item; + $count++; + if ($count >= $limit) { + break; + } + } + } + + /** + * Get the first item of the stream, or null if empty, or ApiError on failure. + * + * @return T|ApiError|null + */ + public function first(): mixed + { + foreach ($this->take(1) as $item) { + return $item; + } + + return $this->lastError; + } + + public function lastError(): ?ApiError + { + return $this->lastError; + } + + public function hasError(): bool + { + return $this->lastError !== null; + } + + /** + * Total number of items reported by the API (available once at least one page is fetched). + */ + public function totalCount(): ?int + { + return $this->firstPagePagination?->totalItems; + } + + /** + * Total number of pages reported by the API (available once at least one page is fetched). + */ + public function totalPages(): ?int + { + return $this->firstPagePagination?->totalPages; + } +}