feat(dto): update DTOs, value objects, sorters, pagination and configuration

This commit is contained in:
Jérémy Munsch 2026-09-14 19:26:16 +02:00
parent 019e7a4025
commit bbb3acc234
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
24 changed files with 350 additions and 70 deletions

View file

@ -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');
}
}
}

View file

@ -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),
];
}
}

View file

@ -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,
];
}
}

View file

@ -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"
);
}

View file

@ -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"
);
}

View file

@ -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"
);
}

View file

@ -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,

View file

@ -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'), '.');
}
}

View file

@ -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'
);
}

View file

@ -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<string, float|string|null> $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;

View file

@ -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;

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiSorter;
final readonly class EmployeeSorter
{
public function __construct(
public EmployeeSortColumn $sortBy,
public \SortDirection $direction = \SortDirection::Descending
) {
}
}

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject;
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiSorter;
enum IncoSortColumn implements SortColumn
{

View file

@ -2,15 +2,13 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject;
use Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder\Direction;
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiSorter;
final readonly class IncoSorter
{
public function __construct(
public IncoSortColumn $sortBy,
public Direction $direction
public \SortDirection $direction = \SortDirection::Descending
) {
}
}

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject;
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiSorter;
interface SortColumn extends \UnitEnum
{

View file

@ -4,18 +4,17 @@ declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject;
use Uwil\ArcadiaApiPhpClient\Exception\DomainException;
use Uwil\ArcadiaApiPhpClient\Lib\ValueObjectId;
final readonly class ArcadiaEmployeeId implements ValueObjectId
{
private const PATTERN = '/^\d{7, 8}$/';
private const PATTERN = '/^\d{7,8}$/';
public function __construct(
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
);
}

View file

@ -4,20 +4,19 @@ declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject;
use DomainException;
use Uwil\ArcadiaApiPhpClient\Lib\ValueObjectId;
/**
* eg: 13 -> 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}$/"
);
}
}

View file

@ -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
);
}

View file

@ -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
);
}

17
src/Lib/LibMiddleware.php Normal file
View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
/**
* @internal
*/
interface LibMiddleware
{
/** @param array<string, mixed> $options */
public function __invoke(RequestInterface $request, array $options): PromiseInterface;
}

30
src/Lib/LibProperties.php Normal file
View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib;
use Composer\InstalledVersions;
final readonly class LibProperties
{
public const string PACKAGE_NAME = 'uwil/arcadia-api-php-client';
public string $productName;
public string $productVersion;
public function __construct()
{
$version = InstalledVersions::getPrettyVersion(self::PACKAGE_NAME);
if ($version === null) {
throw new \UnexpectedValueException(
sprintf("Unable to determine the version for package '%s'.", self::PACKAGE_NAME)
);
}
$this->productName = self::PACKAGE_NAME;
$this->productVersion = $version;
}
}

53
src/Lib/PageSize.php Normal file
View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib;
/**
* Value object representing a page size in pagination.
*/
final readonly class PageSize implements \JsonSerializable, \Stringable
{
public const int DEFAULT_SIZE = 50;
/** ERR_TOO_MANY_RECORDS fixed to 10k */
public const int MAX_PAGE_SIZE = 10000;
public function __construct(
public int $value = self::DEFAULT_SIZE,
) {
if ($this->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;
}
}

View file

@ -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);
}

View file

@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\Pagination;
use Uwil\ArcadiaApiPhpClient\Error\ApiError;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PaginationDto;
use Uwil\ArcadiaApiPhpClient\Lib\PageSize;
use Uwil\ArcadiaApiPhpClient\Lib\Pagination;
/**
* Lazy stream / cursor iterator over paginated API resources.
*
* Provides continuous O(1) memory iteration across all pages.
*
* @template T
*
* @implements \IteratorAggregate<int, T>
*/
final class StreamOf implements \IteratorAggregate
{
private ?ApiError $lastError = null;
private ?PaginationDto $firstPagePagination = null;
/**
* @param \Closure(Pagination):(\Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PageOf<T>|ApiError) $pageFetcher
*/
public function __construct(
private readonly \Closure $pageFetcher,
private readonly PageSize $pageSize = new PageSize(),
) {
}
/**
* Iterate over individual items of type T.
*
* @return \Generator<int, T, mixed, ?ApiError>
*/
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<int,\Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PageOf<T>, 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<int, T>
*/
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;
}
}