refactor(legacy): remove deprecated Genesis utilities, parsers, query builders, mappers and tests

This commit is contained in:
Jérémy Munsch 2026-09-14 19:26:07 +02:00
parent eb10792328
commit c5f7199b9c
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
58 changed files with 0 additions and 6188 deletions

View file

@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient;
use DI\Container;
use DI\ContainerBuilder;
use Uwil\ArcadiaApiPhpClient\Di\PackageDi;
use Uwil\ArcadiaApiPhpClient\Lib\ApiConfig;
use Uwil\ArcadiaApiPhpClient\Service\EmployeeService;
use Uwil\ArcadiaApiPhpClient\Service\IncoService;
final class ArcadiaGenesisClient
{
private readonly Container $container;
public function __construct(
private readonly ApiConfig $apiConfig
) {
$builder = new ContainerBuilder();
PackageDi::initDi(
$builder,
$this->apiConfig
);
$this->container = $builder->build();
}
public function employees(): EmployeeService
{
return $this->container->get(EmployeeService::class);
}
public function incoSheets(): IncoService
{
return $this->container->get(IncoService::class);
}
}

View file

@ -1,86 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Di;
use DI\Container;
use DI\ContainerBuilder;
use GuzzleHttp\Client;
use Uwil\ArcadiaApiPhpClient\Lib\ApiCaller;
use Uwil\ArcadiaApiPhpClient\Lib\ApiConfig;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Factory\EmployeeDtoFactory;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Factory\IncoCompoDtoFactory;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Factory\IncoDtoFactory;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Factory\IncoShortDtoFactory;
use Uwil\ArcadiaApiPhpClient\Lib\Psr7\RequestFactory;
use Uwil\ArcadiaApiPhpClient\Mapper\EmployeeMapper;
use Uwil\ArcadiaApiPhpClient\Mapper\IncoCompoMapper;
use Uwil\ArcadiaApiPhpClient\Mapper\IncoMapper;
use Uwil\ArcadiaApiPhpClient\Mapper\IncoShortMapper;
use Uwil\ArcadiaApiPhpClient\Repository\EmployeeGenesisRepository;
use Uwil\ArcadiaApiPhpClient\Repository\EmployeeRepository;
use Uwil\ArcadiaApiPhpClient\Repository\IncoCompoGenesisRepository;
use Uwil\ArcadiaApiPhpClient\Repository\IncoCompoRepository;
use Uwil\ArcadiaApiPhpClient\Repository\IncoGenesisRepository;
use Uwil\ArcadiaApiPhpClient\Repository\IncoRepository;
use Uwil\ArcadiaApiPhpClient\Repository\IncoShortGenesisRepository;
use Uwil\ArcadiaApiPhpClient\Repository\IncoShortRepository;
final readonly class PackageDi
{
/**
* @param \DI\ContainerBuilder<\DI\Container> $containerBuilder
*/
public static function initDi(
ContainerBuilder $containerBuilder,
ApiConfig $apiConfig
): void {
$containerBuilder->addDefinitions([
ApiConfig::class => fn () => $apiConfig,
Client::class => fn (Container $container) => new Client([
'base_uri' => $container->get(ApiConfig::class)->endpoint,
'timeout' => $container->get(ApiConfig::class)->curlTimeOut,
]),
ApiCaller::class => fn (Container $container) => new ApiCaller(
$container->get(Client::class),
$container->get(ApiConfig::class),
$container->get(RequestFactory::class),
),
EmployeeDtoFactory::class => fn (Container $container) => new EmployeeDtoFactory(
$container->get(EmployeeMapper::class)
),
IncoDtoFactory::class => fn (Container $container) => new IncoDtoFactory(
$container->get(IncoMapper::class)
),
IncoShortDtoFactory::class => fn (Container $container) => new IncoShortDtoFactory(
$container->get(IncoShortMapper::class)
),
IncoCompoDtoFactory::class => fn (Container $container) => new IncoCompoDtoFactory(
$container->get(IncoCompoMapper::class)
),
EmployeeRepository::class => fn (Container $container) => new EmployeeGenesisRepository(
$container->get(ApiCaller::class),
$container->get(EmployeeDtoFactory::class),
$container->get(EmployeeMapper::class),
),
IncoRepository::class => fn (Container $container) => new IncoGenesisRepository(
$container->get(ApiCaller::class),
$container->get(IncoDtoFactory::class),
$container->get(IncoMapper::class),
),
IncoShortRepository::class => fn (Container $container) => new IncoShortGenesisRepository(
$container->get(ApiCaller::class),
$container->get(IncoShortDtoFactory::class),
$container->get(IncoMapper::class),
),
IncoCompoRepository::class => fn (Container $container) => new IncoCompoGenesisRepository(
$container->get(ApiCaller::class),
$container->get(IncoCompoDtoFactory::class),
$container->get(IncoCompoMapper::class),
),
]);
}
}

View file

@ -1,96 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Enum;
enum ArcadiaGenesisApiErrorCode: string
{
// UnDocumented
case E_0004 = '0004';
// 400 - BAD_REQUEST
case ERR_UNAUTHORIZED_PARAMETER = 'ERR_UNAUTHORIZED_PARAMETER';
/** Vous n'êtes pas autorisé à faire cette action. */
case ERR_DEFAULT = 'ERR_DEFAULT';
/** Le nombre de colonnes, d'opérateurs et de valeurs ne correspond pas. */
case ERR_LIST_SIZE_MISMATCH = 'ERR_LIST_SIZE_MISMATCH';
/** Une MetaException a été levée. Une erreur s'est produite. */
case ERR_METAEXCEPTION = 'ERR_METAEXCEPTION';
/** La liste de colonnes de tri n'est pas de la même taille que la liste d'opérateurs de tri. */
case ERR_SORT_LIST_SIZE_MISMATCH = 'ERR_SORT_LIST_SIZE_MISMATCH';
/** Le nom de la table n'est pas fourni. */
case ERR_TABLE_NAME_MISSING = 'ERR_TABLE_NAME_MISSING';
/** La liste d'opérateurs contient des opérateurs non autorisés. */
case ERR_UNAUTHORIZED_OPERATOR = 'ERR_UNAUTHORIZED_OPERATOR';
/** La date doit être fournie au format ISO 8601 (ex : 2025-10-21T14:30:00Z). */
case ERR_BAD_DATE_FORMAT = 'ERR_BAD_DATE_FORMAT';
/** Le type pour le paramètre 'page' doit être un entier supérieur à 0. */
case ERR_PAGE_TYPE_MISMATCH = 'ERR_PAGE_TYPE_MISMATCH';
/** Le type pour le paramètre 'page_size' doit être un entier supérieur à 0. */
case ERR_PAGE_SIZE_TYPE_MISMATCH = 'ERR_PAGE_SIZE_TYPE_MISMATCH';
/** Le nombre d'enregistrements retournés doit être <= 10 000. */
case ERR_TOO_MANY_RECORDS = 'ERR_TOO_MANY_RECORDS';
// 401 - UNAUTHORIZED
/** La clé API n'est pas valide. */
case ERR_INVALID_API_KEY = 'ERR_INVALID_API_KEY';
/** La signature n'est pas valide. */
case ERR_INVALID_API_SIGNATURE = 'ERR_INVALID_API_SIGNATURE';
// 429 - TOO_MANY_REQUESTS
/** Le nombre maximal de requêtes autorisées a été atteint. Veuillez réessayer ultérieurement. */
case ERR_TOO_MANY_REQUESTS = 'ERR_TOO_MANY_REQUESTS';
// 503 - SERVICE_UNAVAILABLE
// Pas de code d'erreur spécifique documenté
case UNKNOWN = 'UNKNOWN';
/** phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh */
public function description(): string
{
return match ($this) {
self::ERR_DEFAULT => 'Vous n\'êtes pas autorisé à faire cette action.',
// phpcs:ignore Generic.Files.LineLength.TooLong
self::ERR_UNAUTHORIZED_PARAMETER => 'Cette clé API n\'a pas le droit de lire les données de toutes les colonnes sélectionnées pour cette table.',
self::ERR_INVALID_API_KEY => 'La clé API n\'est pas valide.',
self::ERR_INVALID_API_SIGNATURE => 'La signature n\'est pas valide.',
self::ERR_LIST_SIZE_MISMATCH => 'Le nombre de colonnes, d\'opérateurs et de valeurs ne correspond pas.',
self::ERR_METAEXCEPTION => 'Une MetaException a été levée. Une erreur s\'est produite.',
self::ERR_SORT_LIST_SIZE_MISMATCH => 'La liste de colonnes de tri n\'est pas de la même taille que la liste d\'opérateurs de tri.',
self::ERR_TABLE_NAME_MISSING => 'Le nom de la table n\'est pas fourni.',
self::ERR_UNAUTHORIZED_OPERATOR => 'La liste d\'opérateurs contient des opérateurs non autorisés.',
self::ERR_BAD_DATE_FORMAT => 'La date doit être fournie au format ISO 8601 (ex : 2025-10-21T14:30:00Z).',
self::ERR_PAGE_TYPE_MISMATCH => 'Le type pour le paramètre \'page\' doit être un entier supérieur à 0.',
self::ERR_PAGE_SIZE_TYPE_MISMATCH => 'Le type pour le paramètre \'page_size\' doit être un entier supérieur à 0.',
self::ERR_TOO_MANY_RECORDS => 'Le nombre d\'enregistrements retournés doit être <= 10 000.',
self::ERR_TOO_MANY_REQUESTS => 'Le nombre maximal de requêtes autorisées a été atteint. Veuillez réessayer ultérieurement.',
self::UNKNOWN => 'Message d\'erreur inconnu',
default => 'Message d\'erreur inconnu',
};
}
public function httpStatusCode(): int
{
return match ($this) {
self::ERR_DEFAULT,
self::ERR_UNAUTHORIZED_PARAMETER,
self::ERR_LIST_SIZE_MISMATCH,
self::ERR_METAEXCEPTION,
self::ERR_SORT_LIST_SIZE_MISMATCH,
self::ERR_TABLE_NAME_MISSING,
self::ERR_UNAUTHORIZED_OPERATOR,
self::ERR_BAD_DATE_FORMAT,
self::ERR_PAGE_TYPE_MISMATCH,
self::ERR_PAGE_SIZE_TYPE_MISMATCH,
self::ERR_TOO_MANY_RECORDS => 400,
self::ERR_INVALID_API_KEY,
self::ERR_INVALID_API_SIGNATURE => 401,
self::ERR_TOO_MANY_REQUESTS => 429,
self::UNKNOWN => 0,
default => 0,
};
}
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Exception;
class ApiCallFailed extends \RuntimeException implements ArcadiaGenesisApiException
{
}

View file

@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Exception;
use Uwil\ArcadiaApiPhpClient\Enum\ArcadiaGenesisApiErrorCode;
class ApiErrorException extends \DomainException implements ArcadiaGenesisApiException
{
public readonly ArcadiaGenesisApiErrorCode $apiError;
public function __construct(ArcadiaGenesisApiErrorCode $apiError, ?\Throwable $previous = null)
{
$this->apiError = $apiError;
parent::__construct(
$apiError->description(),
$apiError->httpStatusCode(),
$previous
);
}
/**
* @param array<string,mixed> $error
*/
public static function fromResponseError(array $error): self
{
// FIXME: Patch degueu en attendant une correction de leur part.
// * Si l'api retourne de multiples erreurs, ce qu'elle n'est pas sensée faire alors on prend la derniere du tableau.
if (count($error) > 1 && is_int(array_keys($error)[0] ?? null)) {
$error = array_pop($error);
}
$code = ArcadiaGenesisApiErrorCode::tryFrom($error['code']) ?? ArcadiaGenesisApiErrorCode::UNKNOWN;
return new self($code);
}
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Exception;
interface ArcadiaGenesisApiException extends \Stringable
{
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Exception;
class DomainException extends \DomainException implements ArcadiaGenesisApiException
{
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Exception;
class InvalidApiResponse extends \DomainException implements ArcadiaGenesisApiException
{
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Exception;
class UnexpectedErrorException extends \ErrorException implements ArcadiaGenesisApiException
{
}

View file

@ -1,208 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\ServerException;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Uwil\ArcadiaApiPhpClient\Enum\ArcadiaGenesisApiErrorCode;
use Uwil\ArcadiaApiPhpClient\Exception\ApiCallFailed;
use Uwil\ArcadiaApiPhpClient\Exception\ApiErrorException;
use Uwil\ArcadiaApiPhpClient\Exception\InvalidApiResponse;
use Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder\TableQuery;
use Uwil\ArcadiaApiPhpClient\Lib\ApiResponse\ApiResponse;
use Uwil\ArcadiaApiPhpClient\Lib\Psr7\RequestFactory;
use Uwil\ArcadiaApiPhpClient\Utils\Utils;
class ApiCaller
{
public function __construct(
private readonly ClientInterface $client,
private readonly ApiConfig $apiConfig,
private readonly RequestFactory $requestFactory
) {
}
public function callApi(string $queryPath, TableQuery $query): ApiResponse
{
$uri = Utils::appendPathTo(
$this->apiConfig->endpoint,
$queryPath
);
$request = $this->requestFactory->buildRequest(
HttpMethod::GET,
$uri,
$query->toArray()
);
return $this->callWithRetry($request);
}
protected function callWithRetry(RequestInterface $request): ApiResponse
{
$maxAttempts = $this->apiConfig->maxRetries + 1;
$lastException = new \RuntimeException("Max attempts '{$maxAttempts}' reached on api call");
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
try {
return $this->call($request);
} catch (ApiCallFailed $e) {
$lastException = $e;
if ($attempt >= $maxAttempts) {
break;
}
$this->delay($attempt);
} catch (ApiErrorException $e) {
if ($e->apiError !== ArcadiaGenesisApiErrorCode::ERR_TOO_MANY_REQUESTS) {
throw $e;
}
$lastException = $e;
if ($attempt >= $maxAttempts) {
break;
}
$this->delay($attempt);
}
}
throw $lastException;
}
protected function call(RequestInterface $request): ApiResponse
{
try {
$response = $this->client->sendRequest($request);
$this->assertResponseHasExpectedHeaders($response);
$body = $this->parseResponseBody($response);
$body = $this->assertApiResponseFormIsAsExpected($body);
if ($this->apiResponseIsError($body)) {
throw ApiErrorException::fromResponseError($body['error']);
}
return ApiResponse::fromBody($body);
} catch (ConnectException $e) {
throw new ApiCallFailed(
'Failed to connect to Api backend: ' . $e->getMessage(),
$e->getCode(),
$e
);
} catch (ClientException $e) {
throw new ApiCallFailed(
'Failed to call Api backend seeing a ClientException',
$e->getCode(),
$e
);
} catch (ServerException $e) {
$response = $e->getResponse();
$body = $this->parseResponseBody($response);
if ($this->apiResponseIsError($body)) {
throw ApiErrorException::fromResponseError($body['error']);
}
throw new ApiErrorException(
ArcadiaGenesisApiErrorCode::UNKNOWN,
$e
);
}
}
/**
* @throws \Uwil\ArcadiaApiPhpClient\Exception\InvalidApiResponse
*/
protected function parseResponseBody(ResponseInterface $response): mixed
{
$bodyContent = $response->getBody()->getContents();
if (! json_validate($bodyContent)) {
$jsonErrorMessage = json_last_error_msg();
throw new InvalidApiResponse(
"Invalid Json message '{$jsonErrorMessage}'"
);
}
return json_decode($bodyContent, true);
}
/**
* @throws \Uwil\ArcadiaApiPhpClient\Exception\InvalidApiResponse
*/
protected function assertResponseHasExpectedHeaders(ResponseInterface $response): void
{
$headerIsJsonContent = in_array(
$response->getHeaders()['Content-Type'][0] ?? '',
[
'application/json;charset=UTF-8',
'application/json',
],
true
);
if (! $headerIsJsonContent) {
throw new InvalidApiResponse(
'Expected Json header in Content-Type'
);
}
}
/**
* @return array<string,mixed>
*
* @throws \Uwil\ArcadiaApiPhpClient\Exception\InvalidApiResponse
*/
protected function assertApiResponseFormIsAsExpected(mixed $body): array
{
$e = new InvalidApiResponse(
'Body content shape cannot be recognized'
);
if (! is_array($body) || ! array_key_exists('success', $body)) {
throw $e;
}
if (! $this->apiResponseIsError($body) && ! $this->apiResponseContainsData($body)) {
throw $e;
}
return $body;
}
/**
* @param array<string, mixed> $responseData
*/
protected function apiResponseIsError(array $responseData): bool
{
return array_key_exists('error', $responseData)
&& is_array($responseData['error'])
&& ! empty($responseData['error']);
}
/**
* @param array<string, mixed> $responseData
*/
protected function apiResponseContainsData(array $responseData): bool
{
return array_key_exists('data', $responseData)
&& is_array($responseData['data']);
}
protected function delay(int $attempt): void
{
$delayMs = $this->apiConfig->retryDelayMs * $attempt;
usleep($delayMs * 1000);
}
}

View file

@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Factory;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PaginationDto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiResponse\ApiResponse;
use Uwil\ArcadiaApiPhpClient\Lib\Pagination;
use Uwil\ArcadiaApiPhpClient\Mapper\Mapper;
/**
* @implements DtoFactory<Mapper, \Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto>
*/
abstract class AbstractDtoFactory implements DtoFactory
{
protected readonly Mapper $model;
public function __construct(Mapper $model)
{
$this->model = $model;
}
public function model(): Mapper
{
return $this->model;
}
protected function paginationDtoFrom(Pagination $pagination): PaginationDto
{
return new PaginationDto(
$pagination->pageNumber(),
$pagination->pageSize(),
0,
0,
);
}
protected function toPaginationDto(
ApiResponse $response
): ?PaginationDto {
$apiPagination = $response->pagination;
if ($apiPagination === null) {
return null;
}
return new PaginationDto(
$apiPagination->page,
$apiPagination->pageSize,
$apiPagination->totalItems,
$apiPagination->totalPages
);
}
}

View file

@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Factory;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PageOf;
use Uwil\ArcadiaApiPhpClient\Lib\ApiResponse\ApiResponse;
use Uwil\ArcadiaApiPhpClient\Lib\Pagination;
use Uwil\ArcadiaApiPhpClient\Mapper\Mapper;
/**
* @template M of \Uwil\ArcadiaApiPhpClient\Mapper\Mapper
* @template D of \Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto
*/
interface DtoFactory
{
/**
* @param M $model
*/
public function __construct(Mapper $model);
/**
* @return M $model
*/
public function model(): Mapper;
/**
* @return ($pagination is null ? D : PageOf<D>)
*
* @phpstan-return ($pagination is null ? D : PageOf<D>)
*/
public function build(ApiResponse $response, ?Pagination $pagination = null): null | Dto | PageOf;
}

View file

@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Factory;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Employee;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PageOf;
use Uwil\ArcadiaApiPhpClient\Lib\ApiResponse\ApiResponse;
use Uwil\ArcadiaApiPhpClient\Lib\Pagination;
use Uwil\ArcadiaApiPhpClient\Mapper\EmployeeMapper;
use Uwil\ArcadiaApiPhpClient\Mapper\Mapper;
/**
* @internal
*/
final class EmployeeDtoFactory extends AbstractDtoFactory
{
protected readonly Mapper $model;
/** @param EmployeeMapper $model */
public function __construct(Mapper $model)
{
if (! ($model instanceof EmployeeMapper)) {
throw new \InvalidArgumentException('Expected model to be instance of ' . EmployeeMapper::class);
}
$this->model = $model;
}
/** @return Employee|PageOf<Employee>|null */
public function build(ApiResponse $response, ?Pagination $pagination = null): Dto | PageOf | null
{
$paginationDto = $this->toPaginationDto($response);
// FIXME: L'api ne retourne pas de pagination alors qu'on a fait une demande paginé.
if ($paginationDto === null && $pagination !== null) {
// NOTE: Hack here.
$paginationDto = $this->paginationDtoFrom($pagination);
return new PageOf($paginationDto, []);
}
// * Single result
if ($paginationDto === null) {
if ($response->isEmpty()) {
return null;
}
return $this->model->toDto($response->data);
}
return new PageOf(
$paginationDto,
$response->mapData(fn (array $data): Employee => $this->model->toDto($data))
);
}
}

View file

@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Factory;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PageOf;
use Uwil\ArcadiaApiPhpClient\Lib\ApiResponse\ApiResponse;
use Uwil\ArcadiaApiPhpClient\Lib\Pagination;
use Uwil\ArcadiaApiPhpClient\Mapper\IncoCompoMapper;
use Uwil\ArcadiaApiPhpClient\Mapper\Mapper;
final class IncoCompoDtoFactory extends AbstractDtoFactory
{
protected readonly Mapper $model;
/** @param IncoCompoMapper $model */
public function __construct(Mapper $model)
{
if (! ($model instanceof IncoCompoMapper)) {
throw new \InvalidArgumentException('Expected model to be instance of ' . IncoCompoMapper::class);
}
$this->model = $model;
}
/**
* @return \Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo|PageOf<\Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo>|null
*/
public function build(ApiResponse $response, ?Pagination $pagination = null): Dto | PageOf | null
{
$paginationDto = $this->toPaginationDto($response);
// FIXME: L'api ne retourne pas de pagination alors qu'on a fait une demande paginé.
if ($paginationDto === null && $pagination !== null) {
// NOTE: Hack here.
$paginationDto = $this->paginationDtoFrom($pagination);
return new PageOf($paginationDto, []);
}
// * Single result
if ($paginationDto === null) {
if ($response->isEmpty()) {
return null;
}
return $this->model->toDto($response->data);
}
throw new \InvalidArgumentException('Pagination not supported');
}
}

View file

@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Factory;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PageOf;
use Uwil\ArcadiaApiPhpClient\Lib\ApiResponse\ApiResponse;
use Uwil\ArcadiaApiPhpClient\Lib\Pagination;
use Uwil\ArcadiaApiPhpClient\Mapper\IncoMapper;
use Uwil\ArcadiaApiPhpClient\Mapper\Mapper;
final class IncoDtoFactory extends AbstractDtoFactory
{
protected readonly Mapper $model;
/** @param IncoMapper $model */
public function __construct(Mapper $model)
{
if (! ($model instanceof IncoMapper)) {
throw new \InvalidArgumentException('Expected model to be instance of ' . IncoMapper::class);
}
$this->model = $model;
}
/** @return Inco|PageOf<Inco> */
public function build(ApiResponse $response, ?Pagination $pagination = null): Dto | PageOf | null
{
$paginationDto = $this->toPaginationDto($response);
// FIXME: L'api ne retourne pas de pagination alors qu'on a fait une demande paginé.
if ($paginationDto === null && $pagination !== null) {
// NOTE: Hack here.
$paginationDto = $this->paginationDtoFrom($pagination);
return new PageOf($paginationDto, []);
}
// * Single result
if ($paginationDto === null) {
if ($response->isEmpty()) {
return null;
}
return $this->model->toDto($response->data);
}
return new PageOf(
$paginationDto,
$response->mapData(fn (array $data): Inco => $this->model->toDto($data))
);
}
}

View file

@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Factory;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoShort;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PageOf;
use Uwil\ArcadiaApiPhpClient\Lib\ApiResponse\ApiResponse;
use Uwil\ArcadiaApiPhpClient\Lib\Pagination;
use Uwil\ArcadiaApiPhpClient\Mapper\IncoShortMapper;
use Uwil\ArcadiaApiPhpClient\Mapper\Mapper;
final class IncoShortDtoFactory extends AbstractDtoFactory
{
protected readonly Mapper $model;
/** @param IncoShortMapper $model */
public function __construct(Mapper $model)
{
if (! ($model instanceof IncoShortMapper)) {
throw new \InvalidArgumentException('Expected model to be instance of ' . IncoShortMapper::class);
}
$this->model = $model;
}
/** @return \Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoShort|PageOf<\Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoShort> */
public function build(ApiResponse $response, ?Pagination $pagination = null): Dto | PageOf | null
{
$paginationDto = $this->toPaginationDto($response);
// FIXME: L'api ne retourne pas de pagination alors qu'on a fait une demande paginé.
if ($paginationDto === null && $pagination !== null) {
// NOTE: Hack here.
$paginationDto = $this->paginationDtoFrom($pagination);
return new PageOf($paginationDto, []);
}
// * Single result
if ($paginationDto === null) {
if ($response->isEmpty()) {
return null;
}
return $this->model->toDto($response->data);
}
return new PageOf(
$paginationDto,
$response->mapData(fn (array $data): IncoShort => $this->model->toDto($data))
);
}
}

View file

@ -1,987 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiParser;
use Uwil\ArcadiaApiPhpClient\Exception\UnexpectedErrorException;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Brand;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\Additive;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\Allegations;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\Allergen;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\IncoWeight;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\Ingredients;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\Nutriscore;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\PortionAmount;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\PortionType;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\StorageCondition;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\BrandCode;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoCompoId;
use Uwil\ArcadiaApiPhpClient\Utils\DateUtils;
use Uwil\ArcadiaApiPhpClient\Utils\StringUtils;
final readonly class IncoApiParser
{
public function __construct()
{
throw new \Error('Abstract class');
}
/**
* @param array<string, mixed> $mappedValues
*/
public static function toBoolean(array $mappedValues, string $field, bool $default): bool
{
$fieldValue = self::getArrayKeyValue($mappedValues, $field);
if (! is_scalar($fieldValue)) {
return false;
}
if (is_string($fieldValue)) {
return ctype_digit($fieldValue) ? (bool) intval($fieldValue) : $default;
}
return (bool) intval($fieldValue);
}
/** @param array<string,null|mixed> $mappedValues */
public static function toDateTimeFrom(array $mappedValues, string $field): ?\DateTimeInterface
{
$fieldValue = self::getArrayKeyValue($mappedValues, $field);
return self::toDateTime(is_string($fieldValue) ? $fieldValue : null);
}
/** @param array<string,null|mixed> $mappedValues */
public static function toBrand(array $mappedValues): Brand
{
$brand = self::getArrayKeyValue($mappedValues, 'brand');
if (! is_array($brand)) {
throw new UnexpectedErrorException(
sprintf(
'Expected inco brand to be an array : %s',
json_encode($brand)
)
);
}
$libelle = self::getArrayKeyValue($brand, 'libelle');
$libelle = IncoApiStringParser::toPhrase(is_string($libelle) ? $libelle : '');
$code = self::getArrayKeyValue($brand, 'code');
$code = self::toInt(is_scalar($code) ? (string) $code : '');
$code = new BrandCode($code);
return new Brand(
$code,
IncoApiStringParser::protectiveDataFilter($libelle) ?? ''
);
}
/**
* @param array<string,null|mixed> $mappedValues
* @return Allergen[]
*/
public static function toAllergens(array $mappedValues): array
{
return array_map(
function ($allergen): Allergen {
if (! is_array($allergen)) {
throw new UnexpectedErrorException(
sprintf(
'Expected inco allergen to be an array : %s',
json_encode($allergen)
)
);
}
$libelle = self::getArrayKeyValue($allergen, 'libelle');
$libelle = IncoApiStringParser::toPhrase(is_string($libelle) ? $libelle : '');
$code = self::getArrayKeyValue($allergen, 'code');
$code = self::toInt(is_scalar($code) ? (string) $code : '');
return new Allergen(
$code,
IncoApiStringParser::protectiveDataFilter($libelle) ?? ''
);
},
self::toArray($mappedValues['allergens'])
);
}
/**
* @param array<string,null|mixed> $mappedValues
*/
public static function toAllegations(array $mappedValues): Allegations
{
$allegations = [
'allegations' => $mappedValues['allegations'],
'other' => $mappedValues['allegations_other'],
'nutrition' => $mappedValues['allegations_nutrition'],
'health' => $mappedValues['allegations_health'],
];
foreach ($allegations as $cat => $allegation) {
$delimiters = '–,';
$tok = StringUtils::mbStrtok($delimiters, $allegation);
/** @var string[] */
$parts = [];
while ($tok !== false) {
$parts[] = $tok;
$tok = StringUtils::mbStrtok($delimiters);
}
// * Remove items that start with "aucun" (case-insensitive)
$parts = array_values(array_filter($parts, function (string $allegation): bool {
$lower = mb_strtolower($allegation);
return strpos($lower, 'aucun') !== 0;
}));
// * map each item: squish (collapse whitespace), lowercase then ucfirst, ensure ends with '.' then remove last character
$parts = array_map(function (string $allegation): string {
$value = IncoApiStringParser::toPhrase($allegation);
return IncoApiStringParser::protectiveDataFilter($value) ?? '';
}, $parts);
$parts = array_values($parts);
$allegations[$cat] = $parts;
}
return new Allegations(
$allegations['allegations'],
$allegations['health'],
$allegations['nutrition'],
$allegations['other'],
);
}
/** @param array<string,null|mixed> $mappedValues */
public static function toIncoCompoId(array $mappedValues): ?IncoCompoId
{
$compo = self::getArrayKeyValue($mappedValues, 'compo');
if (! is_array($compo)) {
return null;
}
$code = self::getArrayKeyValue($compo, 'code');
return new IncoCompoId(is_scalar($code) ? (string) $code : '');
}
/**
* NOTE: Ici osef du type de portion si aucune valeur numerique n'est donnée pour portion.
*
* @param array<string,null|mixed> $mappedValues
*
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
*/
public static function toPortionAmount(array $mappedValues): ?PortionAmount
{
// phpcs:enable
$raw = self::getArrayKeyValue($mappedValues, 'portions');
$rawType = self::getArrayKeyValue($mappedValues, 'portion_type');
if (is_null($raw) || empty($raw)) {
return null;
}
$normalized = self::normalizePortions(is_array($raw) ? null : $raw);
$type = self::parsePortionType(is_string($rawType) ? $rawType : null);
$tokens = self::tokenize($normalized);
$numeric = self::extractNumericParts($tokens);
if (count($numeric) > 2) {
throw new UnexpectedErrorException('Parts has too many numbers in it ' . json_encode($tokens));
}
$low = $numeric[0] ?? null;
$high = $numeric[1] ?? null;
if (is_numeric($low) && is_numeric($high)) {
if ((float) $low === (float) $high) {
$high = null;
} else {
if ((float) $low > (float) $high) {
[$low, $high] = [$high, $low];
}
$type = PortionType::approximative;
}
}
if (is_null($type) && is_numeric($low) && is_null($high)) {
$type = PortionType::exact;
}
if (! is_null($low)) {
if (is_null($type)) {
throw new UnexpectedErrorException("PortionType was not set with low '{$low}' and high '{$high}'");
}
try {
return new PortionAmount(
(float) $low,
$high !== null ? (float) $high : 0.0,
$type === PortionType::approximative
);
} catch (\InvalidArgumentException) {
// NOTE: PortionAmount may not be correct here.
return null;
}
}
if (! preg_match('/\d/', $normalized)) {
return null;
}
throw new UnexpectedErrorException(
"portions was not set with low '{$low}' and high '{$high}' on " . json_encode($normalized)
);
}
/** @param array<string,null|mixed> $mappedValues
* @return null|string|int|float|array<mixed>
*/
public static function getArrayKeyValue(array $mappedValues, string $field): null | string | int | float | array
{
$fieldValue = $mappedValues[$field] ?? null;
if (! array_key_exists($field, $mappedValues)) {
throw new UnexpectedErrorException(
sprintf('Expected inco field %s to exists', $field)
);
}
return $fieldValue;
}
/**
* @param array<string,null|mixed> $mappedValues
*/
public static function toStorageCondition(array $mappedValues): ?StorageCondition
{
$raw = self::getArrayKeyValue($mappedValues, 'conditConserv');
if (is_null($raw) || is_array($raw) || trim((string) $raw) === '') {
return null;
}
$cleaned = IncoApiStringParser::protectiveDataFilter(trim((string) $raw));
if (is_null($cleaned) || trim($cleaned) === '') {
return null;
}
return new StorageCondition($cleaned);
}
/**
* @param array<string,null|mixed> $mappedValues
*/
public static function toSellFamilly(array $mappedValues): ?string
{
$sellFamilly = self::getArrayKeyValue($mappedValues, 'familleVte');
if (is_null($sellFamilly)) {
return null;
}
if (! is_array($sellFamilly)) {
throw new UnexpectedErrorException(
sprintf(
'Expected inco sell family to be an array : %s',
json_encode($sellFamilly)
)
);
}
$libelle = self::getArrayKeyValue($sellFamilly, 'libelle');
return IncoApiStringParser::toPhrase(is_string($libelle) ? $libelle : '');
}
/**
* @param array<string,null|mixed> $mappedValues
* @return Additive[]
*/
public static function toAdditives(array $mappedValues): array
{
$raw = self::getArrayKeyValue($mappedValues, 'lstAdditif');
if (is_null($raw) || empty($raw)) {
return [];
}
if (! is_array($raw)) {
throw new UnexpectedErrorException(
sprintf('Expected lstAdditif to be an array, got: %s', json_encode($raw))
);
}
return array_values(array_map(function (mixed $entry): Additive {
if (! is_array($entry)) {
throw new UnexpectedErrorException(
sprintf('Expected lstAdditif entry to be an array, got: %s', json_encode($entry))
);
}
$code = self::getArrayKeyValue($entry, 'code');
$label = self::getArrayKeyValue($entry, 'libelle');
if (! is_string($code) || ! is_string($label)) {
throw new UnexpectedErrorException(
sprintf('Expected lstAdditif code and libelle to be strings, got: %s', json_encode($entry))
);
}
$label = IncoApiStringParser::toPhrase($label);
$label = (string) IncoApiStringParser::protectiveDataFilter($label);
$code = mb_strtoupper(trim($code));
/** @var string $code */
$code = preg_replace('/^A(.*)/', 'E$1', $code) ?? ''; // FIX pour faux E-code.
$code = strtoupper(trim(str_replace(' ', '', $code)));
// Remettre en minuscules uniquement le suffixe alpha après les chiffres
// ex: "E160AI" -> "E160ai", "E160A(II)" -> "E160a(ii)"
$code = (string) preg_replace_callback(
'/^(E\d+)([A-Z]+(?:\([IVX]+\))?)$/',
static fn (array $m): string => $m[1] . strtolower($m[2]),
$code,
);
return new Additive(
$code,
$label,
);
}, $raw));
}
/**
* @param array<string,null|mixed> $mappedValues
*/
public static function toNutriscore(array $mappedValues): ?Nutriscore
{
$raw = self::getArrayKeyValue($mappedValues, 'nutriscore');
if (is_null($raw) || is_array($raw) || trim((string) $raw) === '') {
return null;
}
$raw = mb_strtoupper(trim((string) $raw));
while (strlen($raw) > 1) {
if (in_array($raw[0], Nutriscore::VALID_GRADES)) {
$raw = $raw[0];
continue;
}
$raw = substr($raw, 1);
}
return new Nutriscore($raw);
}
/**
* @param array<string,null|mixed> $mappedValues
*/
public static function toIncoWeight(array $mappedValues): ?IncoWeight
{
$netRaw = self::getArrayKeyValue($mappedValues, 'pdsNetUvc');
$brutRaw = self::getArrayKeyValue($mappedValues, 'pdsBrutUvc');
$moyRaw = self::getArrayKeyValue($mappedValues, 'pdsMoyTheo');
if (is_null($netRaw) && is_null($brutRaw) && is_null($moyRaw)) {
return null;
}
return new IncoWeight(
netUvc: self::toNonNegativeInt($netRaw, 'pdsNetUvc'),
brutUvc: self::toNonNegativeInt($brutRaw, 'pdsBrutUvc'),
moyTheo: self::toNonNegativeInt($moyRaw, 'pdsMoyTheo'),
);
}
protected static function toNonNegativeInt(mixed $value, string $field): int
{
if (is_string($value) && ! strlen($value)) {
return 0;
}
if (! is_string($value) || ! ctype_digit(trim($value))) {
throw new UnexpectedErrorException(
"Expected field '{$field}' to be a non-negative integer string, got: " . json_encode($value)
);
}
return (int) $value;
}
/**
* Parse and format an ingredient list string into a structured array.
*
* Returns a list of ingredient groups. Each group is either:
* - an indexed entry (list of ingredient strings, no category label), or
* - a named entry keyed by the category label (e.g. "Ingrédients", "Traces possibles").
*
* Ingredients listed under a "Traces possibles" heading are uppercased.
*
* @param array<string,null|mixed> $mappedValues
*
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
*/
public static function parseIngredients(array $mappedValues): Ingredients
{
// phpcs:enable
$ingredientString = strip_tags((string) ($mappedValues['ingredients'] ?? ''));
if (empty($ingredientString) || mb_strlen($ingredientString) < 2) {
return new Ingredients([]);
}
// * Replace float numbers with , as sep points (ACK chr 6)
$floatingPointEsc = chr(6);
$ingredientString = (string) preg_replace(
'/(?:([0-9]+),([0-9]+))/',
'$1' . $floatingPointEsc . '$2',
$ingredientString
);
// * Escape float numbers with . as sep points
$ingredientString = (string) preg_replace(
'/(?:([0-9]+)\.([0-9]+))/',
'$1' . $floatingPointEsc . '$2',
$ingredientString
);
// * Fix string (close parentheses if not closed)
try {
$ingredientString = self::fixParentheses($ingredientString);
} catch (\RuntimeException) {
// * If failed, try with smaller parts
$parts = array_filter(explode('.', $ingredientString));
$fixedParts = array_map(
fn (string $part) => self::removeNonOppennedParentheses(self::fixParentheses($part . ')')),
$parts
);
$ingredientString = implode('.', $fixedParts);
}
// * Split into a structured array (token by '.')
$tokenizedString = self::buildTokenizedString($ingredientString);
$actualKey = null;
$triaged = [];
foreach ($tokenizedString as $index => $tokenArray) {
if (is_string($index)) {
$actualKey = $index;
}
if (is_null($actualKey)) {
$triaged[] = $tokenArray;
continue;
}
if (empty($triaged[$actualKey])) {
$triaged[$actualKey] = [];
}
$triaged[$actualKey] = array_merge($triaged[$actualKey], $tokenArray);
}
// * Reset floating points to original
array_walk_recursive($triaged, function (string &$ingredient) use ($floatingPointEsc): void {
$ingredient = str_replace($floatingPointEsc, '.', $ingredient);
});
// * Remove non-opened parentheses
array_walk_recursive($triaged, function (string &$ingredient): void {
$ingredient = self::removeNonOppennedParentheses($ingredient);
});
// * Replace "Informations en GRAS" -> "Informations en MAJUSCULE"
array_walk_recursive($triaged, function (string &$ingredient): void {
$ingredient = str_ireplace(
'Informations en GRAS',
'Informations en MAJUSCULE',
$ingredient
);
});
// * Uppercase ingredients under any "Traces..." heading
$triaged = self::mapWithKeys(
$triaged,
function (array $value, int | string $index): array {
if (is_string($index) && preg_match('/^Traces?/ui', $index)) {
$value = array_map(fn (string $line) => mb_strtoupper($line), $value);
}
return [$index => $value];
}
);
// * Extract lines that must be moved to their own top-level entry
/** @var array<int, object{index: int|string, value: string}> */
$getOut = [];
$triaged = self::mapWithKeys(
$triaged,
function (array $value, int | string $index) use (&$getOut): array {
if (count($value) < 2) {
return [$index => $value];
}
$outValue = [];
foreach ($value as $line) {
$ascii = mb_strtolower((string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $line));
if (
str_starts_with($ascii, 'fabrique dans')
|| str_starts_with($ascii, 'certifie')
|| str_starts_with($ascii, 'a consommer')
|| str_starts_with($ascii, 'produit fabrique')
) {
$getOut[] = (object) ['index' => $index, 'value' => $line];
continue;
}
$outValue[] = $line;
}
return [$index => $outValue];
}
);
foreach ($getOut as $insert) {
$keys = array_keys($triaged);
$pos = (int) array_search($insert->index, $keys, true);
$before = array_splice($triaged, 0, $pos + 1);
$triaged = $before + [$insert->value => []] + $triaged;
}
return new Ingredients($triaged);
}
protected static function removeNonOppennedParentheses(string $ingredientPart): string
{
$outString = '';
$inParentheses = 0;
foreach (mb_str_split($ingredientPart) as $char) {
if ($char === '(') {
$inParentheses++;
}
if ($char === ')') {
if ($inParentheses === 0) {
continue;
}
$inParentheses--;
}
$outString .= $char;
}
return $outString;
}
/**
* Close any unclosed parentheses in an ingredient string.
*
* @throws \RuntimeException If the string is seriously malformed (more than 1 unclosed level).
*/
protected static function fixParentheses(string $ingredientPart): string
{
$inParentheses = 0;
$lastCloseIndex = 0;
$chars = mb_str_split($ingredientPart);
foreach ($chars as $index => $char) {
if ($char === '(') {
$inParentheses++;
}
if ($char === ')') {
$inParentheses = $inParentheses === 0 ? 0 : $inParentheses - 1;
$lastCloseIndex = (($chars[$index + 1] ?? null) === '.' ? $index + 2 : $index);
}
}
if ($inParentheses > 1) {
throw new \RuntimeException("Seriously mal formed in : {$ingredientPart}");
}
if ($inParentheses) {
$closeAfter = mb_strpos($ingredientPart, '.', $lastCloseIndex);
if ($closeAfter === false) {
$closeAfter = mb_strlen($ingredientPart);
}
$ingredientPart = mb_substr($ingredientPart, 0, (int) $closeAfter)
. ')'
. mb_substr($ingredientPart, (int) $closeAfter);
}
return $ingredientPart;
}
/**
* Tokenize an ingredient part string on $charToEscape, ignoring separators inside parentheses.
*
* @return string[]
*/
protected static function tokenIzeIngredientPart(
string $ingredientPartString,
string $charToEscape = ',',
int $limit = -1
): array {
// * NAK chr(21) - escape floating-point separators so they are not split
$floatingPointEsc = chr(21);
$ingredientPartString = (string) preg_replace(
'/(?:([0-9]+),([0-9]+))/',
'$1' . $floatingPointEsc . '$2',
$ingredientPartString
);
$ingredientPartString = (string) preg_replace(
'/(?:([0-9]+)\.([0-9]+))/',
'$1' . $floatingPointEsc . '$2',
$ingredientPartString
);
// * DEL chr(127) - escape separator characters that are inside parentheses or brackets
$escapeChar = chr(127);
$inParentheses = 0;
$chars = mb_str_split($ingredientPartString);
$result = [];
foreach ($chars as $char) {
if (in_array($char, ['(', '['], true)) {
$inParentheses++;
}
if (in_array($char, [')', ']'], true)) {
$inParentheses = $inParentheses === 0 ? 0 : $inParentheses - 1;
}
// * Inside parentheses: escape the split character AND also ':' to avoid
// sub-tokenization treating "gélifiant: carraghénanes" as a section label
if ($inParentheses > 0 && $char === $charToEscape) {
$result[] = $escapeChar;
} else {
$result[] = $char;
}
}
$ingredientPartString = implode('', $result);
$delimiters = "\n\t{$charToEscape}";
$tok = StringUtils::mbStrtok($delimiters, $ingredientPartString);
/** @var string[] */
$parts = [];
while ($tok !== false) {
$parts[] = $tok;
$tok = StringUtils::mbStrtok($delimiters);
}
// * ESC chr(27) - apply $limit by joining/re-splitting
$limitChar = chr(27);
if ($limit !== -1) {
$joined = implode($limitChar, $parts);
$split = explode($limitChar, $joined, $limit);
$parts = array_map(
fn (string $part) => str_replace([$limitChar, $floatingPointEsc], ['', '.'], $part),
$split
);
}
// * Final cleanup: restore escaped separators and floating points, squish, ucfirst
$parts = array_filter($parts, fn (string $p) => trim($p) !== '');
$parts = array_map(function (string $ingredient) use ($escapeChar, $charToEscape, $floatingPointEsc): string {
$ingredient = str_replace($escapeChar, $charToEscape, $ingredient);
$ingredient = str_replace($floatingPointEsc, '.', $ingredient);
// squish
$ingredient = trim((string) preg_replace('/\s+/u', ' ', $ingredient));
// remove trailing dot
$ingredient = rtrim($ingredient, '.');
// ucfirst on first word character
return self::ucFirstOnWordChar($ingredient);
}, $parts);
return array_values($parts);
}
/**
* Apply mb-aware ucfirst on the first word character of a string,
* but only if that character is NOT immediately preceded by a digit.
* This prevents "105g" from becoming "105G".
*/
protected static function ucFirstOnWordChar(string $string): string
{
if (preg_match('/\p{L}/u', $string, $matches, PREG_OFFSET_CAPTURE) !== 1) {
return $string;
}
$match = $matches[0];
$byteOffset = $match[1];
// * Do not uppercase a letter that immediately follows a digit (e.g. "105g")
if ($byteOffset > 0 && ctype_digit(mb_substr($string, $byteOffset - 1, 1))) {
return $string;
}
$firstChar = mb_strtoupper($match[0]);
return mb_substr($string, 0, $byteOffset)
. $firstChar
. mb_substr($string, $byteOffset + mb_strlen($match[0]));
}
/**
* Build the top-level tokenized structure from the full ingredient string.
*
* Returns an array where each entry is either:
* - int key -> string[] of ingredient tokens (no section label found)
* - string key -> string[] of ingredient tokens (section label used as key)
*
* @return array<int|string, string[]>
*/
protected static function buildTokenizedString(string $ingredientString): array
{
$sentenceTokens = self::tokenIzeIngredientPart($ingredientString, '.');
$result = [];
foreach ($sentenceTokens as $index => $tokenString) {
$elements = self::tokenIzeIngredientPart($tokenString, ':', 2);
$labelRaw = $elements[0] ?? '';
$labelSlug = StringUtils::slugify($labelRaw);
$hasLabel = count($elements) > 1;
$trimmedLabel = trim($labelRaw);
$labelIsRatio = (bool) preg_match('/[\d%]/', $trimmedLabel);
$labelHasParen = (bool) preg_match('/[()]/', $trimmedLabel);
$labelIsTooLong = mb_strlen($trimmedLabel) > 40;
$tokenSlug = StringUtils::slugify($tokenString);
$isInfoLine = str_starts_with($tokenSlug, 'fabrique dans')
|| str_starts_with($tokenSlug, 'certifie')
|| str_starts_with($tokenSlug, 'a consommer')
|| str_starts_with($tokenSlug, 'produit fabrique');
if ($isInfoLine) {
continue;
}
// * "INGREDIENTS :" header → discard the label, keep only the ingredient content
if ($hasLabel && strpos($labelSlug, 'ingredient') !== false) {
$ingredientPart = $elements[1] ?? '';
$result[$index] = self::flattenIngredientTokens(
self::tokenIzeIngredientPart($ingredientPart)
);
continue;
}
// * "Traces ..." / "Décor ..." / "Allergènes ..." → named group, keep the label as key
// but extract the first item from the value part (e.g. "LAIT" after the ':')
$isNamedSpecialGroup = $hasLabel && (
str_starts_with($labelSlug, 'trace') || str_starts_with($labelSlug, 'decor') || str_starts_with($labelSlug, 'allergene')
);
if ($isNamedSpecialGroup) {
$label = trim((string) preg_replace('/\s+/u', ' ', mb_strtolower($labelRaw)));
$label = mb_strtoupper(mb_substr($label, 0, 1)) . mb_substr($label, 1);
$ingredientPart = $elements[1] ?? '';
$result[$label] = self::flattenIngredientTokens(
self::tokenIzeIngredientPart($ingredientPart)
);
continue;
}
if (
$hasLabel && ! $labelIsRatio && ! $labelHasParen && ! $labelIsTooLong && strpos($labelSlug, 'fabrique') !== 0
) {
$label = trim((string) preg_replace('/\s+/u', ' ', mb_strtolower($labelRaw)));
$label = mb_strtoupper(mb_substr($label, 0, 1)) . mb_substr($label, 1);
$ingredientPart = $elements[1] ?? '';
$result[$label] = self::flattenIngredientTokens(
self::tokenIzeIngredientPart($ingredientPart)
);
} else {
$result[$index] = self::flattenIngredientTokens(
self::tokenIzeIngredientPart($tokenString)
);
}
}
return $result;
}
/**
* Flatten orphan parenthesised sub-lists inside a token list.
*
* When a token starts with '(' it means the parser could not split it because
* the opening parenthesis was never closed (orphan sub-list). In that case we:
* 1. Strip the wrapping parentheses.
* 2. Re-tokenize the inner content as a normal comma-separated list.
* 3. Splice the resulting tokens in place of the original token.
*
* Example input tokens:
* ["Viande de dinde (origine france) : 93%",
* "(Viande de dinde : 81%, marinade : 12% (eau, acidifiants : E262-E331, sel, arômes naturels), poivrons verts : 7%)"]
*
* Example output tokens:
* ["Viande de dinde (origine france) : 93%",
* "Viande de dinde : 81%",
* "Marinade : 12% (eau, acidifiants : E262-E331, sel, arômes naturels)",
* "Poivrons verts : 7%"]
*
* @param string[] $tokens
* @return string[]
*/
protected static function flattenIngredientTokens(array $tokens): array
{
$flattened = [];
foreach ($tokens as $token) {
$trimmed = trim($token);
// * Detect an orphan sub-list: token starts with '(' and ends with ')'
// * (fixParentheses will have closed it, so it always ends with ')')
if (
mb_substr($trimmed, 0, 1) === '(' && mb_substr($trimmed, -1) === ')'
) {
// * Strip outer parentheses and re-tokenize as a normal ingredient list
$inner = mb_substr($trimmed, 1, mb_strlen($trimmed) - 2);
$subItems = self::tokenIzeIngredientPart($inner, ',');
foreach ($subItems as $subItem) {
if (trim($subItem) !== '') {
$flattened[] = $subItem;
}
}
continue;
}
$flattened[] = $token;
}
return $flattened;
}
/**
* array_map variant that preserves and transforms keys, similar to Laravel's Arr::mapWithKeys.
*
* The callback receives ($value, $key) and must return a single-element associative array.
*
* @template TKey of int|string
* @template TValue
*
* @param array<TKey, TValue> $array
* @param callable(TValue, TKey): array<int|string, TValue> $callback
* @return array<int|string, TValue>
*/
protected static function mapWithKeys(array $array, callable $callback): array
{
$result = [];
foreach ($array as $key => $value) {
$assoc = $callback($value, $key);
foreach ($assoc as $k => $v) {
$result[$k] = $v;
}
}
return $result;
}
protected static function toInt(string $value, ?int $default = null): int
{
if (! ctype_digit($value) || trim($value) === '') {
if ($default !== null) {
return $default;
}
throw new UnexpectedErrorException("Expected value to be an int, string given '{$value}'");
}
return intval($value);
}
protected static function toDateTime(?string $datetime): ?\DateTimeInterface
{
return is_null($datetime) ? null : DateUtils::parseDateTime($datetime);
}
/**
* @param array<mixed>|string $value
* @return array<mixed>
*/
protected static function toArray(string | array $value): array
{
if (is_string($value)) {
return trim($value) === '' ? [] : [$value];
}
return $value;
}
protected static function parsePortionType(?string $typePortion): ?PortionType
{
if ($typePortion === null) {
return null;
}
$needle = mb_strtolower($typePortion);
$matches = array_values(array_filter(
PortionType::cases(),
fn (PortionType $type) => $type->description() === $needle
));
return $matches[0] ?? null;
}
protected static function normalizePortions(string | int | float | null $value): string
{
if (! is_string($value)) {
$value = (string) $value;
}
$value = trim($value);
$value = str_replace(',', '.', $value);
return $value;
}
/**
* @return list<string>
*/
protected static function tokenize(string $value): array
{
$delimiters = " \n\tà/\\-_de";
$tok = StringUtils::mbStrtok($delimiters, $value);
$parts = [];
while ($tok !== false) {
$parts[] = $tok;
$tok = StringUtils::mbStrtok($delimiters);
}
return $parts;
}
/**
* @param list<string> $parts
* @return list<string>
*/
protected static function extractNumericParts(array $parts): array
{
return array_values(array_filter($parts, fn ($p) => is_numeric($p)));
}
}

View file

@ -1,217 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiParser;
use Uwil\ArcadiaApiPhpClient\Utils\StringEncoder;
final readonly class IncoApiStringParser
{
public function __construct()
{
throw new \Error('Abstract class');
}
public static function toPhrase(string $value): string
{
$squished = preg_replace('/\s+/u', ' ', trim($value)) ?? '';
// * lowercase then uppercase the first character (multibyte-safe)
$lower = mb_strtolower($squished);
$firstChar = mb_strtoupper(mb_substr($lower, 0, 1));
$rest = mb_substr($lower, 1);
$ucfirst = $firstChar . $rest;
// * ensure it ends with a dot
if (mb_substr($ucfirst, -1) !== '.') {
$ucfirst .= '.';
}
// * remove the last character (to match the original ->substr(0, -1))
if (strlen($ucfirst)) {
$ucfirst = mb_substr($ucfirst, 0, mb_strlen($ucfirst) - 1);
}
return $ucfirst;
}
public static function beautifyName(string $name): string
{
// run beautifier (may return null)
$name = self::beautifyText($name) ?? '';
$name = trim($name);
if ($name === '') {
return $name;
}
// Title case (mb aware) and collapse whitespace
$name = mb_convert_case(mb_strtolower($name, 'UTF-8'), MB_CASE_TITLE, 'UTF-8');
$name = preg_replace('/\s+/u', ' ', $name) ?? '';
$name = trim($name);
// Ensure sentence-like formatting: add trailing dot then remove it to mimic finish()->substr(0,-1)
if ($name !== '') {
$name = rtrim($name, " \t\n\r\0\x0B");
if (! preg_match('/[.:!?\…;,]$/u', $name)) {
$name .= '.';
}
// remove the final dot as original code did
$name = mb_substr($name, 0, mb_strlen($name, 'UTF-8') - 1, 'UTF-8');
}
// remove leading colon if present
if (str_starts_with($name, ':')) {
$name = mb_substr($name, 1, null, 'UTF-8');
$name = trim($name);
}
return $name;
}
/**
* Nettoie et normalise une ligne texte (pas de HTML, pas de contrôles non désirés).
*/
public static function protectiveDataFilter(?string $line): ?string
{
if ($line === null || $line === '') {
return $line;
}
// Decode HTML entities then strip tags
$line = html_entity_decode($line, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$line = strip_tags($line);
// Normalize newlines, temporarily convert to <br /> to preserve paragraph breaks
$line = str_replace(["\r\n", "\r"], "\n", $line);
$line = str_replace("\n", '<br />', $line);
// Ensure valid UTF-8
$line = StringEncoder::detectAndConvertToUtf8($line);
// Remove astral symbols (4-byte UTF-8) that may break storage/processing
$line = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $line) ?? '';
// Remove control/non-printable characters but keep TAB(0x09), LF(0x0A) and CR(0x0D) if needed
$line = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\xA0\xAD]/u', '', $line) ?? '';
// Revert <br /> back to newlines and final strip_tags as double safety
$line = str_replace('<br />', "\n", $line);
$line = strip_tags($line);
return $line;
}
/**
* Beautify text
*
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
*/
public static function beautifyText(?string $text): ?string
{
// phpcs:enable
if ($text === null) {
return null;
}
$floatingPointEsc = chr(21);
$s = preg_replace('/(\d+)[,\.](\d+)/', '$1' . $floatingPointEsc . '$2', $text) ?? '';
$mbUcfirst = function (string $str): string {
if ($str === '') {
return $str;
}
$first = mb_substr($str, 0, 1);
$rest = mb_substr($str, 1);
return mb_strtoupper($first) . $rest;
};
$squish = function (string $str): string {
return trim((string) preg_replace('/\s+/u', ' ', $str));
};
$startsWith = function (string $hay, string | array $needles): bool {
foreach ((array) $needles as $n) {
if ($n !== '' && mb_strpos($hay, $n) === 0) {
return true;
}
}
return false;
};
$endsWith = function (string $hay, string | array $needles): bool {
foreach ((array) $needles as $n) {
if ($n !== '' && mb_substr($hay, -mb_strlen($n)) === $n) {
return true;
}
}
return false;
};
$s = preg_replace_callback(
'/(\w)\s*\.(\s*)(\w)/mu',
fn ($m) => $m[1] . '. ' . $m[2] . mb_strtoupper($m[3]),
$s
) ?? '';
$s = preg_replace_callback(
'/(\w)(\s*\n+\s*)(\p{Lu})/mu',
fn ($m) => $m[1] . '.' . $m[2] . $m[3],
$s
) ?? '';
$lines = preg_split('/\R/u', $s);
$out = [];
if ($lines === false) {
return $s;
}
foreach ($lines as $line) {
$phrases = array_filter(array_map('trim', explode('.', $line)), fn ($p) => $p !== '');
$phrased = [];
foreach ($phrases as $phrase) {
$p = $squish($phrase);
$p = $mbUcfirst(mb_strtolower($p));
if ($startsWith($p, "'") && ! $endsWith($p, "'")) {
$p = mb_substr($p, 1);
}
if (! $startsWith($p, "'") && $endsWith($p, "'")) {
$p = mb_substr($p, 0, -1);
}
$wordCount = preg_match_all('/\p{L}+/u', $p);
if ($wordCount === 1) {
$p = preg_replace('/,\s*et\s+/u', ' et ', $p);
$phrased[] = $p;
continue;
}
if (! $endsWith($p, [':', ',', '!', '?', ';', '…'])) {
$p .= '.';
} else {
$p = preg_replace('/:/u', ': ', $p) ?? '';
}
$p = preg_replace('/,\s*et\s+/u', ' et ', $p) ?? '';
$p = $squish($p);
$phrased[] = $p;
}
$out[] = implode(' ', $phrased);
}
$result = implode("\n", $out);
$result = str_replace($floatingPointEsc, '.', $result);
$text = $result;
return $result;
}
}

View file

@ -1,360 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiParser;
use Uwil\ArcadiaApiPhpClient\Exception\UnexpectedErrorException;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\AcideGras;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\AcidesAmines;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\ApportsReference;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\ComposesSpeciaux;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\Energie;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\FruitLegumeNoix;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\GlucidesDetail;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\Identification;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\MacronutrimentsPortion;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\MacronutrimentsRef;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\Mineraux;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\NutriScore;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\PointsNutriScore;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\Vitamines;
/**
* Static factory / parser for IncoCompo-related DTOs.
*
* Cannot be instantiated.
*/
final readonly class IncoCompoApiParser
{
public function __construct()
{
throw new \Error('Static class');
}
/**
* Returns the value for $field from $mappedValues, or throws if missing.
*
* @param array<string,mixed> $mappedValues
* @return null|string|int|float|array<mixed>
*/
public static function getArrayKeyValue(array $mappedValues, string $field): null | string | int | float | array
{
if (! array_key_exists($field, $mappedValues)) {
throw new UnexpectedErrorException(
sprintf('Expected field "%s" to exist in mapped values.', $field)
);
}
return $mappedValues[$field];
}
/**
* Safely extracts a nullable string from the mapped array.
* Converts numeric values to their string representation.
*
* @param array<string,mixed> $mapped
*/
public static function toString(array $mapped, string $field): string
{
$value = self::getArrayKeyValue($mapped, $field);
return match (true) {
is_null($value) => '',
is_string($value) => $value,
is_int($value), is_float($value) => (string) $value,
default => throw new UnexpectedErrorException(
sprintf('Expected field "%s" to be a scalar, got %s.', $field, get_debug_type($value))
),
};
}
// =========================================================================
// Private sub-DTO factories
// =========================================================================
/** @param array<string,mixed> $m */
public static function toIdentification(array $m): Identification
{
return new Identification(
nom: self::toString($m, 'nom'),
numero: self::toString($m, 'numero'),
source: self::toString($m, 'source'),
);
}
/** @param array<string,mixed> $m */
public static function toEnergie(array $m): Energie
{
return new Energie(
tailleReference: self::toString($m, 'tailleReference'),
valEnergKjReference: self::toString($m, 'valEnergKjReference'),
valEnergKcalReference: self::toString($m, 'valEnergKcalReference'),
taillePortion: self::toString($m, 'taillePortion'),
valEnergKjPortion: self::toString($m, 'valEnergKjPortion'),
valEnergKcalPortion: self::toString($m, 'valEnergKcalPortion'),
);
}
/** @param array<string,mixed> $m */
public static function toMacronutrimentsRef(array $m): MacronutrimentsRef
{
return new MacronutrimentsRef(
matGrassReference: self::toString($m, 'matGrassReference'),
matGrassReferencePrec: self::toString($m, 'matGrassReferencePrec'),
acideGrasSatureReference: self::toString($m, 'acideGrasSatureReference'),
acideGrasSatureReferencePrec: self::toString($m, 'acideGrasSatureReferencePrec'),
glucidesReference: self::toString($m, 'glucidesReference'),
glucidesReferencePrec: self::toString($m, 'glucidesReferencePrec'),
sucreReference: self::toString($m, 'sucreReference'),
sucreReferencePrec: self::toString($m, 'sucreReferencePrec'),
fibAlimReference: self::toString($m, 'fibAlimReference'),
fibAlimReferencePrec: self::toString($m, 'fibAlimReferencePrec'),
proteineReference: self::toString($m, 'proteineReference'),
proteineReferencePrec: self::toString($m, 'proteineReferencePrec'),
selReference: self::toString($m, 'selReference'),
selReferencePrec: self::toString($m, 'selReferencePrec'),
sodiumReference: self::toString($m, 'sodiumReference'),
);
}
/** @param array<string,mixed> $m */
public static function toMacronutrimentsPortion(array $m): MacronutrimentsPortion
{
return new MacronutrimentsPortion(
matGrassPortion: self::toString($m, 'matGrassPortion'),
matGrassPortionPrec: self::toString($m, 'matGrassPortionPrec'),
acideGrasSaturePortion: self::toString($m, 'acideGrasSaturePortion'),
acideGrasSaturePortionPrec: self::toString($m, 'acideGrasSaturePortionPrec'),
glucidesPortion: self::toString($m, 'glucidesPortion'),
glucidesPortionPrec: self::toString($m, 'glucidesPortionPrec'),
sucrePortion: self::toString($m, 'sucrePortion'),
sucrePortionPrec: self::toString($m, 'sucrePortionPrec'),
fibAlimPortion: self::toString($m, 'fibAlimPortion'),
fibAlimPortionPrec: self::toString($m, 'fibAlimPortionPrec'),
proteinePortion: self::toString($m, 'proteinePortion'),
proteinePortionPrec: self::toString($m, 'proteinePortionPrec'),
selPortion: self::toString($m, 'selPortion'),
selPortionPrec: self::toString($m, 'selPortionPrec'),
sodiumPortion: self::toString($m, 'sodiumPortion'),
);
}
/** @param array<string,mixed> $m */
public static function toFnl(array $m): FruitLegumeNoix
{
return new FruitLegumeNoix(
fruitsReference: self::toString($m, 'fruitsReference'),
fruitsCoqueSechReference: self::toString($m, 'fruitsCoqueSechReference'),
fruitsPortion: self::toString($m, 'fruitsPortion'),
fruitsCoqueSechPortion: self::toString($m, 'fruitsCoqueSechPortion'),
fruitLegumeNoixFrais: self::toString($m, 'fruitLegumeNoixFrais'),
fruitLegumeNoixFraisPortion: self::toString($m, 'fruitLegumeNoixFraisPortion'),
pourcentFruitsLegumesNoixFrais: self::toString($m, 'pourcentFruitsLegumesNoixFrais'),
pourcentFruits: self::toString($m, 'pourcentFruits'),
);
}
/** @param array<string,mixed> $m */
public static function toApportsReference(array $m): ApportsReference
{
return new ApportsReference(
pourcentApportRefE14P: self::toString($m, 'pourcentApportRefE14P'),
pourcentApportRefKjoP: self::toString($m, 'pourcentApportRefKjoP'),
pourcentApportRefFatP: self::toString($m, 'pourcentApportRefFatP'),
pourcentApportRefFatsatP: self::toString($m, 'pourcentApportRefFatsatP'),
pourcentApportRefChovalP: self::toString($m, 'pourcentApportRefChovalP'),
pourcentApportRefSugarP: self::toString($m, 'pourcentApportRefSugarP'),
pourcentApportRefProP: self::toString($m, 'pourcentApportRefProP'),
pourcentApportRefSalteqP: self::toString($m, 'pourcentApportRefSalteqP'),
apportPort: self::toString($m, 'apportPort'),
pSurL: self::toString($m, 'pSurL'),
);
}
/** @param array<string,mixed> $m */
public static function toNutriScore(array $m): NutriScore
{
return new NutriScore(
composanteN: self::toString($m, 'composanteN'),
composanteN2022: self::toString($m, 'composanteN2022'),
composanteP: self::toString($m, 'composanteP'),
composanteP2022: self::toString($m, 'composanteP2022'),
scoreNutri: self::toString($m, 'scoreNutri'),
scoreNutri2022: self::toString($m, 'scoreNutri2022'),
classement: self::toString($m, 'classement'),
nutriScore2022: self::toString($m, 'nutriScore2022'),
nutriAArt: self::toString($m, 'nutriAArt'),
viandeRouge: self::toString($m, 'viandeRouge'),
);
}
/** @param array<string,mixed> $m */
public static function toPointsNutriScore(array $m): PointsNutriScore
{
return new PointsNutriScore(
pointsEnergie: self::toString($m, 'pointsEnergie'),
pointsAcideGrasSature: self::toString($m, 'pointsAcideGrasSature'),
pointsSucres: self::toString($m, 'pointsSucres'),
pointsSel: self::toString($m, 'pointsSel'),
pointsFibres: self::toString($m, 'pointsFibres'),
pointsProteines: self::toString($m, 'pointsProteines'),
pointsFruitsLegumesNoixFrais: self::toString($m, 'pointsFruitsLegumesNoixFrais'),
);
}
/** @param array<string,mixed> $m */
public static function toMineraux(array $m): Mineraux
{
return new Mineraux(
nutCa: self::toString($m, 'nutCa'),
nutMg: self::toString($m, 'nutMg'),
nutK: self::toString($m, 'nutK'),
nutP: self::toString($m, 'nutP'),
nutCld: self::toString($m, 'nutCld'),
nutFe: self::toString($m, 'nutFe'),
nutZn: self::toString($m, 'nutZn'),
nutCu: self::toString($m, 'nutCu'),
nutMn: self::toString($m, 'nutMn'),
nutSe: self::toString($m, 'nutSe'),
nutId: self::toString($m, 'nutId'),
nutCr: self::toString($m, 'nutCr'),
nutMo: self::toString($m, 'nutMo'),
nutS6: self::toString($m, 'nutS6'),
nutNitra: self::toString($m, 'nutNitra'),
);
}
/** @param array<string,mixed> $m */
public static function toVitamines(array $m): Vitamines
{
return new Vitamines(
nutVita: self::toString($m, 'nutVita'),
nutVitb6: self::toString($m, 'nutVitb6'),
nutVitb12: self::toString($m, 'nutVitb12'),
nutVitc: self::toString($m, 'nutVitc'),
nutVitd: self::toString($m, 'nutVitd'),
nutVite: self::toString($m, 'nutVite'),
nutVitk: self::toString($m, 'nutVitk'),
nutChocal: self::toString($m, 'nutChocal'),
nutNia: self::toString($m, 'nutNia'),
nutRibf: self::toString($m, 'nutRibf'),
nutThia: self::toString($m, 'nutThia'),
nutPantac: self::toString($m, 'nutPantac'),
nutBiot: self::toString($m, 'nutBiot'),
nutFoldfe: self::toString($m, 'nutFoldfe'),
);
}
/** @param array<string,mixed> $m */
public static function toGlucidesDetail(array $m): GlucidesDetail
{
return new GlucidesDetail(
nutStarch: self::toString($m, 'nutStarch'),
nutMaltdex: self::toString($m, 'nutMaltdex'),
nutSucs: self::toString($m, 'nutSucs'),
nutGlus: self::toString($m, 'nutGlus'),
nutLacs: self::toString($m, 'nutLacs'),
nutMals: self::toString($m, 'nutMals'),
nutGalfb: self::toString($m, 'nutGalfb'),
nutGHc: self::toString($m, 'nutGHc'),
nutFrufb: self::toString($m, 'nutFrufb'),
nutFrus: self::toString($m, 'nutFrus'),
nutOlsac: self::toString($m, 'nutOlsac'),
nutPolyl: self::toString($m, 'nutPolyl'),
nutFibsol: self::toString($m, 'nutFibsol'),
nutInotl: self::toString($m, 'nutInotl'),
);
}
/** @param array<string,mixed> $m */
public static function toAcideGras(array $m): AcideGras
{
return new AcideGras(
// Lipides généraux
nutFd: self::toString($m, 'nutFd'),
nutChol: self::toString($m, 'nutChol'),
nutAgt: self::toString($m, 'nutAgt'),
// AGS
nutFformiq: self::toString($m, 'nutFformiq'),
nutFacetiq: self::toString($m, 'nutFacetiq'),
nutFpropioniq: self::toString($m, 'nutFpropioniq'),
nutFbutyriq: self::toString($m, 'nutFbutyriq'),
nutFvaleriq: self::toString($m, 'nutFvaleriq'),
nutFcaproiq: self::toString($m, 'nutFcaproiq'),
nutFenatiq: self::toString($m, 'nutFenatiq'),
nutFcapryliq: self::toString($m, 'nutFcapryliq'),
nutFpelargoniq: self::toString($m, 'nutFpelargoniq'),
nutFcapriq: self::toString($m, 'nutFcapriq'),
nutFundecyliq: self::toString($m, 'nutFundecyliq'),
nutFlauriq: self::toString($m, 'nutFlauriq'),
nutFtridecyliq: self::toString($m, 'nutFtridecyliq'),
nutFmyristiq: self::toString($m, 'nutFmyristiq'),
nutFpentadecyliq: self::toString($m, 'nutFpentadecyliq'),
nutF16d0: self::toString($m, 'nutF16d0'),
nutFmargariq: self::toString($m, 'nutFmargariq'),
nutFsteariq: self::toString($m, 'nutFsteariq'),
nutFarachidiq: self::toString($m, 'nutFarachidiq'),
nutFheneicosyliq: self::toString($m, 'nutFheneicosyliq'),
nutFbeheniq: self::toString($m, 'nutFbeheniq'),
nutFtricosiliq: self::toString($m, 'nutFtricosiliq'),
nutFlignoceriq: self::toString($m, 'nutFlignoceriq'),
nutFcarboceriq: self::toString($m, 'nutFcarboceriq'),
nutFcerotiq: self::toString($m, 'nutFcerotiq'),
nutFceroplastiq: self::toString($m, 'nutFceroplastiq'),
nutFmontaniq: self::toString($m, 'nutFmontaniq'),
nutFnonacosyliq: self::toString($m, 'nutFnonacosyliq'),
nutFmelissiq: self::toString($m, 'nutFmelissiq'),
nutFlaceroiq: self::toString($m, 'nutFlaceroiq'),
nutFhentriacontyliq: self::toString($m, 'nutFhentriacontyliq'),
nutFhextriacontyliq: self::toString($m, 'nutFhextriacontyliq'),
nutFheptatriacontyliq: self::toString($m, 'nutFheptatriacontyliq'),
nutFoctatriacontyliq: self::toString($m, 'nutFoctatriacontyliq'),
nutFnonadylique: self::toString($m, 'nutFnonadylique'),
nutFpsylliq: self::toString($m, 'nutFpsylliq'),
// AGMI
nutFpalm: self::toString($m, 'nutFpalm'),
nutFoleiq: self::toString($m, 'nutFoleiq'),
nutF18d: self::toString($m, 'nutF18d'),
nutF19d: self::toString($m, 'nutF19d'),
nutF11eico: self::toString($m, 'nutF11eico'),
nutFgeddiq: self::toString($m, 'nutFgeddiq'),
nutFeruciq: self::toString($m, 'nutFeruciq'),
nutFnervoniq: self::toString($m, 'nutFnervoniq'),
nutFhyeniq: self::toString($m, 'nutFhyeniq'),
nutFamscis: self::toString($m, 'nutFamscis'),
// AGPI
nutF18d2cn6: self::toString($m, 'nutF18d2cn6'),
nutF18d3n3: self::toString($m, 'nutF18d3n3'),
nutF20d4: self::toString($m, 'nutF20d4'),
nutF20d5n3: self::toString($m, 'nutF20d5n3'),
nutF22d6n3: self::toString($m, 'nutF22d6n3'),
nutFapun3: self::toString($m, 'nutFapun3'),
nutFapun6: self::toString($m, 'nutFapun6'),
nutFapun9: self::toString($m, 'nutFapun9'),
nutFapucis: self::toString($m, 'nutFapucis'),
nutFapulc: self::toString($m, 'nutFapulc'),
);
}
/** @param array<string,mixed> $m */
public static function toAcidesAmines(array $m): AcidesAmines
{
return new AcidesAmines(
nutAae: self::toString($m, 'nutAae'),
nutCasn: self::toString($m, 'nutCasn'),
nutWhey: self::toString($m, 'nutWhey'),
);
}
/** @param array<string,mixed> $m */
public static function toComposesSpeciaux(array $m): ComposesSpeciaux
{
return new ComposesSpeciaux(
nutCholn: self::toString($m, 'nutCholn'),
nutCarnitine: self::toString($m, 'nutCarnitine'),
nutTau: self::toString($m, 'nutTau'),
nutNucleotide: self::toString($m, 'nutNucleotide'),
);
}
}

View file

@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder;
enum Direction: string
{
case ASC = 'asc';
case DESC = 'desc';
}

View file

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder;
enum Operator: string
{
case EQUAL = '=';
case NOT_EQUAL = '!=';
case GREATER_THAN = '>';
case GREATER_THAN_OR_EQUAL = '>=';
case LESS_THAN = '<';
case LESS_THAN_OR_EQUAL = '<=';
case BETWEEN = 'BETWEEN';
case NOT_BETWEEN = 'NOT BETWEEN';
case LIKE = 'LIKE';
case NOT_LIKE = 'NOT LIKE';
case IN = 'IN';
case NOT_IN = 'NOT IN';
}

View file

@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder;
final readonly class TableQuery implements \Stringable
{
/**
* @param list<string> $selectColumns
* @param list<array{column:string,operator:Operator,value:string}> $criteria
* @param list<array{column:string,direction:Direction}> $orderClauses
*/
public function __construct(
public string $table,
public ?string $soc = null,
public ?string $lang = null,
public array $selectColumns = [],
public array $criteria = [],
public array $orderClauses = [],
public ?int $page = null,
public ?int $pageSize = null,
) {
}
public function __toString(): string
{
$params = [];
$params['table'] = $this->table;
if ($this->soc !== null) {
$params['soc'] = $this->soc;
}
if ($this->lang !== null) {
$params['lang'] = $this->lang;
}
if ($this->selectColumns !== []) {
$params['s_cols'] = implode('|', $this->selectColumns);
}
if ($this->criteria !== []) {
$params['c_cols'] = implode('|', array_column($this->criteria, 'column'));
$params['c_opes'] = implode('|', array_map(
static fn (array $c): string => $c['operator']->value,
$this->criteria,
));
$params['c_vals'] = implode('|', array_column($this->criteria, 'value'));
}
if ($this->orderClauses !== []) {
$params['t_cols'] = implode('|', array_column($this->orderClauses, 'column'));
$params['t_opes'] = implode('|', array_map(
static fn (array $o): string => $o['direction']->value,
$this->orderClauses,
));
}
if ($this->page !== null) {
$params['page'] = (string) $this->page;
}
if ($this->pageSize !== null) {
$params['page_size'] = (string) $this->pageSize;
}
$parts = [];
foreach ($params as $key => $value) {
$parts[] = $key . '=' . $value;
}
return implode('&', $parts);
}
/**
* @return array<string, string|int|null>
*/
public function toArray(): array
{
return array_filter([
'table' => $this->table,
'soc' => $this->soc,
'lang' => $this->lang,
's_cols' => $this->selectColumns !== [] ? implode('|', $this->selectColumns) : null,
'c_cols' => $this->criteria !== [] ? implode('|', array_column($this->criteria, 'column')) : null,
'c_opes' => $this->criteria !== [] ? implode('|', array_map(
static fn (array $c): string => $c['operator']->value,
$this->criteria,
)) : null,
'c_vals' => $this->criteria !== [] ? implode('|', array_column($this->criteria, 'value')) : null,
't_cols' => $this->orderClauses !== [] ? implode('|', array_column($this->orderClauses, 'column')) : null,
't_opes' => $this->orderClauses !== [] ? implode('|', array_map(
static fn (array $o): string => $o['direction']->value,
$this->orderClauses,
)) : null,
'page' => $this->page,
'page_size' => $this->pageSize,
], static fn (mixed $v): bool => $v !== null);
}
}

View file

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder;
interface TableQueryBuildStep
{
public function build(): TableQuery;
public function toQueryString(): string;
}

View file

@ -1,211 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder;
use Uwil\ArcadiaApiPhpClient\Lib\Pagination;
final class TableQueryBuilder implements
TableQueryBuildStep,
TableQueryFromStep,
TableQueryOrderByStep,
TableQueryPaginateStep,
TableQuerySelectStep,
TableQueryWhereStep
{
private string $table;
private ?string $soc = null;
private ?string $lang = null;
/** @var list<string> */
private array $selectColumns = [];
/** @var list<array{column: string, operator: Operator, value: string}> */
private array $criteria = [];
/** @var list<array{column: string, direction: Direction}> */
private array $orderClauses = [];
private ?Pagination $pagination = null;
protected function __construct()
{
// PRIVATE
}
public static function query(): TableQueryFromStep
{
return new self();
}
// * FromStep
public function from(string $table): TableQuerySelectStep
{
if (trim($table) === '') {
throw new \InvalidArgumentException('Table name must not be empty.');
}
$this->table = $table;
return $this;
}
// * SelectStep
public function society(string $soc): static
{
$this->soc = $soc;
return $this;
}
public function language(string $lang): static
{
$this->lang = $lang;
return $this;
}
public function select(string ...$columns): TableQueryWhereStep
{
$this->selectColumns = array_values($columns);
return $this;
}
public function selectAll(): TableQueryWhereStep
{
$this->selectColumns = [];
return $this;
}
// * WhereStep
public function where(string $column, Operator $operator, string $value): TableQueryWhereStep
{
$this->criteria[] = [
'column' => $column,
'operator' => $operator,
'value' => $value,
];
return $this;
}
public function whereEqual(string $column, string $value): TableQueryWhereStep
{
return $this->where($column, Operator::EQUAL, $value);
}
public function whereNotEqual(string $column, string $value): TableQueryWhereStep
{
return $this->where($column, Operator::NOT_EQUAL, $value);
}
public function whereGreaterThan(string $column, string $value): TableQueryWhereStep
{
return $this->where($column, Operator::GREATER_THAN, $value);
}
public function whereGreaterThanOrEqual(string $column, string $value): TableQueryWhereStep
{
return $this->where($column, Operator::GREATER_THAN_OR_EQUAL, $value);
}
public function whereLessThan(string $column, string $value): TableQueryWhereStep
{
return $this->where($column, Operator::LESS_THAN, $value);
}
public function whereLessThanOrEqual(string $column, string $value): TableQueryWhereStep
{
return $this->where($column, Operator::LESS_THAN_OR_EQUAL, $value);
}
public function whereBetween(string $column, string $value): TableQueryWhereStep
{
return $this->where($column, Operator::BETWEEN, $value);
}
public function whereNotBetween(string $column, string $value): TableQueryWhereStep
{
return $this->where($column, Operator::NOT_BETWEEN, $value);
}
public function whereLike(string $column, string $value): TableQueryWhereStep
{
return $this->where($column, Operator::LIKE, $value);
}
public function whereNotLike(string $column, string $value): TableQueryWhereStep
{
return $this->where($column, Operator::NOT_LIKE, $value);
}
public function whereIn(string $column, string ...$value): TableQueryWhereStep
{
return $this->where($column, Operator::IN, implode(',', $value));
}
public function whereNotIn(string $column, string ...$value): TableQueryWhereStep
{
return $this->where($column, Operator::NOT_IN, implode(',', $value));
}
// * OrderByStep
public function orderBy(string $column, Direction $direction = Direction::ASC): TableQueryOrderByStep
{
$this->orderClauses[] = [
'column' => $column,
'direction' => $direction,
];
return $this;
}
public function orderByAsc(string $column): TableQueryOrderByStep
{
return $this->orderBy($column, Direction::ASC);
}
public function orderByDesc(string $column): TableQueryOrderByStep
{
return $this->orderBy($column, Direction::DESC);
}
// * PaginateStep
public function paginate(Pagination $pagination): TableQueryBuildStep
{
$this->pagination = $pagination;
return $this;
}
// * BuildStep
public function build(): TableQuery
{
return new TableQuery(
table: $this->table,
soc: $this->soc,
lang: $this->lang,
selectColumns: $this->selectColumns,
criteria: $this->criteria,
orderClauses: $this->orderClauses,
page: $this->pagination?->pageNumber(),
pageSize: $this->pagination?->pageSize(),
);
}
public function toQueryString(): string
{
return (string) $this->build();
}
}

View file

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder;
interface TableQueryFromStep
{
public function from(string $table): TableQuerySelectStep;
}

View file

@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder;
interface TableQueryOrderByStep extends TableQueryPaginateStep
{
public function orderBy(string $column, Direction $direction = Direction::ASC): TableQueryOrderByStep;
public function orderByAsc(string $column): TableQueryOrderByStep;
public function orderByDesc(string $column): TableQueryOrderByStep;
}

View file

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder;
use Uwil\ArcadiaApiPhpClient\Lib\Pagination;
interface TableQueryPaginateStep extends TableQueryBuildStep
{
public function paginate(Pagination $pagination): TableQueryBuildStep;
}

View file

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder;
interface TableQuerySelectStep extends TableQueryWhereStep
{
/**
* Set the society code (soc parameter).
*/
public function society(string $soc): static;
/**
* Set the language code (lang parameter).
*/
public function language(string $lang): static;
/**
* Select specific columns (s_cols).
*/
public function select(string ...$columns): TableQueryWhereStep;
/**
* Select all columns (no s_cols parameter will be sent).
*/
public function selectAll(): TableQueryWhereStep;
}

View file

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder;
interface TableQueryWhereStep extends TableQueryOrderByStep
{
public function where(string $column, Operator $operator, string $value): TableQueryWhereStep;
public function whereEqual(string $column, string $value): TableQueryWhereStep;
public function whereNotEqual(string $column, string $value): TableQueryWhereStep;
public function whereGreaterThan(string $column, string $value): TableQueryWhereStep;
public function whereGreaterThanOrEqual(string $column, string $value): TableQueryWhereStep;
public function whereLessThan(string $column, string $value): TableQueryWhereStep;
public function whereLessThanOrEqual(string $column, string $value): TableQueryWhereStep;
public function whereBetween(string $column, string $value): TableQueryWhereStep;
public function whereNotBetween(string $column, string $value): TableQueryWhereStep;
public function whereLike(string $column, string $value): TableQueryWhereStep;
public function whereNotLike(string $column, string $value): TableQueryWhereStep;
public function whereIn(string $column, string ...$value): TableQueryWhereStep;
public function whereNotIn(string $column, string ...$value): TableQueryWhereStep;
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiResponse;
final readonly class ApiPagination
{
public function __construct(
public int $page,
public int $pageSize,
public int $totalItems,
public int $totalPages,
public ApiPaginationLinks $links,
) {
}
}

View file

@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiResponse;
use GuzzleHttp\Psr7\Uri;
use Psr\Http\Message\UriInterface;
final readonly class ApiPaginationLinks
{
public function __construct(
public UriInterface $first,
public UriInterface $last,
public ?UriInterface $next,
public ?UriInterface $previous
) {
}
/**
* @param array<string, string> $links
*/
public static function fromArray(array $links): self
{
/** @var Uri[] $links */
$links = array_map([self::class, 'toUri'], $links);
return new self(
$links['first'],
$links['last'],
$links['next'] ?? null,
$links['previous'] ?? null,
);
}
protected static function toUri(string $value): Uri
{
return new Uri($value);
}
}

View file

@ -1,184 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiResponse;
use UnexpectedValueException;
use Uwil\ArcadiaApiPhpClient\Exception\InvalidApiResponse;
use Uwil\ArcadiaApiPhpClient\Utils\StringEncoder;
use Uwil\ArcadiaApiPhpClient\Utils\Utils;
// https://github.com/PHPCSStandards/PHP_CodeSniffer/issues/734
// Les property hook ne sont pas supportés.
// phpcs:disable
final class ApiResponse
{
private const DATA_KEY = 'data';
private const PAGINATION_KEY = 'pagination';
public readonly ?ApiPagination $pagination;
/** @var array<string, mixed> */
public array $data {
get {
return Utils::deepCopy($this->data);
}
}
/**
* @param array<string,mixed> $responseData
*/
public function __construct(
array $responseData
) {
$this->pagination = $this->toPagination($responseData);
$this->data = $this->toData(
$responseData,
$this->pagination !== null
);
}
public function isEmpty(): bool
{
return count($this->data) === 0;
}
/**
* @param array<string,mixed> $data
*/
public static function fromBody(array $data): self
{
return new ApiResponse($data);
}
/**
* @template T of mixed
*
* NOTE: Can only be call when reponse is paginated.
*
* @param callable(array<string,mixed>):T $hook
* @return T[]
*/
public function mapData(callable $hook): array
{
if ($this->pagination === null) {
throw new \LogicException('Response is not paginated.');
}
return array_map(fn (array $item) => $hook($item), $this->data);
}
/**
* @param array<string,mixed> $responseData
* @return array<string, mixed>
*/
protected function toData(array $responseData, bool $isPaginated): array
{
if (
! array_key_exists(self::DATA_KEY, $responseData)
|| ! is_array($responseData[self::DATA_KEY])
) {
throw new InvalidApiResponse(
sprintf(
'Missing key %s form in response',
self::DATA_KEY
)
);
}
/** @var array<string,mixed> $output */
$output = $responseData[self::DATA_KEY];
if ($isPaginated && count($output)) {
// NOTE: When there is only one result output is not a list but a record (Not documentend, seen on 30/03/26).
$keys = array_keys($output);
/** @var array<string, mixed> */
return (array_shift($keys) === 0 || ! count($output)) ?
$output : [$output];
}
// * Make string any string is encoded in proper UTF-8 string.
$this->encodeCallOutputToUtf8($output);
if (! is_array($output)) {
throw new UnexpectedValueException('Output shall be ann array here');
}
return $output;
}
/**
* @param array<string, mixed> $data
*/
protected function toPagination(array $data): ?ApiPagination
{
if (
! array_key_exists(self::PAGINATION_KEY, $data)
|| ! is_array($data[self::PAGINATION_KEY])
) {
return null;
}
$skel = [
'page' => 1,
'pageSize' => 1,
'totalItems' => 0,
'totalPages' => 0,
'links' => [
'first' => 'https://none.example.ldc.fr',
'last' => 'https://none.example.ldc.fr',
],
];
$expectedKeys = array_keys($skel);
$actualKeys = array_keys($data[self::PAGINATION_KEY] ?? []);
$missing = array_diff($expectedKeys, $actualKeys);
$extra = array_diff($actualKeys, $expectedKeys);
if ($missing || $extra) {
throw new InvalidApiResponse(
sprintf(
'Unexpected %s form in response, mssing keys: %s, extra keys : %s',
self::PAGINATION_KEY,
json_encode($missing),
json_encode($extra),
)
);
}
$page = intval($data[self::PAGINATION_KEY]['page']);
$pageSize = intval($data[self::PAGINATION_KEY]['pageSize']);
$totalItems = intval($data[self::PAGINATION_KEY]['totalItems']);
$totalPages = intval($data[self::PAGINATION_KEY]['totalPages']);
$links = ApiPaginationLinks::fromArray($data[self::PAGINATION_KEY]['links']);
return new ApiPagination(
$page,
$pageSize,
$totalItems,
$totalPages,
$links,
);
}
/** @param array<string,mixed> $output */
private function encodeCallOutputToUtf8(null | string | array &$output): void
{
if (is_array($output)) {
array_walk_recursive($output, function (null | string | array &$nestedOutput): void {
$this->encodeCallOutputToUtf8($nestedOutput);
});
}
if (! is_string($output)) {
return;
}
$output = StringEncoder::detectAndConvertToUtf8($output);
}
}

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject;
use Uwil\ArcadiaApiPhpClient\Exception\DomainException;
use Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder\Direction;
final readonly class AbstractSorter
{
public function __construct(
public string $sortBy,
public Direction $direction
) {
if (empty(mb_trim($sortBy))) {
throw new DomainException('Sort by cannot be empty');
}
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject;
use Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder\Direction;
final readonly class EmployeeSorter
{
public function __construct(
public EmployeeSortColum $sortBy,
public Direction $direction
) {
}
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib;
enum HttpMethod: string
{
case HEAD = 'HEAD';
case GET = 'GET';
case POST = 'POST';
case PUT = 'PUT';
case PATCH = 'PATCH';
case DELETE = 'DELETE';
}

View file

@ -1,160 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\Psr7;
use GuzzleHttp\Psr7\Request as Psr7Request;
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\Utils as Psr7Utils;
use GuzzleHttp\Utils as GuzzleHttpUtils;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\UriInterface;
use Uwil\ArcadiaApiPhpClient\Lib\ApiConfig;
use Uwil\ArcadiaApiPhpClient\Lib\HttpMethod;
use Uwil\ArcadiaApiPhpClient\Utils\Utils;
final readonly class RequestFactory
{
public function __construct(
#[\SensitiveParameter]
private readonly ApiConfig $apiConfig
) {
}
/**
* @param array<array-key,mixed> $data
* @param array<string,string> $headersMap
*/
public function buildRequest(
HttpMethod $method,
UriInterface $uri,
array $data = [],
array $headersMap = [],
): RequestInterface {
$scheme = $uri->getScheme();
if ($scheme !== 'http' && $scheme !== 'https') {
throw new \InvalidArgumentException('Only http(s) is allowed');
}
foreach ($headersMap as $headerName => $value) {
if (! is_string($headerName)) {
throw new \InvalidArgumentException('Header must be a string');
}
if (! is_string($value)) {
throw new \InvalidArgumentException("Header value '{$headerName}' must be a string");
}
}
// ! Make sure parameters are on queries when HTTP GET
if ($method === HttpMethod::GET) {
$this->putAllParamsOnQuery($uri, $data);
} else {
$this->putAllParamInData($uri, $data);
}
$timestamp = Utils::getNow();
$request = new Psr7Request(
\strtoupper($method->value),
$uri,
$headersMap
);
// NOTE: Dont prodive body content on GET method.
$jsonEncodedData = $method !== HttpMethod::GET ? GuzzleHttpUtils::jsonEncode($data) : '';
$alteredUri = $this->transformUriInOrderToMatchLabProxy($uri);
$signature = Utils::calculateSignature(
$method,
$alteredUri,
$jsonEncodedData,
$this->apiConfig->secret,
$timestamp
);
$fullApiHeaders = $this->addApiHeaders(
$headersMap,
$signature,
$timestamp
);
return Psr7Utils::modifyRequest($request, [
'set_headers' => $fullApiHeaders,
'body' => $jsonEncodedData,
]);
}
/**
* @param array<string,string> $headers
* @return array<string,string>
*/
protected function addApiHeaders(
array $headers,
string $signature,
\DateTimeInterface $timestamp
): array {
return array_merge(
$headers,
[
'Content-Type' => 'application/json',
'X-API-Key' => $this->apiConfig->apiKey,
'X-API-Signature' => $signature,
'X-Timestamp' => Utils::toRfc3339($timestamp),
]
);
}
/**
* @param array<string,mixed> $data
*/
protected function putAllParamsOnQuery(UriInterface &$uri, array &$data): void
{
$existingQuery = $uri->getQuery();
$queryArray = [];
if ($existingQuery !== '') {
parse_str($existingQuery, $queryArray);
}
$merged = array_merge($queryArray, $data);
$newQuery = $merged ? http_build_query($merged) : '';
$uri = $uri->withQuery($newQuery);
$data = [];
}
/**
* @param array<array-key,mixed> $data
*/
protected function putAllParamInData(UriInterface &$uri, array &$data): void
{
$existingQuery = $uri->getQuery();
if ($existingQuery === '') {
return;
}
$queryArray = [];
parse_str($existingQuery, $queryArray);
$data = array_merge($queryArray, $data);
$uri = $uri->withQuery('');
}
/**
* FIXME: This should be removed, temp fix
*/
protected function transformUriInOrderToMatchLabProxy(UriInterface $uri): UriInterface
{
$scheme = $uri->getScheme();
$authority = str_replace('lesidc', 'svlidc', $uri->getAuthority());
$path = '/gaston/api' . preg_replace('/^\/gaston\/api/', '', $uri->getPath());
$query = ! empty($uri->getQuery()) ? "?{$uri->getQuery()}" : '';
$fragment = ! empty($uri->getFragment()) ? "#{$uri->getFragment()}" : '';
return new Uri(
"{$scheme}://{$authority}{$path}{$query}{$fragment}"
);
}
}

View file

@ -1,150 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Mapper;
use DomainException;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Employee;
use Uwil\ArcadiaApiPhpClient\Lib\ApiParser\IncoApiStringParser;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\ArcadiaEmployeeId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\EmployeeSortColum as EmployeeSortColumn;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\EstablishmentCode;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\ServiceId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\SiteId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\SortColumn;
use Uwil\ArcadiaApiPhpClient\Utils\DateUtils;
final class EmployeeMapper implements Mapper
{
protected string $table = 'RU_PERSONNE_SIRH';
protected string $tableKey = 'matricule';
/**
* Mapping camelCase (propriété PHP) => NOM_COLONNE (API/BDD).
*
* @var array<string, string>
*/
protected array $definition = [
'matricule' => 'MATRICULE_FIC',
'lastname' => 'NOM_FIC',
'firstname' => 'PRENOM_FIC',
'start_date' => 'DATE_DEB_FIC',
'end_date' => 'DATE_FIN_FIC',
'planned_end_date' => 'DATE_EFFET_FIC',
'establishment' => 'ETAB_FIC',
'site' => 'SITE_FIC',
'service' => 'SERVICE_FIC',
'created_at' => 'DATE_EFFET_FIC',
'updated_at' => 'DAT_MAJ',
];
public function getTable(): string
{
return $this->table;
}
public function getTableKey(): string
{
return $this->definition[$this->tableKey];
}
public function getPropertiesMap(): array
{
return $this->definition;
}
public function getValueFor(string | SortColumn $column): string
{
if (is_string($column)) {
return array_key_exists($column, $this->definition) ?
$this->definition[$column] :
throw new \InvalidArgumentException("No matching column for {$column}");
}
if (! ($column instanceof EmployeeSortColumn)) {
throw new \InvalidArgumentException(
'Expected ' . EmployeeSortColumn::class .
' given ' . get_class($column)
);
}
return $this->mapToArcadiaColumn($column);
}
public function toDto(array $data): Employee
{
$columnsMapper = $this->getPropertiesMap();
/** @var array<string,null|mixed> */
$mapped = [];
foreach ($columnsMapper as $modelName => $columnName) {
// FIXME: Ceci devrait être ok au lieu de cela l'API ne respecte pas le contrat du select quand un champ est null.
// if (! array_key_exists($columnName, $data)) {
// throw new UnexpectedErrorException("Column '{$columnName}' not found in data " . json_encode($data));
// }
// FIXME: Supprimer ceci quand le bug d'api aura été corrigé, et decommenter le code au dessus.
if (! array_key_exists($columnName, $data)) {
// NOTE: On ne devrais pas avoir a faire cela.
$mapped[$modelName] = null;
continue;
}
$mapped[$modelName] = $data[$columnName];
if (is_string($mapped[$modelName])) {
$mapped[$modelName] = IncoApiStringParser::protectiveDataFilter($mapped[$modelName]);
}
}
$employeeId = new ArcadiaEmployeeId($mapped['matricule']);
$siteeId = new SiteId($mapped['site']);
$serviceId = new ServiceId($mapped['service']);
$establishmentCode = new EstablishmentCode($mapped['establishment']);
$startDate = $this->toDateTime($mapped['start_date']);
if ($startDate === null) {
throw new DomainException('Missing start date');
}
$createdAt = $this->toDateTime($mapped['start_date']);
if ($createdAt === null) {
throw new DomainException('Missing created at');
}
return new Employee(
$employeeId,
$mapped['lastname'],
$mapped['firstname'],
$startDate,
$this->toDateTime($mapped['end_date']),
$this->toDateTime($mapped['planned_end_date']),
$establishmentCode,
$siteeId,
$serviceId,
$createdAt,
$this->toDateTime($mapped['updated_at'])
);
}
protected function toDateTime(?string $datetime): ?\DateTimeInterface
{
return is_null($datetime) ? null : DateUtils::parseDateTime($datetime);
}
protected function mapToArcadiaColumn(EmployeeSortColumn $column): string
{
return match ($column) {
EmployeeSortColumn::arcadiaId => $this->definition['matricule'],
EmployeeSortColumn::startDate => $this->definition['start_date'],
EmployeeSortColumn::endDate => $this->definition['end_date'],
EmployeeSortColumn::plannedEndDate => $this->definition['planned_end_date'],
EmployeeSortColumn::createdAt => $this->definition['created_at'],
EmployeeSortColumn::updatedAt => $this->definition['updated_at']
};
}
}

View file

@ -1,435 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Mapper;
use Uwil\ArcadiaApiPhpClient\Exception\UnexpectedErrorException;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
use Uwil\ArcadiaApiPhpClient\Lib\ApiParser\IncoApiStringParser;
use Uwil\ArcadiaApiPhpClient\Lib\ApiParser\IncoCompoApiParser;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoCompoId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\SortColumn;
final class IncoCompoMapper implements Mapper
{
private string $table = 'INCO_COMPO';
private string $tableKey = 'id';
/**
* Mapping camelCase (propriété PHP) => NOM_COLONNE (API/BDD).
*
* @var array<string, string>
*/
private array $definition = [
'id' => 'ID',
'idInco' => 'ID_INCO',
// =====================================================================
// ÉNERGIE
// =====================================================================
// * Par 100g/ml (référence _R)
'tailleReference' => 'TAILLE_REF',
'valEnergKjReference' => 'VAL_ENERG_KJ_R',
// ! MISSING VAL_ENERG_KJ_NUT
'valEnergKcalReference' => 'VAL_ENERG_KCAL_R',
// ! MISSING VAL_ENERG_KCAL_NUT
// ! MISSING ENER_E14_R
// ! MISSING ENER_KJO_R
// ! MISSING POURCENT_APPORT_REF_E14_R
// ! MISSING POURCENT_APPORT_REF_KJO_R
// * Par portion (_P)
'taillePortion' => 'TAILLE_PORT',
// ! MISSING FNL_FRAIS_PORTION (présent en BDD mais absent ici - voir section FNL)
'valEnergKjPortion' => 'VAL_ENERG_KJ_P',
'valEnergKcalPortion' => 'VAL_ENERG_KCAL_P',
// ! MISSING ENER_E14_P
// ! MISSING ENER_KJO_P
// ! MISSING POURCENT_APPORT_REF_E14_P ← présent en API mais dans $definition d'origine comme POURCENT_APPORT_REF_E14_P
'pourcentApportRefE14P' => 'POURCENT_APPORT_REF_E14_P',
'pourcentApportRefKjoP' => 'POURCENT_APPORT_REF_KJO_P',
// =====================================================================
// MACRONUTRIMENTS - Par 100g (_R)
// =====================================================================
'matGrassReference' => 'MAT_GRASS_R',
// ! MISSING MAT_GRASS_NUT
'matGrassReferencePrec' => 'MAT_GRASS_R_PREC',
// ! MISSING FATSAT_R
// ! MISSING POURCENT_APPORT_REF_FATSAT_R
'acideGrasSatureReference' => 'AGS_R',
// ! MISSING AGS_NUT
'acideGrasSatureReferencePrec' => 'AGS_R_PREC',
'glucidesReference' => 'GLUCIDES_R',
'glucidesReferencePrec' => 'GLUCIDES_R_PREC',
// ! MISSING CHOVAL_R
// ! MISSING POURCENT_APPORT_REF_CHOVAL_R
'sucreReference' => 'SUCRE_R',
// ! MISSING SUCRE_NUT
'sucreReferencePrec' => 'SUCRE_R_PREC',
// ! MISSING SUGAR_R
// ! MISSING POURCENT_APPORT_REF_SUGAR_R
'fibAlimReference' => 'FIB_ALIM_R',
// ! MISSING FIB_ALIM_NUT
'fibAlimReferencePrec' => 'FIB_ALIM_R_PREC',
// ! MISSING FIBTG_R
// ! MISSING POURCENT_APPORT_REF_FIBTG_R
'proteineReference' => 'PROTEINE_R',
// ! MISSING PROTEINE_NUT
'proteineReferencePrec' => 'PROTEINE_R_PREC',
// ! MISSING PRO_R
// ! MISSING POURCENT_APPORT_REF_PRO_R
'selReference' => 'SEL_R',
// ! MISSING SEL_NUT
'selReferencePrec' => 'SEL_R_PREC',
// ! MISSING SALTEQ_R
// ! MISSING POURCENT_APPORT_REF_SALTEQ_R
'sodiumReference' => 'SODIUM_R',
// ! MISSING SODIUM_R_PREC
// =====================================================================
// MACRONUTRIMENTS - Par portion (_P)
// =====================================================================
'matGrassPortion' => 'MAT_GRASS_P',
'matGrassPortionPrec' => 'MAT_GRASS_P_PREC',
// ! MISSING FAT_P
'pourcentApportRefFatP' => 'POURCENT_APPORT_REF_FAT_P',
// ! MISSING FATSAT_P
'pourcentApportRefFatsatP' => 'POURCENT_APPORT_REF_FATSAT_P',
'acideGrasSaturePortion' => 'AGS_P',
'acideGrasSaturePortionPrec' => 'AGS_P_PREC',
'glucidesPortion' => 'GLUCIDES_P',
'glucidesPortionPrec' => 'GLUCIDES_P_PREC',
// ! MISSING CHOVAL_P
'pourcentApportRefChovalP' => 'POURCENT_APPORT_REF_CHOVAL_P',
'sucrePortion' => 'SUCRE_P',
'sucrePortionPrec' => 'SUCRE_P_PREC',
// ! MISSING SUGAR_P
'pourcentApportRefSugarP' => 'POURCENT_APPORT_REF_SUGAR_P',
'fibAlimPortion' => 'FIB_ALIM_P',
'fibAlimPortionPrec' => 'FIB_ALIM_P_PREC',
// ! MISSING FIBTG_P
// ! MISSING POURCENT_APPORT_REF_FIBTG_P
'proteinePortion' => 'PROTEINE_P',
'proteinePortionPrec' => 'PROTEINE_P_PREC',
// ! MISSING PRO_P
'pourcentApportRefProP' => 'POURCENT_APPORT_REF_PRO_P',
'selPortion' => 'SEL_P',
'selPortionPrec' => 'SEL_P_PREC',
// ! MISSING SALTEQ_P
'pourcentApportRefSalteqP' => 'POURCENT_APPORT_REF_SALTEQ_P',
'sodiumPortion' => 'SODIUM_P',
// =====================================================================
// APPORTS DE RÉFÉRENCE - Divers
// =====================================================================
// ! MISSING APPORT_REF (apport de référence pour 100g)
'apportPort' => 'APPORT_PORT', // * présent dans l'API : "Apport de référence pour un adulte type (8400Kj-2000Kcal)"
// ! MISSING PART_P
// ! MISSING PART_G
// ! MISSING PART_L
'pSurL' => 'P_SUR_L',
// ! MISSING POURCENT_AGS
// ! MISSING POURCENT_AGI
// ! MISSING POURCENT_SS
// ! MISSING POURCENT_AMIDON
// =====================================================================
// FRUITS, NOIX, LÉGUMES (FNL)
// =====================================================================
'fruitsReference' => 'FRUITS_R',
// ! MISSING FRUITS_R_PREC
'fruitsPortion' => 'FRUITS_P',
'fruitsCoqueSechReference' => 'FRUITS_COQUE_SECH_R',
// ! MISSING FRUITS_COQUE_SECH_R_PREC
'fruitsCoqueSechPortion' => 'FRUITS_COQUE_SECH_P',
'fruitLegumeNoixFrais' => 'FNL_FRAIS',
'fruitLegumeNoixFraisPortion' => 'FNL_FRAIS_PORTION',
'pourcentFruitsLegumesNoixFrais' => 'POURCENT_FNL_FRAIS',
'pourcentFruits' => 'POURCENT_FRUITS',
// =====================================================================
// NUTRI-SCORE
// =====================================================================
// * Composantes de calcul
'composanteN' => 'COMPOSANTE_N',
'composanteN2022' => 'COMPOSANTE_N_2022',
'composanteP' => 'COMPOSANTE_P',
'composanteP2022' => 'COMPOSANTE_P_2022',
// * Scores bruts
'scoreNutri' => 'SCORE_NUTRI',
'scoreNutri2022' => 'SCORE_NUTRI_2022',
// * Classement final (lettre A-E)
// ! NOTE : CLASSEMENT n'est pas censé être dans INCO_COMPO selon la spec
'classement' => 'CLASSEMENT',
'nutriScore2022' => 'NUTRI_SCORE_2022',
'nutriAArt' => 'NUTRI_A_ART',
// * Indicateur viande rouge (algorithme 2022)
'viandeRouge' => 'VIANDE_ROUGE',
// =====================================================================
// POINTS NUTRI-SCORE
// =====================================================================
'pointsEnergie' => 'POINTS_ENERGIE',
'pointsAcideGrasSature' => 'POINTS_AGS',
'pointsSucres' => 'POINTS_SUCRES',
'pointsSel' => 'POINTS_SEL',
'pointsFibres' => 'POINTS_FIBRES',
'pointsProteines' => 'POINTS_PROTEINES',
'pointsFruitsLegumesNoixFrais' => 'POINTS_FNL_FRAIS',
// =====================================================================
// IDENTIFICATION / MÉTADONNÉES
// =====================================================================
'nom' => 'NOM',
'numero' => 'NUMERO',
'source' => 'SOURCE',
// =====================================================================
// MINÉRAUX & OLIGO-ÉLÉMENTS
// =====================================================================
'nutCa' => 'NUT_CA',
'nutMg' => 'NUT_MG',
'nutK' => 'NUT_K',
'nutP' => 'NUT_P',
'nutCld' => 'NUT_CLD',
'nutFe' => 'NUT_FE',
'nutZn' => 'NUT_ZN',
'nutCu' => 'NUT_CU',
'nutMn' => 'NUT_MN',
'nutSe' => 'NUT_SE',
'nutId' => 'NUT_ID',
'nutCr' => 'NUT_CR',
'nutMo' => 'NUT_MO',
'nutS6' => 'NUT_S6',
'nutNitra' => 'NUT_NITRA',
// =====================================================================
// VITAMINES
// =====================================================================
'nutVita' => 'NUT_VITA',
'nutVitb6' => 'NUT_VITB6',
'nutVitb12' => 'NUT_VITB12',
'nutVitc' => 'NUT_VITC',
'nutVitd' => 'NUT_VITD',
'nutVite' => 'NUT_VITE',
'nutVitk' => 'NUT_VITK',
'nutChocal' => 'NUT_CHOCAL',
'nutNia' => 'NUT_NIA',
'nutRibf' => 'NUT_RIBF',
'nutThia' => 'NUT_THIA',
'nutPantac' => 'NUT_PANTAC',
'nutBiot' => 'NUT_BIOT',
'nutFoldfe' => 'NUT_FOLDFE',
// =====================================================================
// GLUCIDES DÉTAILLÉS
// =====================================================================
'nutStarch' => 'NUT_STARCH',
'nutMaltdex' => 'NUT_MALTDEX',
'nutSucs' => 'NUT_SUCS',
'nutGlus' => 'NUT_GLUS',
'nutLacs' => 'NUT_LACS',
'nutMals' => 'NUT_MALS',
'nutGalfb' => 'NUT_GALFB',
'nutGHc' => 'NUT_G_HC',
'nutFrufb' => 'NUT_FRUFB',
'nutFrus' => 'NUT_FRUS',
'nutOlsac' => 'NUT_OLSAC',
'nutPolyl' => 'NUT_POLYL',
'nutFibsol' => 'NUT_FIBSOL',
'nutInotl' => 'NUT_INOTL',
// =====================================================================
// LIPIDES / ACIDES GRAS
// =====================================================================
// * Lipides généraux
'nutFd' => 'NUT_FD',
'nutChol' => 'NUT_CHOL',
'nutAgt' => 'NUT_AGT',
// * AGS - chaîne courte (C1-C10)
'nutFformiq' => 'NUT_FFORMIQ', // C1:0
'nutFacetiq' => 'NUT_FACETIQ', // C2:0
'nutFpropioniq' => 'NUT_FPROPIONIQ', // C3:0
'nutFbutyriq' => 'NUT_FBUTYRIQ', // C4:0
'nutFvaleriq' => 'NUT_FVALERIQ', // C5:0
'nutFcaproiq' => 'NUT_FCAPROIQ', // C6:0
'nutFenatiq' => 'NUT_FENATIQ', // C7:0
'nutFcapryliq' => 'NUT_FCAPRYLIQ', // C8:0
'nutFpelargoniq' => 'NUT_FPELARGONIQ', // C9:0
'nutFcapriq' => 'NUT_FCAPRIQ', // C10:0
// * AGS - chaîne moyenne à longue (C11-C38)
'nutFundecyliq' => 'NUT_FUNDECYLIQ', // C11:0
'nutFlauriq' => 'NUT_FLAURIQ', // C12:0
'nutFtridecyliq' => 'NUT_FTRIDECYLIQ', // C13:0
'nutFmyristiq' => 'NUT_FMYRISTIQ', // C14:0
'nutFpentadecyliq' => 'NUT_FPENTADECYLIQ', // C15:0
'nutF16d0' => 'NUT_F16D0', // C16:0 palmitique
'nutFmargariq' => 'NUT_FMARGARIQ', // C17:0
'nutFsteariq' => 'NUT_FSTEARIQ', // C18:0
'nutFarachidiq' => 'NUT_FARACHIDIQ', // C20:0
'nutFheneicosyliq' => 'NUT_FHENEICOSYLIQ', // C21:0
'nutFbeheniq' => 'NUT_FBEHENIQ', // C22:0
'nutFtricosiliq' => 'NUT_FTRICOSILIQ', // C23:0
'nutFlignoceriq' => 'NUT_FLIGNOCERIQ', // C24:0
'nutFcarboceriq' => 'NUT_FCARBOCERIQ', // C26:0
'nutFcerotiq' => 'NUT_FCEROTIQ', // C26:0 alt.
'nutFceroplastiq' => 'NUT_FCEROPLASTIQ',
'nutFmontaniq' => 'NUT_FMONTANIQ', // C28:0
'nutFnonacosyliq' => 'NUT_FNONACOSYLIQ', // C29:0
'nutFmelissiq' => 'NUT_FMELISSIQ', // C30:0
'nutFlaceroiq' => 'NUT_FLACEROIQ', // C32:0
'nutFhentriacontyliq' => 'NUT_FHENTRIACONTYLIQ', // C31:0
'nutFhextriacontyliq' => 'NUT_FHEXTRIACONTYLIQ', // C36:0
'nutFheptatriacontyliq' => 'NUT_FHEPTATRIACONTYLIQ', // C37:0
'nutFoctatriacontyliq' => 'NUT_FOCTATRIACONTYLIQ', // C38:0
'nutFnonadylique' => 'NUT_FNONADYLIQUE',
'nutFpsylliq' => 'NUT_FPSYLLIQ',
// * AGMI
'nutFpalm' => 'NUT_FPALM', // C16:1
'nutFoleiq' => 'NUT_FOLEIQ', // C18:1 ω-9
'nutF18d' => 'NUT_F18D', // C18 insaturé générique
'nutF19d' => 'NUT_F19D', // C19 insaturé
'nutF11eico' => 'NUT_F11EICO', // C20:1
'nutFgeddiq' => 'NUT_FGEDDIQ', // C20:1 alt.
'nutFeruciq' => 'NUT_FERUCIQ', // C22:1
'nutFnervoniq' => 'NUT_FNERVONIQ', // C24:1
'nutFhyeniq' => 'NUT_FHYENIQ',
'nutFamscis' => 'NUT_FAMSCIS', // AGMI cis total
// * AGPI
'nutF18d2cn6' => 'NUT_F18D2CN6', // C18:2 ω-6 linoléique
'nutF18d3n3' => 'NUT_F18D3N3', // C18:3 ω-3 α-linolénique
'nutF20d4' => 'NUT_F20D4', // C20:4 arachidonique
'nutF20d5n3' => 'NUT_F20D5N3', // C20:5 ω-3 EPA
'nutF22d6n3' => 'NUT_F22D6N3', // C22:6 ω-3 DHA
'nutFapun3' => 'NUT_FAPUN3', // AGPI ω-3 total
'nutFapun6' => 'NUT_FAPUN6', // AGPI ω-6 total
'nutFapun9' => 'NUT_FAPUN9', // AGPI ω-9 total
'nutFapucis' => 'NUT_FAPUCIS', // AGPI cis total
'nutFapulc' => 'NUT_FAPULC', // AGPI longue chaîne total
// =====================================================================
// ACIDES AMINÉS & PROTÉINES SPÉCIFIQUES
// =====================================================================
'nutAae' => 'NUT_AAE', // acides aminés essentiels total
'nutCasn' => 'NUT_CASN', // caséines
'nutWhey' => 'NUT_WHEY', // protéines lactosérum
// =====================================================================
// COMPOSÉS BIOACTIFS & SPÉCIAUX
// =====================================================================
'nutCholn' => 'NUT_CHOLN',
'nutCarnitine' => 'NUT_CARNITINE',
'nutTau' => 'NUT_TAU',
'nutNucleotide' => 'NUT_NUCLEOTIDE',
];
public function getTable(): string
{
return $this->table;
}
public function getTableKey(): string
{
return $this->definition[$this->tableKey];
}
public function getPropertiesMap(): array
{
return $this->definition;
}
public function getValueFor(string | SortColumn $column): string
{
if (is_string($column)) {
return array_key_exists($column, $this->definition) ?
$this->definition[$column] :
throw new \InvalidArgumentException("No matching column for {$column}");
}
throw new \InvalidArgumentException('Sort column is not supported for IncoCompo');
}
public function toDto(array $data): IncoCompo
{
$mapped = $this->mapColumns($data);
$id = IncoCompoApiParser::getArrayKeyValue($mapped, 'id');
$id = new IncoCompoId(is_scalar($id) ? (string) $id : '');
$incoId = IncoCompoApiParser::getArrayKeyValue($mapped, 'idInco');
if (! is_array($incoId)) {
throw new UnexpectedErrorException(
sprintf(
'Expected inco id to be an array : %s',
json_encode($incoId)
)
);
}
$codeArray = IncoCompoApiParser::getArrayKeyValue($incoId, 'code');
$incoId = new IncoId(is_scalar($codeArray) ? (string) $codeArray : '');
return new IncoCompo(
id: $id,
idInco: $incoId,
identification: IncoCompoApiParser::toIdentification($mapped),
energie: IncoCompoApiParser::toEnergie($mapped),
macronutrimentsRef: IncoCompoApiParser::toMacronutrimentsRef($mapped),
macronutrimentsPortion: IncoCompoApiParser::toMacronutrimentsPortion($mapped),
fnl: IncoCompoApiParser::toFnl($mapped),
apportsReference: IncoCompoApiParser::toApportsReference($mapped),
nutriScore: IncoCompoApiParser::toNutriScore($mapped),
pointsNutriScore: IncoCompoApiParser::toPointsNutriScore($mapped),
mineraux: IncoCompoApiParser::toMineraux($mapped),
vitamines: IncoCompoApiParser::toVitamines($mapped),
glucidesDetail: IncoCompoApiParser::toGlucidesDetail($mapped),
acideGras: IncoCompoApiParser::toAcideGras($mapped),
acidesAmines: IncoCompoApiParser::toAcidesAmines($mapped),
composesSpeciaux: IncoCompoApiParser::toComposesSpeciaux($mapped),
);
}
/**
* @param array<string, mixed> $data
* @return array<string, null|mixed>
*/
protected function mapColumns(array $data): array
{
/** @var array<string,null|mixed> */
$mapped = [];
foreach ($this->definition as $propertyName => $columnName) {
// FIXME: Remove null fallback once the API respects its select contract.
$mapped[$propertyName] = array_key_exists($columnName, $data)
? $data[$columnName]
: null;
if (is_string($mapped[$propertyName])) {
$mapped[$propertyName] = IncoApiStringParser::protectiveDataFilter($mapped[$propertyName]);
}
}
return $mapped;
}
}

View file

@ -1,217 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Mapper;
use Uwil\ArcadiaApiPhpClient\Exception\UnexpectedErrorException;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiParser\IncoApiParser;
use Uwil\ArcadiaApiPhpClient\Lib\ApiParser\IncoApiStringParser;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoSortColumn;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\SortColumn;
use Uwil\ArcadiaApiPhpClient\Utils\StringUtils;
final class IncoMapper implements Mapper
{
private string $table = 'INCO';
private string $tableKey = 'id';
/**
* Mapping camelCase (propriété PHP) => NOM_COLONNE (API/BDD).
*
* @var array<string, string>
*/
private array $definition = [
'id' => 'NO_ART',
'name' => 'DENOM_LEG',
'description' => 'DESCRIPTIF',
'tips' => 'ASTUCES',
'preparation_advices' => 'CONSEIL_PREPA',
'sell_arguments' => 'ARG_VENTE',
'allegations' => 'ALLEGATIONS',
'allegations_health' => 'ALLEGAT_SANTE',
'allegations_nutrition' => 'ALLEGAT_NUTRIT',
'allegations_other' => 'ALLEGAT_AUTRE',
'ingredients' => 'LST_ING',
'portions' => 'NB_PORTION',
'portion_type' => 'TYP_PORTION',
'allergens' => 'LST_ALLERGENE',
'brand' => 'MARQUE',
'nutriscore' => 'NUTRISCORE_ON_PACK',
'pdsNetUvc' => 'PDS_NET_UVC',
'pdsBrutUvc' => 'PDS_BRUT_UVC',
'pdsMoyTheo' => 'PDS_MOY_THEO',
'conditConserv' => 'CONDIT_CONSERV',
'lstAdditif' => 'LST_ADDITIF',
'familleVte' => 'FAMILLE_VTE',
// 'numero' => 'NUMERO',
// 'taille_ref' => 'TAILLE_REF',
'is_seasonal' => 'PROD_SAISON',
'compo' => 'REF_COMPO',
'created_at' => 'DAT_CRE',
'updated_at' => 'DAT_MAJ',
];
public function getTable(): string
{
return $this->table;
}
public function getTableKey(): string
{
return $this->definition[$this->tableKey];
}
public function getPropertiesMap(): array
{
return $this->definition;
}
public function getValueFor(string | SortColumn $column): string
{
if (! is_string($column)) {
if (! ($column instanceof IncoSortColumn)) {
throw new \InvalidArgumentException(
'Expected ' . IncoSortColumn::class .
' given ' . get_class($column)
);
}
return match ($column) {
IncoSortColumn::id => $this->definition['id'],
IncoSortColumn::createdAt => $this->definition['created_at'],
IncoSortColumn::updatedAt => $this->definition['updated_at']
};
}
if (array_key_exists($column, $this->definition)) {
return $this->definition[$column];
}
throw new \InvalidArgumentException("No matching column for {$column}");
}
/** @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh */
public function toDto(array $data): Inco
{
// phpcs:enable
$columnsMapper = $this->getPropertiesMap();
/** @var array<string,null|mixed> */
$mapped = [];
foreach ($columnsMapper as $modelName => $columnName) {
// FIXME: Ceci devrait être ok au lieu de cela l'API ne respecte pas le contrat du select quand un champ est null.
// if (! array_key_exists($columnName, $data)) {
// throw new UnexpectedErrorException("Column '{$columnName}' not found in data " . json_encode($data));
// }
// FIXME: Supprimer ceci quand le bug d'api aura été corrigé, et decommenter le code au dessus.
if (! array_key_exists($columnName, $data)) {
// NOTE: On ne devrais pas avoir a faire cela.
$mapped[$modelName] = null;
continue;
}
$mapped[$modelName] = $data[$columnName];
if (is_string($mapped[$modelName])) {
$mapped[$modelName] = IncoApiStringParser::protectiveDataFilter($mapped[$modelName]);
}
}
$incoId = IncoApiParser::getArrayKeyValue($mapped, 'id');
if (! is_array($incoId)) {
throw new UnexpectedErrorException(
sprintf(
'Expected inco id to be an array : %s',
json_encode($incoId)
)
);
}
$codeArray = IncoApiParser::getArrayKeyValue($incoId, 'code');
if (! is_string($codeArray)) {
throw new UnexpectedErrorException(
sprintf(
'Expected inco id code to be a string : %s',
json_encode($codeArray)
)
);
}
$libelle = IncoApiParser::getArrayKeyValue($incoId, 'libelle');
if (! is_string($libelle)) {
throw new UnexpectedErrorException(
sprintf(
'Expected inco id libelle to be a string : %s',
json_encode($libelle)
)
);
}
$incoId = new IncoId($codeArray);
$brand = IncoApiParser::toBrand($mapped);
$allegations = IncoApiParser::toAllegations($mapped);
$allergens = IncoApiParser::toAllergens($mapped);
$portionAmount = IncoApiParser::toPortionAmount($mapped);
$compoId = IncoApiParser::toIncoCompoId($mapped);
$ingredients = IncoApiParser::parseIngredients($mapped);
$isSeasonal = IncoApiParser::toBoolean($mapped, 'is_seasonal', false);
$weight = IncoApiParser::toIncoWeight($mapped);
$nutriscore = IncoApiParser::toNutriscore($mapped);
$storageCondition = IncoApiParser::toStorageCondition($mapped);
$additives = IncoApiParser::toAdditives($mapped);
$sellFamilly = IncoApiParser::toSellFamilly($mapped);
$libelle = IncoApiStringParser::beautifyName($libelle);
$tips = IncoApiStringParser::beautifyText($mapped['tips']);
$description = IncoApiStringParser::beautifyText($mapped['description']) ?? '';
$sellArguments = IncoApiStringParser::beautifyText($mapped['sell_arguments']) ?? '';
$preparationAdvices = IncoApiStringParser::beautifyText($mapped['preparation_advices']) ?? '';
$createdAt = IncoApiParser::toDateTimeFrom($mapped, 'created_at');
$updatedAt = IncoApiParser::toDateTimeFrom($mapped, 'updated_at');
$name = IncoApiParser::getArrayKeyValue($mapped, 'name');
$nameStr = is_string($name) ? $name : null;
if (empty($nameStr) || mb_strlen($nameStr) <= 1) {
$nameStr = (! empty($description) && mb_strlen($description) > 0) ?
StringUtils::words($description, 4, '') : "Produit Inco {$incoId->value}";
}
$name = IncoApiStringParser::beautifyText($nameStr) ?? '';
return new Inco(
$incoId,
$name,
$libelle,
$description,
$allegations,
$sellArguments,
$preparationAdvices,
$tips,
$sellFamilly,
$ingredients,
$portionAmount,
$allergens,
$brand,
$isSeasonal,
$weight,
$nutriscore,
$storageCondition,
$additives,
$compoId,
$createdAt,
$updatedAt
);
}
}

View file

@ -1,163 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Mapper;
use Uwil\ArcadiaApiPhpClient\Exception\UnexpectedErrorException;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoShort;
use Uwil\ArcadiaApiPhpClient\Lib\ApiParser\IncoApiParser;
use Uwil\ArcadiaApiPhpClient\Lib\ApiParser\IncoApiStringParser;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoSortColumn;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\SortColumn;
use Uwil\ArcadiaApiPhpClient\Utils\StringUtils;
final class IncoShortMapper implements Mapper
{
private string $table = 'INCO';
private string $tableKey = 'id';
/**
* Mapping camelCase (propriété PHP) => NOM_COLONNE (API/BDD).
*
* @var array<string, string>
*/
private array $definition = [
'id' => 'NO_ART',
'name' => 'DENOM_LEG',
'description' => 'DESCRIPTIF',
'created_at' => 'DAT_CRE',
'updated_at' => 'DAT_MAJ',
];
public function getTable(): string
{
return $this->table;
}
public function getTableKey(): string
{
return $this->definition[$this->tableKey];
}
public function getPropertiesMap(): array
{
return $this->definition;
}
public function getValueFor(string | SortColumn $column): string
{
if (! is_string($column)) {
if (! ($column instanceof IncoSortColumn)) {
throw new \InvalidArgumentException(
'Expected ' . IncoSortColumn::class .
' given ' . get_class($column)
);
}
return match ($column) {
IncoSortColumn::id => $this->definition['id'],
IncoSortColumn::createdAt => $this->definition['created_at'],
IncoSortColumn::updatedAt => $this->definition['updated_at']
};
}
if (array_key_exists($column, $this->definition)) {
return $this->definition[$column];
}
throw new \InvalidArgumentException("No matching column for {$column}");
}
/** @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh */
public function toDto(array $data): IncoShort
{
// phpcs:enable
$columnsMapper = $this->getPropertiesMap();
/** @var array<string,null|mixed> */
$mapped = [];
foreach ($columnsMapper as $modelName => $columnName) {
// FIXME: Ceci devrait être ok au lieu de cela l'API ne respecte pas le contrat du select quand un champ est null.
// if (! array_key_exists($columnName, $data)) {
// throw new UnexpectedErrorException("Column '{$columnName}' not found in data " . json_encode($data));
// }
// FIXME: Supprimer ceci quand le bug d'api aura été corrigé, et decommenter le code au dessus.
if (! array_key_exists($columnName, $data)) {
// NOTE: On ne devrais pas avoir a faire cela.
$mapped[$modelName] = null;
continue;
}
$mapped[$modelName] = $data[$columnName];
if (is_string($mapped[$modelName])) {
$mapped[$modelName] = IncoApiStringParser::protectiveDataFilter($mapped[$modelName]);
}
}
$incoId = IncoApiParser::getArrayKeyValue($mapped, 'id');
if (! is_array($incoId)) {
throw new UnexpectedErrorException(
sprintf(
'Expected inco id to be an array : %s',
json_encode($incoId)
)
);
}
$codeArray = IncoApiParser::getArrayKeyValue($incoId, 'code');
if (! is_string($codeArray)) {
throw new UnexpectedErrorException(
sprintf(
'Expected inco id code to be a string : %s',
json_encode($codeArray)
)
);
}
$libelle = IncoApiParser::getArrayKeyValue($incoId, 'libelle');
if (! is_string($libelle)) {
throw new UnexpectedErrorException(
sprintf(
'Expected inco id libelle to be a string : %s',
json_encode($libelle)
)
);
}
$incoId = new IncoId($codeArray);
$libelle = IncoApiStringParser::beautifyName($libelle);
$description = IncoApiStringParser::beautifyText($mapped['description']) ?? '';
$createdAt = IncoApiParser::toDateTimeFrom($mapped, 'created_at');
$updatedAt = IncoApiParser::toDateTimeFrom($mapped, 'updated_at');
$name = IncoApiParser::getArrayKeyValue($mapped, 'name');
$nameStr = is_string($name) ? $name : null;
if (empty($nameStr) || mb_strlen($nameStr) <= 1) {
$nameStr = (! empty($description) && mb_strlen($description) > 0) ?
StringUtils::words($description, 4, '') : "Produit Inco {$incoId->value}";
}
$name = IncoApiStringParser::beautifyText($nameStr) ?? '';
return new IncoShort(
$incoId,
$name,
$libelle,
$description,
$createdAt,
$updatedAt
);
}
}

View file

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Mapper;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\SortColumn;
interface Mapper
{
public function getTable(): string;
public function getTableKey(): string;
/** @return array<string,string> */
public function getPropertiesMap(): array;
public function getValueFor(string | SortColumn $column): string;
/**
* @param array<string,null|mixed> $data
*
* @throws \Uwil\ArcadiaApiPhpClient\Exception\UnexpectedErrorException If mapping fails.
*/
public function toDto(array $data): Dto;
}

View file

@ -1,138 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Repository;
use Uwil\ArcadiaApiPhpClient\Lib\ApiCaller;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PageOf;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Factory\DtoFactory;
use Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder\TableQueryBuilder;
use Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder\TableQueryWhereStep;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\AbstractSorter;
use Uwil\ArcadiaApiPhpClient\Lib\Pagination;
use Uwil\ArcadiaApiPhpClient\Mapper\Mapper;
use Uwil\ArcadiaApiPhpClient\Utils\DateUtils;
/**
* @template D of Dto
*/
abstract class AbstractGenesisRepository
{
private string $queryPath = '/get';
/**
* @param DtoFactory<Mapper, D> $dtoFactory
*/
public function __construct(
private readonly ApiCaller $apiCaller,
protected readonly DtoFactory $dtoFactory,
protected readonly Mapper $mapper
) {
}
/**
* @param (callable(TableQueryWhereStep):TableQueryWhereStep) $filters
* @return PageOf<D>
*/
protected function paginatedQuery(
Pagination $pagination,
?AbstractSorter $sorter = null,
callable ...$filters
): PageOf {
$query = TableQueryBuilder::query()
->from($this->mapper->getTable())
->select(...$this->mapper->getPropertiesMap());
foreach ($filters as $filter) {
$query = $filter($query);
}
if ($sorter !== null) {
$query = $query->orderBy(
$sorter->sortBy,
$sorter->direction
);
}
$queryData = $query
->paginate($pagination)
->build();
$apiResponse = $this->apiCaller->callApi(
$this->queryPath,
$queryData
);
// NOTE: Forcement non null ici
// @phpstan-ignore return.type
return $this->dtoFactory->build($apiResponse, $pagination);
}
/**
* @return D|null
*/
protected function findByColumn(string $column, string $value): ?Dto
{
$queryData = TableQueryBuilder::query()
->from($this->mapper->getTable())
->select(...$this->mapper->getPropertiesMap())
->whereEqual($column, $value)
->build();
$apiResponse = $this->apiCaller->callApi(
$this->queryPath,
$queryData
);
return $this->dtoFactory->build($apiResponse);
}
/**
* @return callable(TableQueryWhereStep):TableQueryWhereStep
*/
protected function sinceFilter(string $column, \DateTimeInterface $since): callable
{
return fn (TableQueryWhereStep $q): TableQueryWhereStep => $q->whereGreaterThanOrEqual(
$column,
DateUtils::toRfc3339($since)
);
}
/**
* @param array<object{value:string|int}> $values
* @return callable(TableQueryWhereStep):TableQueryWhereStep
*/
protected function inFilter(string $column, array $values): callable
{
/** @var array<string> $stringValues */
$stringValues = array_map(fn (object $v): string => (string) $v->value, $values);
return fn (TableQueryWhereStep $q): TableQueryWhereStep => $q->whereIn($column, ...$stringValues);
}
/**
* @return callable(TableQueryWhereStep):TableQueryWhereStep
*/
protected function whereLikeFilter(string $column, string $stringValue): callable
{
return fn (TableQueryWhereStep $q): TableQueryWhereStep => $q->whereLike($column, $stringValue);
}
/**
* @param callable(TableQueryWhereStep):TableQueryWhereStep $filters
* @return callable(TableQueryWhereStep):TableQueryWhereStep
*/
protected function composeFilters(callable ...$filters): callable
{
return function (TableQueryWhereStep $query) use ($filters): TableQueryWhereStep {
foreach ($filters as $filter) {
$query = $filter($query);
}
return $query;
};
}
}

View file

@ -1,164 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Repository;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Employee;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PageOf;
use Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder\Direction;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\AbstractSorter;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\ArcadiaEmployeeId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\EmployeeSortColum;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\EmployeeSorter;
use Uwil\ArcadiaApiPhpClient\Lib\Pagination;
/**
* @extends AbstractGenesisRepository<Employee>
*/
final class EmployeeGenesisRepository extends AbstractGenesisRepository implements EmployeeRepository
{
/** @return PageOf<Employee> */
public function selectAll(
Pagination $pagination,
?EmployeeSorter $sorter = null
): PageOf {
if ($sorter !== null) {
return $this->paginatedQuery(
$pagination,
$this->mapSorter($sorter)
);
}
return $this->paginatedQuery(
$pagination,
);
}
/** @return PageOf<Employee> */
public function selectAllSince(
Pagination $pagination,
\DateTimeInterface $since,
Direction $sortDirection = Direction::DESC
): PageOf {
$sortBy = EmployeeSortColum::updatedAt;
$sortByColumn = $this->mapper->getValueFor($sortBy);
return $this->paginatedQuery(
$pagination,
$this->toSorter($sortBy),
$this->sinceFilter($sortByColumn, $since),
);
}
/** @return PageOf<Employee> */
public function selectAllHavingSitesSince(
Pagination $pagination,
\DateTimeInterface $since,
array $sites,
Direction $sortDirection = Direction::DESC
): PageOf {
$sortBy = EmployeeSortColum::updatedAt;
$sortByColumn = $this->mapper->getValueFor($sortBy);
$filterByColumn = $this->mapper->getValueFor('site');
return $this->paginatedQuery(
$pagination,
$this->toSorter($sortBy),
$this->composeFilters(
$this->sinceFilter($sortByColumn, $since),
$this->inFilter($filterByColumn, $sites),
),
);
}
/** @return PageOf<Employee> */
public function selectAllHavingSites(
Pagination $pagination,
array $sites,
?EmployeeSorter $sorter = null
): PageOf {
$filterColumn = $this->mapper->getValueFor('site');
if ($sorter !== null) {
return $this->paginatedQuery(
$pagination,
$this->toSorter($sorter->sortBy),
$this->inFilter($filterColumn, $sites),
);
}
return $this->paginatedQuery(
$pagination,
null,
$this->inFilter($filterColumn, $sites),
);
}
/**
* @param array<\Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\ServiceId> $services
* @return PageOf<Employee>
*/
public function selectAllHavingServicesSince(
Pagination $pagination,
\DateTimeInterface $since,
array $services,
Direction $sortDirection = Direction::DESC
): PageOf {
$sortBy = EmployeeSortColum::updatedAt;
$sortByColumn = $this->mapper->getValueFor($sortBy);
$filterColumn = $this->mapper->getValueFor('service');
return $this->paginatedQuery(
$pagination,
$this->toSorter($sortBy, $sortDirection),
$this->composeFilters(
$this->sinceFilter($sortByColumn, $since),
$this->inFilter($filterColumn, $services),
),
);
}
/**
* @param array<\Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\ServiceId> $services
* @return PageOf<Employee>
*/
public function selectAllHavingServices(
Pagination $pagination,
array $services,
?EmployeeSorter $sorter = null
): PageOf {
$filterColumn = $this->mapper->getValueFor('service');
return $this->paginatedQuery(
$pagination,
$sorter ? $this->toSorter($sorter->sortBy) : null,
$this->inFilter($filterColumn, $services),
);
}
public function find(ArcadiaEmployeeId $id): ?Employee
{
return $this->findByColumn(
$this->mapper->getTableKey(),
$id->value
);
}
private function toSorter(EmployeeSortColum $sortBy, ?Direction $direction = null): AbstractSorter
{
return new AbstractSorter(
$this->mapper->getValueFor($sortBy),
$direction ?? Direction::DESC
);
}
private function mapSorter(EmployeeSorter $sorter): AbstractSorter
{
return new AbstractSorter(
$this->mapper->getValueFor($sorter->sortBy),
$sorter->direction
);
}
}

View file

@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Repository;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoCompoId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoId;
/**
* @extends AbstractGenesisRepository<IncoCompo>
*/
final class IncoCompoGenesisRepository extends AbstractGenesisRepository implements IncoCompoRepository
{
public function find(IncoCompoId $id): ?IncoCompo
{
return $this->findByColumn(
$this->mapper->getTableKey(),
$id->value
);
}
public function findFor(IncoId $id): ?IncoCompo
{
return $this->findByColumn(
$this->mapper->getValueFor('idInco'),
$id->value
);
}
}

View file

@ -1,143 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Repository;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PageOf;
use Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder\Direction;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\AbstractSorter;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoSortColumn;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoSorter;
use Uwil\ArcadiaApiPhpClient\Lib\Pagination;
/**
* @extends AbstractGenesisRepository<Inco>
*/
final class IncoGenesisRepository extends AbstractGenesisRepository implements IncoRepository
{
public function selectAll(
Pagination $pagination,
?IncoSorter $sorter = null
): PageOf {
if ($sorter !== null) {
return $this->paginatedQuery(
$pagination,
$this->mapSorter($sorter)
);
}
return $this->paginatedQuery(
$pagination,
);
}
public function selectAllSince(
Pagination $pagination,
\DateTimeInterface $since,
Direction $sortDirection = Direction::DESC
): PageOf {
$sortBy = IncoSortColumn::updatedAt;
$sortByColumn = $this->mapper->getValueFor($sortBy);
return $this->paginatedQuery(
$pagination,
$this->toSorter($sortBy, $sortDirection),
$this->sinceFilter($sortByColumn, $since),
);
}
public function selectAllHavingBrandsSince(
Pagination $pagination,
\DateTimeInterface $since,
array $brands,
Direction $sortDirection = Direction::DESC,
): PageOf {
$sortBy = IncoSortColumn::updatedAt;
$sortByColumn = $this->mapper->getValueFor($sortBy);
$filterByColumn = $this->mapper->getValueFor('brand');
return $this->paginatedQuery(
$pagination,
$this->toSorter($sortBy, $sortDirection),
$this->composeFilters(
$this->sinceFilter($sortByColumn, $since),
$this->inFilter($filterByColumn, $brands),
),
);
}
public function selectAllHavingBrands(
Pagination $pagination,
array $brands,
?IncoSorter $sorter = null
): PageOf {
$filterColumn = $this->mapper->getValueFor('brand');
if ($sorter !== null) {
return $this->paginatedQuery(
$pagination,
$this->toSorter($sorter->sortBy),
$this->inFilter($filterColumn, $brands),
);
}
return $this->paginatedQuery(
$pagination,
null,
$this->inFilter($filterColumn, $brands),
);
}
public function findWhere(
string $incoIdLike,
array $inBrands,
Pagination $pagination,
?IncoSorter $sorter = null
): PageOf {
$whereLikeColumn = $this->mapper->getValueFor('id');
$filterColumn = $this->mapper->getValueFor('brand');
if ($sorter !== null) {
return $this->paginatedQuery(
$pagination,
$this->toSorter($sorter->sortBy),
$this->inFilter($filterColumn, $inBrands),
);
}
return $this->paginatedQuery(
$pagination,
null,
$this->whereLikeFilter($whereLikeColumn, $incoIdLike),
$this->inFilter($filterColumn, $inBrands),
);
}
public function find(IncoId $id): ?Inco
{
return $this->findByColumn(
$this->mapper->getTableKey(),
$id->value
);
}
private function toSorter(IncoSortColumn $sortBy, ?Direction $direction = null): AbstractSorter
{
return new AbstractSorter(
$this->mapper->getValueFor($sortBy),
$direction ?? Direction::DESC
);
}
private function mapSorter(IncoSorter $sorter): AbstractSorter
{
return new AbstractSorter(
$this->mapper->getValueFor($sorter->sortBy),
$sorter->direction
);
}
}

View file

@ -1,77 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Repository;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PageOf;
use Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder\Direction;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\AbstractSorter;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoSortColumn;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoSorter;
use Uwil\ArcadiaApiPhpClient\Lib\Pagination;
/**
* @extends AbstractGenesisRepository<\Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoShort>
*/
final class IncoShortGenesisRepository extends AbstractGenesisRepository implements IncoShortRepository
{
/**
* @param \Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\BrandCode[] $brands
* @return PageOf<\Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoShort>
*/
public function selectAllHavingBrands(
Pagination $pagination,
array $brands,
?IncoSorter $sorter = null
): PageOf {
$filterColumn = $this->mapper->getValueFor('brand');
if ($sorter !== null) {
return $this->paginatedQuery(
$pagination,
$this->toSorter($sorter->sortBy),
$this->inFilter($filterColumn, $brands),
);
}
return $this->paginatedQuery(
$pagination,
null,
$this->inFilter($filterColumn, $brands),
);
}
public function findWhere(
string $incoIdLike,
array $inBrands,
Pagination $pagination,
?IncoSorter $sorter = null
): PageOf {
$whereLikeColumn = $this->mapper->getValueFor('id');
$filterColumn = $this->mapper->getValueFor('brand');
if ($sorter !== null) {
return $this->paginatedQuery(
$pagination,
$this->toSorter($sorter->sortBy),
$this->inFilter($filterColumn, $inBrands),
);
}
return $this->paginatedQuery(
$pagination,
null,
$this->whereLikeFilter($whereLikeColumn, $incoIdLike),
$this->inFilter($filterColumn, $inBrands),
);
}
private function toSorter(IncoSortColumn $sortBy, ?Direction $direction = null): AbstractSorter
{
return new AbstractSorter(
$this->mapper->getValueFor($sortBy),
$direction ?? Direction::DESC
);
}
}

View file

@ -1,89 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Utils;
final readonly class DateUtils
{
private const FRENCH_DATE_REGEX = '/^\d{4}-\d{2}-\d{2}$/';
private const ISO8601_REGEX = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/';
private const RFC3339_REGEX = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+\-]\d{2}:\d{2})$/';
public function __construct()
{
throw new \Error('Abstract class');
}
public static function toRfc3339(\DateTimeInterface | string $datetime): string
{
$datetime = is_string($datetime) ? self::parseDateTime($datetime) : $datetime;
return $datetime->format(\DATE_RFC3339);
}
public static function parseDateTime(string $datetime): \DateTimeInterface
{
$datetime = trim($datetime);
/** @var \DateTimeInterface|null */
$output = null;
switch (true) {
case (bool) preg_match(self::FRENCH_DATE_REGEX, $datetime):
$output = self::parseFrench($datetime);
break;
case (bool) preg_match(self::RFC3339_REGEX, $datetime):
$output = self::parseRfc3339($datetime);
break;
case (bool) preg_match(self::ISO8601_REGEX, $datetime):
$output = self::parseIso8601($datetime);
break;
default:
try {
$output = new \DateTimeImmutable($datetime);
} catch (\Exception $e) {
throw new \InvalidArgumentException('Unrecognized date/time format: ' . $datetime);
}
}
return $output;
}
protected static function parseFrench(string $datetime): \DateTimeInterface
{
$dt = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $datetime . ' 00:00:00');
return $dt ?: self::parseFallback($datetime);
}
protected static function parseRfc3339(string $datetime): \DateTimeInterface
{
$dt = \DateTimeImmutable::createFromFormat(\DateTime::RFC3339, $datetime);
if ($dt !== false) {
return $dt;
}
$dt = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.uP', $datetime);
return $dt ?: self::parseFallback($datetime);
}
protected static function parseIso8601(string $datetime): \DateTimeInterface
{
$dt = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $datetime);
return $dt ?: self::parseFallback($datetime);
}
protected static function parseFallback(string $datetime): \DateTimeInterface
{
try {
return new \DateTimeImmutable($datetime);
} catch (\Throwable $e) {
throw new \InvalidArgumentException("Unrecognized date/time format: {$datetime}");
}
}
}

View file

@ -1,325 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Utils;
/**
* Converts strings from Western European single-byte encodings to UTF-8.
*
* Supported source encodings:
* - UTF-8 (passthrough with validation)
* - Windows-1252 (superset of ISO-8859-1, covers Latin-1 in practice)
* - ISO-8859-15 (Latin-9: Latin-1 + €, œ, Œ, Š, š, Ž, ž, Ÿ)
*
* Auto-detection is inherently unreliable for single-byte encodings.
* Always prefer toUtf8() with an explicit encoding.
*/
final class StringEncoder
{
/** UTF-8 BOM sequence (EF BB BF). */
private const string UTF8_BOM = "\xEF\xBB\xBF";
/**
* Canonical encodings supported by this class.
*
* @var list<string>
*/
private const array CANONICAL_ENCODINGS = [
'UTF-8',
'Windows-1252',
'ISO-8859-15',
];
/**
* Lowercase alias -> canonical name.
*
* Windows-1252 absorbs ISO-8859-1/Latin-1 because it is a strict superset:
* bytes 0xA0-0xFF are identical, and 0x80-0x9F are defined in Win-1252
* but undefined (and useless) in ISO-8859-1.
*
* @var array<string, string>
*/
private const array ENCODING_ALIASES = [
// UTF-8
'utf8' => 'UTF-8',
'utf-8' => 'UTF-8',
// Windows-1252 (absorbs Latin-1 / ISO-8859-1)
'win1252' => 'Windows-1252',
'windows1252' => 'Windows-1252',
'cp1252' => 'Windows-1252',
'latin1' => 'Windows-1252',
'latin-1' => 'Windows-1252',
'iso-8859-1' => 'Windows-1252',
// ISO-8859-15 (Latin-9)
'latin9' => 'ISO-8859-15',
'latin-9' => 'ISO-8859-15',
'iso-8859-15' => 'ISO-8859-15',
];
/**
* Windows-1252 specific codepoints (0x80-0x9F) absent from ISO-8859-1.
*
* Bytes 0x81, 0x8D, 0x8F, 0x90, 0x9D are officially undefined in Windows-1252.
* They are intentionally absent - encountering them triggers the
* Latin-1 fallback (Unicode codepoint = byte value).
*
* @var array<int, string> Byte value -> UTF-8 character.
*/
private const array WIN1252_SPECIAL_BYTES = [
0x80 => "\u{20AC}", // €
0x82 => "\u{201A}", // ‚
0x83 => "\u{0192}", // ƒ
0x84 => "\u{201E}", // „
0x85 => "\u{2026}", // …
0x86 => "\u{2020}", // †
0x87 => "\u{2021}", // ‡
0x88 => "\u{02C6}", // ˆ
0x89 => "\u{2030}", // ‰
0x8A => "\u{0160}", // Š
0x8B => "\u{2039}", // ‹
0x8C => "\u{0152}", // Œ
0x8E => "\u{017D}", // Ž
0x91 => "\u{2018}", // '
0x92 => "\u{2019}", // '
0x93 => "\u{201C}", // "
0x94 => "\u{201D}", // "
0x95 => "\u{2022}", // •
0x96 => "\u{2013}", // –
0x97 => "\u{2014}", // —
0x98 => "\u{02DC}", // ˜
0x99 => "\u{2122}", // ™
0x9A => "\u{0161}", // š
0x9B => "\u{203A}", // ›
0x9C => "\u{0153}", // œ
0x9E => "\u{017E}", // ž
0x9F => "\u{0178}", // Ÿ
];
/**
* ISO-8859-15 bytes that differ from ISO-8859-1.
* Only 8 codepoints differ between Latin-1 and Latin-9.
*
* @var array<int, string> Byte value -> UTF-8 character.
*/
private const array LATIN9_OVERRIDES = [
0xA4 => "\u{20AC}", // € (replaces ¤)
0xA6 => "\u{0160}", // Š (replaces ¦)
0xA8 => "\u{0161}", // š (replaces ¨)
0xB4 => "\u{017D}", // Ž (replaces ´)
0xB8 => "\u{017E}", // ž (replaces ¸)
0xBC => "\u{0152}", // Œ (replaces ¼)
0xBD => "\u{0153}", // œ (replaces ½)
0xBE => "\u{0178}", // Ÿ (replaces ¾)
];
/**
* Converts a string from a known encoding to UTF-8.
*
* @param bool $stripBom Remove UTF-8 BOM from the result if present.
*
* @throws \ValueError If encoding is unsupported or the string is invalid.
*/
public static function toUtf8(
string $string,
string $sourceEncoding,
bool $stripBom = true,
): string {
if ($string === '') {
return '';
}
$canonical = self::resolveEncoding($sourceEncoding);
$result = match ($canonical) {
'UTF-8' => self::ensureValidUtf8($string),
'Windows-1252' => self::fromSingleByte($string, self::WIN1252_SPECIAL_BYTES),
'ISO-8859-15' => self::fromSingleByte($string, self::LATIN9_OVERRIDES),
default => throw new \ValueError(sprintf(
'Unsupported encoding "%s". Supported: %s',
$canonical,
implode(', ', self::supportedEncodings()),
))
};
return $stripBom ? self::stripUtf8Bom($result) : $result;
}
/**
* Attempts to detect the encoding and convert to UTF-8.
*
* Detection strategy:
* 1. If the string is valid UTF-8, it is treated as UTF-8.
* 2. Otherwise, the configured fallback encoding is used.
*
* This two-step approach is more reliable than mb_detect_encoding
* for single-byte encodings (which accept any byte sequence).
*
* @param string $fallbackEncoding Encoding to assume when the string is not valid UTF-8.
* Defaults to 'Windows-1252' (covers most Western European legacy data).
* @param bool $stripBom Remove UTF-8 BOM from the result if present.
*
* @throws \ValueError If fallbackEncoding is unsupported or is 'UTF-8'.
*/
public static function detectAndConvertToUtf8(
string $string,
string $fallbackEncoding = 'Windows-1252',
bool $stripBom = true,
): string {
if ($string === '') {
return '';
}
$canonical = self::resolveEncoding($fallbackEncoding);
if ($canonical === 'UTF-8') {
throw new \ValueError(
'Fallback encoding cannot be UTF-8. Use toUtf8() for explicit UTF-8 validation.',
);
}
// UTF-8 is the only encoding we can reliably detect:
// it has strict byte sequence rules that reject invalid sequences.
if (mb_check_encoding($string, 'UTF-8')) {
$result = $string;
} else {
$result = self::toUtf8($string, $canonical, stripBom: false);
}
return $stripBom ? self::stripUtf8Bom($result) : $result;
}
/**
* Converts an array of strings to UTF-8 from a known encoding.
*
* @param list<string> $strings
* @return list<string>
*/
public static function arrayToUtf8(
array $strings,
string $sourceEncoding,
bool $stripBom = true,
): array {
return array_map(
static fn (string $s): string => self::toUtf8($s, $sourceEncoding, $stripBom),
$strings,
);
}
/**
* Returns all supported encoding names (canonical + aliases).
*
* @return list<string>
*/
public static function supportedEncodings(): array
{
return array_values(array_unique([
...self::CANONICAL_ENCODINGS,
...array_keys(self::ENCODING_ALIASES),
]));
}
/**
* Resolves a user-supplied encoding name to its canonical form.
*
* @throws \ValueError If encoding is unknown.
*/
private static function resolveEncoding(string $encoding): string
{
$normalized = strtolower(trim($encoding));
if (isset(self::ENCODING_ALIASES[$normalized])) {
return self::ENCODING_ALIASES[$normalized];
}
$match = array_find(
self::CANONICAL_ENCODINGS,
static fn (string $canonical): bool => strtolower($canonical) === $normalized,
);
if ($match !== null) {
return $match;
}
throw new \ValueError(sprintf(
'Unsupported encoding "%s". Supported: %s',
$encoding,
implode(', ', self::supportedEncodings()),
));
}
/**
* Validates that a string is well-formed UTF-8.
*
* @throws \ValueError If the string contains invalid UTF-8 sequences.
*/
private static function ensureValidUtf8(string $string): string
{
if (! mb_check_encoding($string, 'UTF-8')) {
throw new \ValueError('String is not valid UTF-8.');
}
return $string;
}
/**
* Converts a single-byte encoded string to UTF-8.
*
* All supported single-byte encodings share the same base:
* - 0x00-0x7F: ASCII (identity)
* - 0x80-0xFF: Encoding-specific - use overrides map first, then Latin-1 identity.
*
* @param array<int, string> $overrides Byte -> UTF-8 mapping for encoding-specific codepoints.
* For Windows-1252: the 0x80-0x9F special range.
* For ISO-8859-15: the 8 bytes that differ from Latin-1.
*/
private static function fromSingleByte(string $string, array $overrides): string
{
/** @var array<int, int> $bytes keyed from 1 (unpack behavior) */
$bytes = unpack('C*', $string);
$parts = [];
foreach ($bytes as $byte) {
$parts[] = match (true) {
$byte <= 0x7F => chr($byte),
isset($overrides[$byte]) => $overrides[$byte],
default => self::encodeLatin1ByteAsUtf8($byte),
};
}
return implode('', $parts);
}
/**
* Encodes a single byte (0x80-0xFF) as a two-byte UTF-8 sequence.
*
* For Latin-1 / ISO-8859-1, the Unicode codepoint equals the byte value,
* so the UTF-8 encoding is: 110xxxxx 10xxxxxx.
*
* @throws \ValueError If the byte is outside the expected range.
*/
private static function encodeLatin1ByteAsUtf8(int $byte): string
{
if ($byte < 0x80 || $byte > 0xFF) {
throw new \ValueError(
sprintf('Byte must be in range 0x80-0xFF, got 0x%02X.', $byte),
);
}
return chr(0xC0 | ($byte >> 6)) . chr(0x80 | ($byte & 0x3F));
}
/**
* Strips the UTF-8 BOM (EF BB BF) if present at the start of the string.
*/
private static function stripUtf8Bom(string $string): string
{
if (str_starts_with($string, self::UTF8_BOM)) {
return substr($string, 3);
}
return $string;
}
}

View file

@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Utils;
final readonly class StringUtils
{
public function __construct()
{
throw new \Error('Abstract class');
}
public static function mbStrtok(string $delimiters, ?string $str = null): string | false
{
// * Keep track of the position on the string for each subsequent call.
static $pos = 0;
static $string = '';
// * If a new string is passed, reset the static parameters.
if ($str !== null) {
$pos = 0;
$string = $str;
}
// * Initialize the token.
$token = '';
while ($pos < mb_strlen($string)) {
$char = mb_substr($string, $pos, 1);
$pos++;
if (mb_strpos($delimiters, $char) === false) {
$token .= $char;
} else {
// * Don't return empty strings.
if ($token !== '') {
return $token;
}
}
}
// * Check whether there is a last token to return.
if ($token !== '') {
return $token;
}
return false;
}
/**
* ASCII-transliterate and lowercase a string for slug-style comparisons.
*/
public static function slugify(string $value): string
{
$ascii = (string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
return strtolower(trim($ascii));
}
/**
* Split a string into words, respecting Unicode word boundaries.
*
* @param string $value The input string.
* @param int $words Maximum number of words to keep (default: 100).
* @param string $end String to append when truncated (default: ' ...').
*/
public static function words(string $value, int $words = 100, string $end = ' ...'): string
{
preg_match('/^\s*+(?:\S+\s*+){1,' . $words . '}/u', $value, $matches);
if (! isset($matches[0]) || mb_strlen($value) === mb_strlen($matches[0])) {
return $value;
}
return rtrim($matches[0]) . $end;
}
}

View file

@ -1,222 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Utils;
use Psr\Http\Message\UriInterface;
use Uwil\ArcadiaApiPhpClient\Lib\HttpMethod;
final readonly class Utils
{
public function __construct()
{
throw new \Error('Abstract class');
}
public static function appendPathTo(UriInterface $uri, string $path): UriInterface
{
if ($path === '') {
return $uri;
}
$basePath = $uri->getPath();
if ($basePath === '' || $basePath === '/') {
$newPath = $path;
} else {
$baseEndsWithSlash = substr($basePath, -1) === '/';
$pathStartsWithSlash = substr($path, 0, 1) === '/';
if ($baseEndsWithSlash && $pathStartsWithSlash) {
$newPath = $basePath . ltrim($path, '/');
} elseif (! $baseEndsWithSlash && ! $pathStartsWithSlash) {
$newPath = $basePath . '/' . $path;
} else {
$newPath = $basePath . $path;
}
}
$newPath = preg_replace('#/+#', '/', $newPath) ?? $newPath;
return $uri->withPath($newPath);
}
public static function isHttpScheme(UriInterface $uri): bool
{
return in_array(strtolower($uri->getScheme()), ['http', 'https']);
}
public static function calculateSignature(
HttpMethod $httpMethod,
#[\SensitiveParameter]
UriInterface $uri,
#[\SensitiveParameter]
string $body,
#[\SensitiveParameter]
string $secret,
\DateTimeInterface $timestamp
): string {
$method = \strtoupper($httpMethod->value);
$query = self::parseQueryString($uri->getQuery());
$parametersMap = self::flattenAndSortParameters($query);
$normalizedBody = ! empty($body) ? self::normalizeBody($body) : '';
$contentSha256 = $normalizedBody !== ''
? base64_encode(hash('sha256', $normalizedBody, true))
: '';
$stringToSign = implode("\n", [
$method,
strtolower($uri->getHost()),
$uri->getPath(),
http_build_query($parametersMap, '', '&', \PHP_QUERY_RFC3986),
self::toRfc3339($timestamp),
$contentSha256,
]);
return base64_encode(hash_hmac(
'sha256',
$stringToSign,
$secret,
true
));
}
public static function getNow(): \DateTimeInterface
{
return new \DateTimeImmutable('now', new \DateTimeZone('Europe/Paris'));
}
public static function toRfc3339(\DateTimeInterface $date): string
{
return $date->format(\DATE_RFC3339);
}
public static function deepCopy(mixed $value): mixed
{
$seen = [];
return self::recursiveDeepCopy($value, $seen);
}
/**
* @param array<int, mixed> $seen
*/
protected static function recursiveDeepCopy(mixed $value, array &$seen): mixed // NOSONAR
{
if (is_array($value)) {
$out = [];
foreach ($value as $k => $v) {
$out[$k] = self::recursiveDeepCopy($v, $seen);
}
return $out;
}
if (is_object($value)) {
if ($value instanceof \Closure) {
return $value; // keep closure
}
$id = spl_object_id($value);
if (isset($seen[$id])) {
return $seen[$id];
}
// try clone; if not clonable, keep original
$clone = clone $value;
$seen[$id] = $clone;
$ref = new \ReflectionObject($value);
do {
foreach ($ref->getProperties() as $prop) {
$prop->setAccessible(true); // NOSONAR
$prop->setValue( // NOSONAR
$clone,
self::recursiveDeepCopy(
$prop->getValue($value), // NOSONAR
$seen
)
);
}
$ref = $ref->getParentClass();
} while ($ref !== false);
return $clone;
}
// resources and scalars: return as-is
return $value;
}
/**
* @param array<string, array<mixed>|string> $parametersMap
* @return array<string,string>
*/
protected static function flattenAndSortParameters(
#[\SensitiveParameter]
array $parametersMap,
): array {
// NOTE: Ceci n'est surement pas pris en compte coté API (profondeur dans les parametres query), néanmois c'est géré pour l'instant
$flattenRequestArray = function (array $array) use (&$flattenRequestArray) {
$out = [];
foreach ($array as $key => $value) {
if (is_array($value)) {
$subArray = $flattenRequestArray($value);
foreach ($subArray as $subKey => $subValue) {
$out["$key.$subKey"] = $subValue;
}
continue;
}
$out[$key] = $value;
}
return $out;
};
$parametersMap = $flattenRequestArray($parametersMap);
// ! Natural sort by keys.
ksort($parametersMap, \SORT_STRING);
return $parametersMap;
}
/**
* @return array<array-key,array<mixed>|string>
*/
protected static function parseQueryString(string $query): array
{
$output = [];
parse_str($query, $output);
return $output;
}
/** Removes UTF-8 BOM et last CRLF */
protected static function normalizeBody(string $bodyContent): string
{
if (str_starts_with($bodyContent, "\xEF\xBB\xBF")) {
$bodyContent = substr($bodyContent, 3);
}
if ($bodyContent !== '' && str_ends_with($bodyContent, "\r\n")) {
return substr($bodyContent, 0, -2);
}
if (
$bodyContent !== '' && (
str_ends_with($bodyContent, "\n") || str_ends_with($bodyContent, "\r"))
) {
return substr($bodyContent, 0, -1);
}
return $bodyContent;
}
}

View file

@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
use Uwil\ArcadiaApiPhpClient\Lib\ApiQueryBuilder\TableQueryBuilder;
use Uwil\ArcadiaApiPhpClient\Lib\Pagination;
describe('Api Query Builder', function (): void {
it('cannot be directly instanciated', function (): void {
new TableQueryBuilder();
})->throws(\Error::class);
it('can select * from table', function (): void {
// GIVEN
$table = 'someTable';
$qBuilder = TableQueryBuilder::query();
// WHEN
$queryString = $qBuilder
->from($table)
->selectAll()
->toQueryString();
$queryString2 = $qBuilder
->from($table)
->toQueryString();
// THEN
expect($queryString)->toBe("table={$table}");
expect($queryString2)->toBe("table={$table}");
});
it('can select columns', function (): void {
// GIVEN
$table = 'someTable';
$columns = ['col1', 'col2'];
$qBuilder = TableQueryBuilder::query();
// WHEN
$queryString = $qBuilder
->from($table)
->select(...$columns)
->toQueryString();
// THEN
expect($queryString)->toBe("table={$table}&s_cols={$columns[0]}|{$columns[1]}");
});
it('can paginate', function (): void {
// GIVEN
$table = 'someTable';
$qBuilder = TableQueryBuilder::query();
$pageNumber = 2;
$pageSize = 3;
$pagination = Pagination::ofPage($pageNumber, $pageSize);
// WHEN
$queryString = $qBuilder
->from($table)
->selectAll()
->paginate($pagination)
->toQueryString();
// THEN
expect($queryString)->toBe("table={$table}&page={$pageNumber}&page_size={$pageSize}");
});
});

View file

@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
use Psr\Http\Client\ClientInterface;
use Tests\TestCase;
use Uwil\ArcadiaApiPhpClient\Lib\ApiCaller;
use Uwil\ArcadiaApiPhpClient\Lib\ApiConfig;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Factory\DtoFactory;
use Uwil\ArcadiaApiPhpClient\Lib\Psr7\RequestFactory;
use Uwil\ArcadiaApiPhpClient\Mapper\EmployeeMapper;
use Uwil\ArcadiaApiPhpClient\Repository\EmployeeGenesisRepository;
uses(TestCase::class);
beforeEach(function () {
/** @disregard P1056 */
$this->client = \Mockery::mock(ClientInterface::class);
/** @disregard P1056 */
$this->requestFactory = \Mockery::mock(RequestFactory::class);
/** @disregard P1056 */
$this->apiConfig = \Mockery::mock(ApiConfig::class);
});
afterEach(function () {
\Mockery::close();
});
describe('Employee Genesis Repository', function (): void {
it('can instanciate', function (): void {
// GIVEN
$apiCaller = \Mockery::mock(ApiCaller::class);
$dtoFactory = \Mockery::mock(DtoFactory::class);
$model = new EmployeeMapper();
// WHEN / THEN
new EmployeeGenesisRepository(
$apiCaller,
$dtoFactory,
$model
);
})->throwsNoExceptions();
});

View file

@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
use Psr\Http\Client\ClientInterface;
use Tests\TestCase;
use Uwil\ArcadiaApiPhpClient\Lib\ApiCaller;
use Uwil\ArcadiaApiPhpClient\Lib\ApiConfig;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Factory\DtoFactory;
use Uwil\ArcadiaApiPhpClient\Lib\Psr7\RequestFactory;
use Uwil\ArcadiaApiPhpClient\Mapper\IncoCompoMapper;
use Uwil\ArcadiaApiPhpClient\Repository\IncoCompoGenesisRepository;
uses(TestCase::class);
beforeEach(function () {
/** @disregard P1056 */
$this->client = \Mockery::mock(ClientInterface::class);
/** @disregard P1056 */
$this->requestFactory = \Mockery::mock(RequestFactory::class);
/** @disregard P1056 */
$this->apiConfig = \Mockery::mock(ApiConfig::class);
});
afterEach(function () {
\Mockery::close();
});
describe('Inco Compo Genesis Repository', function (): void {
it('can instanciate', function (): void {
// GIVEN
$apiCaller = \Mockery::mock(ApiCaller::class);
$dtoFactory = \Mockery::mock(DtoFactory::class);
$model = new IncoCompoMapper();
// WHEN / THEN
new IncoCompoGenesisRepository(
$apiCaller,
$dtoFactory,
$model
);
})->throwsNoExceptions();
});

View file

@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
use Psr\Http\Client\ClientInterface;
use Tests\TestCase;
use Uwil\ArcadiaApiPhpClient\Lib\ApiCaller;
use Uwil\ArcadiaApiPhpClient\Lib\ApiConfig;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Factory\DtoFactory;
use Uwil\ArcadiaApiPhpClient\Lib\Psr7\RequestFactory;
use Uwil\ArcadiaApiPhpClient\Mapper\IncoMapper;
use Uwil\ArcadiaApiPhpClient\Repository\IncoGenesisRepository;
uses(TestCase::class);
beforeEach(function () {
/** @disregard P1056 */
$this->client = \Mockery::mock(ClientInterface::class);
/** @disregard P1056 */
$this->requestFactory = \Mockery::mock(RequestFactory::class);
/** @disregard P1056 */
$this->apiConfig = \Mockery::mock(ApiConfig::class);
});
afterEach(function () {
\Mockery::close();
});
describe('Inco Genesis Repository', function (): void {
it('can instanciate', function (): void {
// GIVEN
$apiCaller = \Mockery::mock(ApiCaller::class);
$dtoFactory = \Mockery::mock(DtoFactory::class);
$model = new IncoMapper();
// WHEN / THEN
new IncoGenesisRepository(
$apiCaller,
$dtoFactory,
$model
);
})->throwsNoExceptions();
});

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
use Uwil\ArcadiaApiPhpClient\Repository\EmployeeRepository;
use Uwil\ArcadiaApiPhpClient\Service\EmployeeService;
describe('Employee Service', function (): void {
it('can instanciate', function (): void {
// GIVEN
$employeeRepository = \Mockery::mock(EmployeeRepository::class);
// WHEN / THEN
new EmployeeService($employeeRepository);
})->throwsNoExceptions();
});

View file

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
use Uwil\ArcadiaApiPhpClient\Repository\IncoCompoRepository;
use Uwil\ArcadiaApiPhpClient\Repository\IncoRepository;
use Uwil\ArcadiaApiPhpClient\Repository\IncoShortRepository;
use Uwil\ArcadiaApiPhpClient\Service\IncoService;
describe('Inco Service', function (): void {
it('can instanciate', function (): void {
// GIVEN
$incoRepository = \Mockery::mock(IncoRepository::class);
$incoShortRepository = \Mockery::mock(IncoShortRepository::class);
$incoCompoRepository = \Mockery::mock(IncoCompoRepository::class);
// WHEN / THEN
new IncoService(
$incoRepository,
$incoShortRepository,
$incoCompoRepository,
);
})->throwsNoExceptions();
});

View file

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
use GuzzleHttp\Psr7\Uri;
use Uwil\ArcadiaApiPhpClient\Lib\HttpMethod;
use Uwil\ArcadiaApiPhpClient\Utils\Utils;
describe('Signature calculation', function (): void {
it('can calculate signature on get method', function (): void {
// Given
$host = 'sub.example.net';
$secret = 'MYSUPERSECRET';
$data = [
'c_cols' => 'NOM_FIC',
'table' => 'RU_PERSONNE_SIRH',
];
$queryString = http_build_query($data, '', '&', \PHP_QUERY_RFC3986);
$method = HttpMethod::GET;
$uri = new Uri("https://{$host}/get?{$queryString}");
$timestamp = new \DateTimeImmutable('2026-01-01 00:00:00', new \DateTimeZone('CEST'));
// When
$signature = Utils::calculateSignature(
$method,
$uri,
'',
$secret,
$timestamp
);
// Then
expect($signature)->toBe('IUwJIBCsmV4n5ahm09uaWQFwFMomjV08KtTDvVjFeTc=');
});
});

View file

@ -1,350 +0,0 @@
<?php
declare(strict_types=1);
use Uwil\ArcadiaApiPhpClient\Utils\StringEncoder;
describe('toUtf8()', function (): void {
it('returns an empty string when given an empty string', function (): void {
// Given
$input = '';
// When
$result = StringEncoder::toUtf8($input, 'UTF-8');
// Then
expect($result)->toBe('');
});
it('passes through a valid UTF-8 string with multibyte characters', function (): void {
// Given
$input = 'Héllo café résumé';
// When
$result = StringEncoder::toUtf8($input, 'UTF-8');
// Then
expect($result)->toBe('Héllo café résumé');
});
it('throws ValueError for an invalid UTF-8 string', function (): void {
// Given
$input = "Invalid \xC3\x28 sequence";
// When / Then
StringEncoder::toUtf8($input, 'UTF-8');
})->throws(\ValueError::class, 'not valid UTF-8');
it('strips the UTF-8 BOM by default', function (): void {
// Given
$input = "\xEF\xBB\xBFBonjour";
// When
$result = StringEncoder::toUtf8($input, 'UTF-8');
// Then
expect($result)->toBe('Bonjour');
});
it('preserves the UTF-8 BOM when stripBom is false', function (): void {
// Given
$input = "\xEF\xBB\xBFBonjour";
// When
$result = StringEncoder::toUtf8($input, 'UTF-8', stripBom: false);
// Then
expect($result)->toBe("\xEF\xBB\xBFBonjour");
});
it('converts Windows-1252 special characters to UTF-8', function (int $byte, string $expectedUtf8): void {
// Given
$input = chr($byte);
// When
$result = StringEncoder::toUtf8($input, 'Windows-1252');
// Then
expect($result)->toBe($expectedUtf8);
})->with([
'euro sign (0x80)' => [0x80, '€'],
'single low-9 quote (0x82)' => [0x82, '‚'],
'latin small f (0x83)' => [0x83, 'ƒ'],
'double low-9 quote (0x84)' => [0x84, '„'],
'ellipsis (0x85)' => [0x85, '…'],
'dagger (0x86)' => [0x86, '†'],
'double dagger (0x87)' => [0x87, '‡'],
'circumflex (0x88)' => [0x88, 'ˆ'],
'per mille (0x89)' => [0x89, '‰'],
'S caron (0x8A)' => [0x8A, 'Š'],
'left single guillemet (0x8B)' => [0x8B, '‹'],
'OE ligature (0x8C)' => [0x8C, 'Œ'],
'Z caron (0x8E)' => [0x8E, 'Ž'],
'left single quote (0x91)' => [0x91, "\u{2018}"],
'right single quote (0x92)' => [0x92, "\u{2019}"],
'left double quote (0x93)' => [0x93, "\u{201C}"],
'right double quote (0x94)' => [0x94, "\u{201D}"],
'bullet (0x95)' => [0x95, '•'],
'en dash (0x96)' => [0x96, '–'],
'em dash (0x97)' => [0x97, '—'],
'tilde (0x98)' => [0x98, '˜'],
'trademark (0x99)' => [0x99, '™'],
's caron (0x9A)' => [0x9A, 'š'],
'right single guillemet (0x9B)' => [0x9B, '›'],
'oe ligature (0x9C)' => [0x9C, 'œ'],
'z caron (0x9E)' => [0x9E, 'ž'],
'Y diaeresis (0x9F)' => [0x9F, 'Ÿ'],
]);
it('converts Windows-1252 high bytes (0xA0-0xFF) as ISO-8859-1 identity', function (): void {
// Given
$input = chr(0xE9);
// When
$result = StringEncoder::toUtf8($input, 'Windows-1252');
// Then the result is the UTF-8 encoded é
expect($result)->toBe('é');
});
it('converts a full Windows-1252 sentence with mixed ranges', function (): void {
// Given
$input = "B\x9Cuf \xE0 5\x80";
// When
$result = StringEncoder::toUtf8($input, 'Windows-1252');
// Then
expect($result)->toBe('Bœuf à 5€');
});
it('handles undefined Windows-1252 bytes (0x81, 0x8D, 0x8F, 0x90, 0x9D) via Latin-1 fallback', function (int $byte): void {
// Given
$input = chr($byte);
// When
$result = StringEncoder::toUtf8($input, 'Windows-1252');
// Then
expect(mb_check_encoding($result, 'UTF-8'))->toBeTrue()
->and(mb_strlen($result, 'UTF-8'))->toBe(1);
})->with([0x81, 0x8D, 0x8F, 0x90, 0x9D]);
it('converts ISO-8859-15 euro sign to UTF-8', function (): void {
// Given
$input = chr(0xA4);
// When
$result = StringEncoder::toUtf8($input, 'ISO-8859-15');
// Then
expect($result)->toBe('€');
});
it('accepts encoding aliases', function (string $alias, string $expected): void {
// Given
$input = "caf\xE9"; // é in Latin-1/Win-1252
// When
$result = StringEncoder::toUtf8($input, $alias);
// Then
expect($result)->toBe($expected);
})->with([
'latin1' => ['latin1', 'café'],
'latin-1' => ['latin-1', 'café'],
'win1252' => ['win1252', 'café'],
'windows1252' => ['windows1252', 'café'],
'cp1252' => ['cp1252', 'café'],
]);
it('throws ValueError for an unsupported encoding', function (): void {
// Given / When / Then
StringEncoder::toUtf8('hello', 'EBCDIC-42');
})->throws(\ValueError::class, 'Unsupported encoding');
});
describe('detectAndConvertToUtf8()', function (): void {
it('returns an empty string when given an empty string', function (): void {
// Given
$input = '';
// When
$result = StringEncoder::detectAndConvertToUtf8($input);
// Then
expect($result)->toBe('');
});
it('detects and passes through valid UTF-8 with a hint', function (): void {
// Given
$input = 'Héllo world 🌍';
// When
$result = StringEncoder::detectAndConvertToUtf8($input, 'Windows-1252');
// Then
expect($result)->toBe('Héllo world 🌍');
});
it('strips the UTF-8 BOM from auto-detected UTF-8 strings', function (): void {
// Given
$input = "\xEF\xBB\xBFBonjour";
// When
$result = StringEncoder::detectAndConvertToUtf8($input);
// Then
expect($result)->toBe('Bonjour');
});
it('uses encoding hints to guide detection', function (): void {
// Given
$input = "5\x80";
// When
$result = StringEncoder::detectAndConvertToUtf8($input, 'Windows-1252');
// Then
expect($result)->toBe('5€');
});
it('produces valid UTF-8 output regardless of detection result', function (): void {
// Given
$input = "\xFE\x01\xFE\x01";
// When / Then
try {
$result = StringEncoder::detectAndConvertToUtf8($input);
expect(mb_check_encoding($result, 'UTF-8'))->toBeTrue();
} catch (\RuntimeException $e) {
expect($e->getMessage())->toContain('Unable to detect encoding');
}
});
});
describe('arrayToUtf8()', function (): void {
it('handles an empty array', function (): void {
// Given
$input = [];
// When
$result = StringEncoder::arrayToUtf8($input, 'UTF-8');
// Then
expect($result)->toBe([]);
});
it('converts an array of Windows-1252 strings with special characters', function (): void {
// Given
$input = ["5\x80", "B\x9Cuf"];
// When
$result = StringEncoder::arrayToUtf8($input, 'Windows-1252');
// Then
expect($result)->toBe(['5€', 'Bœuf']);
});
});
describe('supportedEncodings()', function (): void {
it('returns a non-empty list of strings', function (): void {
// Given / When
$result = StringEncoder::supportedEncodings();
// Then
expect($result)
->toBeArray()
->not->toBeEmpty()
->each->toBeString();
});
it('includes all canonical encodings', function (): void {
// Given
$expected = ['UTF-8', 'ISO-8859-15', 'Windows-1252'];
// When
$result = StringEncoder::supportedEncodings();
// Then
foreach ($expected as $encoding) {
expect($result)->toContain($encoding);
}
});
it('includes common aliases', function (): void {
// Given
$aliases = ['latin1', 'latin-1', 'latin9', 'cp1252', 'utf8', 'utf-8'];
// When
$result = StringEncoder::supportedEncodings();
// Then
foreach ($aliases as $alias) {
expect($result)->toContain($alias);
}
});
it('contains no duplicates', function (): void {
// Given
$result = StringEncoder::supportedEncodings();
// When / Then
expect($result)->toBe(array_values(array_unique($result)));
});
});
describe('encoding resolution (via toUtf8)', function (): void {
it('resolves case-insensitively', function (string $variant): void {
// Given
$input = 'hello';
// When
$result = StringEncoder::toUtf8($input, $variant);
// Then
expect($result)->toBe('hello');
})->with(['utf-8', 'UTF-8', 'Utf-8', 'UTF8', 'utf8']);
it('trims whitespace around encoding names', function (): void {
// Given
$input = 'hello';
// When
$result = StringEncoder::toUtf8($input, ' utf-8 ');
// Then
expect($result)->toBe('hello');
});
});
describe('regressions', function (): void {
it('correctly converts "Bœuf" from Windows-1252 (œ = 0x9C)', function (): void {
// Given
$input = "B\x9Cuf";
// When
$result = StringEncoder::toUtf8($input, 'Windows-1252');
// Then
expect($result)->toBe('Bœuf')
->and(mb_check_encoding($result, 'UTF-8'))->toBeTrue();
});
it('round-trips UTF-8 → identity without corruption', function (): void {
// Given
$input = 'Héllo 世界 🎵 Ÿ œ € ™';
// When
$result = StringEncoder::toUtf8($input, 'UTF-8');
// Then
expect($result)->toBe($input);
});
});