Compare commits

..

No commits in common. "main" and "init-jeremy" have entirely different histories.

215 changed files with 13566 additions and 11671 deletions

6
.gitattributes vendored
View file

@ -1,6 +0,0 @@
/tests export-ignore
/.gitlab-ci.yml export-ignore
/packages export-ignore
/phpstan.neon.dist export-ignore
.gitignore export-ignore
.gitattributes export-ignore

3
.gitignore vendored
View file

@ -1,4 +1,3 @@
/packages/
/vendor/
.php-cs-fixer.cache
junit.xml
@ -6,5 +5,3 @@ junit.xml
coverage
coverage.xml
test.php
.scannerwork/
/composer.lock

View file

@ -1,59 +1,59 @@
include:
- project: "ldc-web/shared-ci"
ref: "main"
file: "lint/phpcs.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "lint/phpstan.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "security/composer.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "test/test.yml"
stages:
- lint
- security
- test
- publish
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'lint/phpcs.yml'
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'lint/phpstan.yml'
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'security/composer.yml'
- project: 'ldc-web/shared-ci'
ref: 'main'
file: 'test/test.yml'
phplint:
stage: lint
image: $CI_REGISTRY/ldc-web/projets/ldc-web-images-docker/uwil-ci-8.5:latest
image: $CI_REGISTRY/ldc-web/projets/ldc-web-images-docker/uwil-qa-8.5:latest
variables:
TEXT_REPORT: php-l-report.txt
before_script:
- composer config gitlab-token.lab.s-web.fr $CI_DEPLOY_USER $CI_DEPLOY_PASSWORD
- composer install --no-scripts --no-interaction --no-ansi --no-progress --prefer-dist
script:
- composer run-script lint:php > "$TEXT_REPORT" 2>&1; status=$?
- rm -f "$TEXT_REPORT"
- touch "$TEXT_REPORT"
- bash -c 'composer run-script lint:php 2>&1 | tee "$TEXT_REPORT"; status=${PIPESTATUS[0]}; if [ $status -ne 0 ]; then echo "composer lint command failed (exit $status)"; fi; exit $status' || true
- cat "$TEXT_REPORT"
- if [ $status -ne 0 ] || grep -qE "Parse error|Errors parsing" "$TEXT_REPORT"; then echo "PHP lint failed"; exit 1; fi
- if grep -qE "Parse error|Errors parsing" "$TEXT_REPORT"; then echo "PHP lint failed"; exit 1; fi
artifacts:
when: always
expose_as: "php-l report"
expose_as: 'php-l report'
paths:
- php-l-report.txt
reports:
junit: php-l-report.txt
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
pint:
stage: lint
image: $CI_REGISTRY/ldc-web/projets/ldc-web-images-docker/uwil-ci-8.5:latest
image: $CI_REGISTRY/ldc-web/projets/ldc-web-images-docker/uwil-qa-8.5:latest
variables:
PINT_REPORT: pint-report.txt
before_script:
- composer config gitlab-token.lab.s-web.fr $CI_DEPLOY_USER $CI_DEPLOY_PASSWORD
- composer install --no-scripts --no-interaction --no-ansi --no-progress --prefer-dist
script:
- vendor/bin/pint --test > "$PINT_REPORT" 2>&1; status=$?
- rm -f "$PINT_REPORT"
- touch "$PINT_REPORT"
- bash -c 'vendor/bin/pint --test 2>&1 | tee "$PINT_REPORT"; status=${PIPESTATUS[0]}; exit $status'
- cat "$PINT_REPORT"
- if [ $status -ne 0 ] || grep -qE "ERROR|ERRORS|ERROR:" "$PINT_REPORT"; then echo "Pint reported errors"; exit 1; fi
- if [ -f "$PINT_REPORT" ] && grep -qE "ERROR|ERRORS|ERROR:" "$PINT_REPORT"; then echo "Pint reported errors"; exit 1; fi
artifacts:
when: always
expose_as: "pint report"
expose_as: 'pint report'
paths:
- pint-report.txt
allow_failure: false
@ -62,7 +62,7 @@ pint:
pest:
stage: test
image: $CI_REGISTRY/ldc-web/projets/ldc-web-images-docker/uwil-ci-8.5:latest
image: $CI_REGISTRY/ldc-web/projets/ldc-web-images-docker/uwil-qa-8.5:latest
variables:
JUNIT_REPORT: junit.xml
TEXT_REPORT: test-output.txt
@ -71,14 +71,14 @@ pest:
- composer config gitlab-token.lab.s-web.fr $CI_DEPLOY_USER $CI_DEPLOY_PASSWORD
- composer install --no-scripts --no-interaction --no-ansi --no-progress --prefer-dist
script:
- rm -f "$JUNIT_REPORT" "$COVERAGE_REPORT"
- composer run-script test:coverage -- --min=85 > "$TEXT_REPORT" 2>&1; status=$?
- rm -f "$JUNIT_REPORT" "$TEXT_REPORT" "$COVERAGE_REPORT"
- touch "$TEXT_REPORT"
- bash -c 'composer run-script test:coverage 2>&1 | tee "$TEXT_REPORT"; status=${PIPESTATUS[0]}; exit $status'
- cat "$TEXT_REPORT"
- if [ -f "$COVERAGE_REPORT" ]; then echo "Coverage report generated $COVERAGE_REPORT"; else echo "No coverage report found"; fi
- exit $status
artifacts:
when: always
expose_as: "pest report"
expose_as: 'pest report'
paths:
- junit.xml
- test-output.txt
@ -89,22 +89,7 @@ pest:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
publish:
stage: publish
image: curlimages/curl:latest
variables:
URL: "$CI_SERVER_PROTOCOL://$CI_SERVER_HOST:$CI_SERVER_PORT/api/v4/projects/$CI_PROJECT_ID/packages/composer?job_token=$CI_JOB_TOKEN"
script:
- version=$(echo "tag=$CI_COMMIT_TAG")
- insecure=$([ "$CI_SERVER_PROTOCOL" = "http" ] && echo "--insecure" || echo "")
- response=$(curl -s -w "\n%{http_code}" $insecure --data $version $URL)
- code=$(echo "$response" | tail -n 1)
- body=$(echo "$response" | head -n 1)
- if [ "$code" -eq 201 ]; then
echo "Package created - Code $code - $body";
else
echo "Could not create package - Code $code - $body";
exit 1;
fi
rules:
- if: '$CI_COMMIT_TAG =~ /^\d+.\d+.\d+/'
stages:
- lint
- security
- test

6
.vscode/launch.json vendored
View file

@ -1,12 +1,6 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Listen for Xdebug",
"type": "php",
"request": "launch",
"port": 9003
},
{
"name": "Listen for Xdebug (Docker)",
"type": "php",

45
.vscode/settings.json vendored
View file

@ -1,7 +1,15 @@
{
"sonarlint.analysisExcludes": "vendor/**,packages/**,junit.xml,coverage/**",
"php.version": "8.5.0",
"intelephense.environment.phpVersion": "8.5.0",
"php.executables": {
"php84": {
"binary": "/usr/local/bin/php84",
"paths": {
"/app": "${workspaceFolder}"
}
}
},
"php.executablePath": "/usr/local/bin/php84",
"php.version": "8.4.0",
"intelephense.environment.phpVersion": "8.4.0",
"intelephense.environment.includePaths": [
"./vendor/pestphp",
"./vendor/phpunit/phpunit",
@ -17,6 +25,9 @@
"${workspaceFolder}/vendor/bin/phpstan"
],
"phpstan.configFile": "phpstan.neon.dist",
"phpstan.paths": {
"${workspaceFolder}": "/app"
},
"files.associations": { "*.module": "php" },
"php.suggest.basic": false,
"laravel-pint.enable": true,
@ -79,33 +90,5 @@
"cmd": "${workspaceFolder}/vendor/bin/pint ${file}"
}
]
},
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#045a4a",
"activityBar.background": "#045a4a",
"activityBar.foreground": "#e7e7e7",
"activityBar.inactiveForeground": "#e7e7e799",
"activityBarBadge.background": "#d029f5",
"activityBarBadge.foreground": "#e7e7e7",
"activityBarTop.activeBackground": "#045a4a",
"activityBarTop.background": "#045a4a",
"activityBarTop.foreground": "#e7e7e7",
"activityBarTop.inactiveForeground": "#e7e7e799",
"commandCenter.border": "#e7e7e799",
"commandCenter.foreground": "#e7e7e7",
"sash.hoverBorder": "#045a4a",
"statusBar.background": "#022922",
"statusBar.debuggingBackground": "#022922",
"statusBar.debuggingForeground": "#e7e7e7",
"statusBar.foreground": "#e7e7e7",
"statusBarItem.hoverBackground": "#045a4a",
"statusBarItem.remoteBackground": "#9709b6",
"statusBarItem.remoteForeground": "#e7e7e7",
"titleBar.activeBackground": "#022922",
"titleBar.activeForeground": "#e7e7e7",
"titleBar.inactiveBackground": "#02292299",
"titleBar.inactiveForeground": "#e7e7e799",
"activityBar.activeBorder": "#e7e7e7",
"activityBarTop.activeBorder": "#e7e7e7"
}
}

View file

337
README.md
View file

@ -1,325 +1,42 @@
# Arcadia API PHP Client
# Arcadia Genesis PHP Client
[![PHP Version](https://img.shields.io/badge/php-^8.5-blue.svg)](https://www.php.net/)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
[![Tests: Pest](https://img.shields.io/badge/tests-Pest%20PHP-purple.svg)](https://pestphp.com/)
[![PHPStan: Level 10](https://img.shields.io/badge/phpstan-level%2010-brightgreen.svg)](https://phpstan.org/)
## Mappers
Client PHP haut niveau, fortement typé et orienté domaine pour l'API Arcadia.
Il s'appuie sur le client OpenAPI généré par Kiota (`uwil/arcadia-kiota-open-api-client`) et fournit :
- Une couche de services métier (`EmployeeService`, `IncoService`, `BrandService`, `ApiService`).
- Des Value Objects stricts (`ArcadiaEmployeeId`, `IncoId`, `IncoCompoId`, `BrandCode`, `SiteId`, etc.).
- Des DTOs riches (`Employee`, `Inco`, `IncoCompo`, `IncoShort`, `Brand`) avec typage complet et conformité `JsonSerializable`.
- Une gestion unifiée de la pagination (`PageOf`, `Pagination`) et du parcours continu par flux (`StreamOf`).
- L'authentification automatique avec signature HMAC (`HMAC-SHA256`) et gestion des nonces.
- Le support de la journalisation (PSR-3 `LoggerInterface`) et de l'observabilité (OpenTelemetry).
- Une gestion explicite des erreurs via la hiérarchie `ApiError`.
### Employee
---
## Sommaire
- [Prérequis](#prérequis)
- [Installation](#installation)
- [Initialisation](#initialisation)
- [Services et Utilisation](#services-et-utilisation)
- [Vérification de l'API (`apiService`)](#vérification-de-lapi-apiservice)
- [Gestion des Collaborateurs (`employees`)](#gestion-des-collaborateurs-employees)
- [Fiches INCO & Compositions (`incoSheets`)](#fiches-inco--compositions-incosheets)
- [Gestion des Marques (`brands`)](#gestion-des-marques-brands)
- [Pagination et Streaming](#pagination-et-streaming)
- [Gestion des Erreurs](#gestion-des-erreurs)
- [Tests et Qualité](#tests-et-qualité)
- [Licence](#licence)
---
## Prérequis
- **PHP 8.5+**
- Extensions PHP : `ext-curl`, `ext-mbstring`
- Accès au registre GitLab Composer de LDC Digital
---
## Installation
Comme cette bibliothèque et sa sous-dépendance Kiota sont hébergées sur le registre privé GitLab de LDC, configurez le dépôt Composer dans le `composer.json` de votre projet :
```json
{
"repositories": [
{
"type": "composer",
"url": "https://lab.s-web.fr/api/v4/group/23/-/packages/composer/packages.json"
}
]
}
```
Puis installez le client via Composer :
```bash
composer require uwil/arcadia-api-php-client
```
Assurez-vous d'avoir configuré vos identifiants d'accès GitLab pour Composer (via `auth.json` ou variable d'environnement CI) :
```bash
composer config gitlab-token.lab.s-web.fr <deploy-token-user> <deploy-token-password>
```
---
## Initialisation
L'entrée principale de la bibliothèque est [`ArcadiaApiClient`](src/ArcadiaApiClient.php), configurée avec [`ApiConfig`](src/Lib/ApiConfig.php) :
#### Props
```php
use GuzzleHttp\Psr7\Utils;
use Uwil\ArcadiaApiPhpClient\ArcadiaApiClient;
use Uwil\ArcadiaApiPhpClient\Auth\ClientIdentifier;
use Uwil\ArcadiaApiPhpClient\Auth\ClientSecret;
use Uwil\ArcadiaApiPhpClient\Lib\ApiConfig;
$config = new ApiConfig(
endpoint: Utils::uriFor('https://arcadia-api.domain.tld/api'),
apiKey: new ClientIdentifier('VOTRE_API_KEY'),
secret: new ClientSecret('VOTRE_SECRET_KEY'),
curlTimeOut: 20.0, // secondes (optionnel, défaut: 20.0)
maxRetries: 3, // tentatives en cas d'erreur réseau (optionnel, défaut: 0)
retryDelaySeconds: 1, // délai entre les retries (optionnel, défaut: 1)
ignoreSelfSignedCert: false, // pour environnements locaux de test (défaut: false)
logger: null, // ?Psr\Log\LoggerInterface
tracer: null, // ?OpenTelemetry\API\Trace\TracerInterface
);
$client = new ArcadiaApiClient($config);
public string $matricule;
public string $lastname;
public string $firstname;
public string $start_date;
public string|null $end_date;
public string|null $planned_end_date;
public string $establishment;
public string $site;
public string $service;
```
---
## Services et Utilisation
### Vérification de l'API (`apiService`)
Permet de vérifier la disponibilité et la santé de l'API Arcadia :
#### Usage
```php
$isAlive = $client->apiService()->isAlive();
<?php
if ($isAlive) {
echo "L'API Arcadia est joignable et opérationnelle.\n";
}
use Uwil\ArcadiaGenesisPhpClient\Datas\EmployeesDatas;
require_once 'vendor/autoload.php';
$employee = EmployeesDatas::getById("0115590");
var_dump($employee);
?>
```
---
### INCO
### Gestion des Collaborateurs (`employees`)
TODO
Le service [`EmployeeService`](src/Service/EmployeeService.php) permet de consulter et rechercher les salariés.
## TESTS
#### Récupérer par identifiant
```php
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\ArcadiaEmployeeId;
use Uwil\ArcadiaApiPhpClient\Error\ApiError;
$employee = $client->employees()->findBy(new ArcadiaEmployeeId('0115590'));
if ($employee instanceof ApiError) {
echo "Erreur API: " . $employee->getMessage();
} elseif ($employee === null) {
echo "Collaborateur non trouvé (404).\n";
} else {
echo "Salarié: {$employee->firstname} {$employee->lastname} (Site: {$employee->site})\n";
}
```
#### Récupérer par site(s)
```php
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\SiteId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiSorter\EmployeeSorter;
use Uwil\ArcadiaApiPhpClient\Lib\ApiSorter\EmployeeSortColumn;
// Pour un site unique avec tri par matricule
$page = $client->employees()->getAllOfSites(
new SiteId('01'),
new EmployeeSorter(EmployeeSortColumn::MATRICULE, 'asc')
);
// Pour plusieurs sites modifiés depuis une date
$page = $client->employees()->getAllOfSitesSince(
[new SiteId('01'), new SiteId('02')],
new DateTimeImmutable('2026-01-01')
);
```
#### Streaming de tous les collaborateurs
Le streaming parcourt automatiquement toutes les pages de façon transparente grâce à un générateur PHP :
```php
foreach ($client->employees()->streamAll() as $employee) {
echo "{$employee->matricule} - {$employee->lastname}\n";
}
```
---
### Fiches INCO & Compositions (`incoSheets`)
Le service [`IncoService`](src/Service/IncoService.php) gère les fiches techniques produit (INCO) et leurs compositions nutritionnelles détaillées.
#### Consulter une fiche INCO
```php
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoId;
$inco = $client->incoSheets()->findBy(new IncoId('12345'));
if ($inco !== null && !($inco instanceof ApiError)) {
echo "Fiche: {$inco->commercialLabel}\n";
echo "Nutri-Score: {$inco->nutriscore?->grade}\n";
}
```
#### Consulter la composition nutritionnelle
Vous pouvez interroger la composition par son `IncoCompoId` ou directement par l'`IncoId` :
```php
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoCompoId;
// Directement via IncoCompoId
$compo = $client->incoSheets()->findCompo(new IncoCompoId('98765'));
// Ou via IncoId
$compo = $client->incoSheets()->findCompo(new IncoId('12345'));
if ($compo !== null && !($compo instanceof ApiError)) {
echo "Énergie (kJ): {$compo->energy?->kJ?->value}\n";
echo "Acides gras saturés: {$compo->fattyAcids?->saturatedFattyAcids?->value}\n";
echo "Points Nutri-Score: {$compo->nutriScorePoints?->totalPoints}\n";
}
```
#### Recherche et listage par marque
```php
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\BrandCode;
// Liste courte (IncoShort) pour optimiser les performances d'affichage
$shortsPage = $client->incoSheets()->getAllShortOfBrands(new BrandCode('GAULOIS'));
// Recherche par motif d'identifiant
$results = $client->incoSheets()->findByIncoIdLike('POULET%', new BrandCode('GAULOIS'));
// Streaming de toutes les fiches d'une marque
foreach ($client->incoSheets()->streamAllOfBrands(new BrandCode('LEGAULOIS')) as $sheet) {
echo "Fiche #{$sheet->id->value}: {$sheet->commercialLabel}\n";
}
```
---
### Gestion des Marques (`brands`)
Le service [`BrandService`](src/Service/BrandService.php) donne accès aux marques référencées :
```php
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\BrandCode;
// Récupérer une marque
$brand = $client->brands()->findBy(new BrandCode('GAULOIS'));
// Parcourir toutes les marques
foreach ($client->brands()->streamAll() as $brand) {
echo "Marque [{$brand->code->value}] : {$brand->label}\n";
}
```
---
## Pagination et Streaming
### Objet `PageOf`
Toutes les méthodes de liste paginées retournent un objet [`PageOf<T>`](src/Lib/ApiDto/Dto/PageOf.php) contenant les éléments et les métadonnées de pagination :
```php
$page = $client->employees()->getAll();
// Éléments de la page courante
$employees = $page->items;
// Métadonnées
echo "Page actuelle : " . $page->pagination->pageNumber->value . "\n";
echo "Taille de page : " . $page->pagination->pageSize->value . "\n";
echo "Total éléments : " . $page->pagination->totalItems . "\n";
echo "Total pages : " . $page->pagination->totalPages . "\n";
```
### Objet `StreamOf`
Toutes les méthodes `stream*()` retournent un [`StreamOf<T>`](src/Lib/Pagination/StreamOf.php) qui implémente `\IteratorAggregate`. Il effectue les requêtes de page suivante automatiquement à la demande :
```php
$stream = $client->employees()->streamAll();
foreach ($stream as $employee) {
// Les pages suivantes sont chargées en arrière-plan au fur et à mesure de l'itération
}
```
---
## Gestion des Erreurs
Les méthodes du client ne lancent pas d'exceptions inattendues pour les erreurs HTTP ou de communication ; elles renvoient une instance de [`ApiError`](src/Error/ApiError.php) (ou ses sous-classes spécialisées) :
```php
use Uwil\ArcadiaApiPhpClient\Error\AuthenticationError;
use Uwil\ArcadiaApiPhpClient\Error\ValidationError;
use Uwil\ArcadiaApiPhpClient\Error\NetworkError;
use Uwil\ArcadiaApiPhpClient\Error\RateLimitError;
use Uwil\ArcadiaApiPhpClient\Error\ServerError;
use Uwil\ArcadiaApiPhpClient\Error\ApiError;
$result = $client->employees()->findBy(new ArcadiaEmployeeId('0115590'));
match (true) {
$result instanceof AuthenticationError => logAuthFailure($result),
$result instanceof ValidationError => logValidationDetails($result->getViolations()),
$result instanceof NetworkError => retryLater($result),
$result instanceof RateLimitError => handleRateLimit($result),
$result instanceof ServerError => notifyAdmin($result),
$result instanceof ApiError => logGenericError($result),
$result === null => handleNotFound(),
default => handleEmployee($result),
};
```
---
## Tests et Qualité
Le projet est rigoureusement testé avec une suite complète de tests unitaires et d'architecture.
```bash
# Lancer les tests unitaires
composer test
# Vérifier la couverture de code (seuil minimal 85% configuré)
composer test:coverage
# Analyse statique PHPStan (niveau 10)
composer lint:phpstan
# Vérifier le style du code (Laravel Pint)
composer lint:pint
# Formater le code automatiquement
composer format
```
---
## Licence
Ce projet est sous licence MIT. Consultez le fichier [LICENSE](LICENSE) pour plus d'informations.
[coverage](./coverage/index.html)

5
TODO.md Normal file
View file

@ -0,0 +1,5 @@
# TODO
- add pest tests
- map more info on INCO
- add Nutritioanl Values call when authorized

View file

@ -1,15 +1,11 @@
{
"name": "uwil/arcadia-api-php-client",
"description": "PHP Client implementation for Arcadia API",
"name": "uwil/arcadia-genesis-php-client",
"description": "PHP Client implementation for Arcadia Genesis API",
"type": "library",
"license": "MIT",
"autoload": {
"psr-4": {
"Uwil\\ArcadiaApiPhpClient\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Uwil\\ArcadiaGenesisPhpClient\\": "src/",
"Tests\\": "tests/"
}
},
@ -20,21 +16,19 @@
}
],
"scripts": {
"analyse": "vendor/bin/phpstan analyse",
"format": "vendor/bin/pint",
"lint:php": "find . -path './vendor' -prune -o -type f -name '*.php' -print0 | xargs -0 -n1 -P4 -I{} php -l \"{}\"",
"lint:phpcs": "vendor/bin/phpcs",
"lint:phpstan": "vendor/bin/phpstan analyse",
"lint:pint": "vendor/bin/pint --no-interaction --quiet",
"test": "vendor/bin/pest --log-junit=junit.xml",
"test:coverage": "vendor/bin/pest --log-junit=junit.xml --coverage --coverage-html=coverage --coverage-clover=coverage.xml"
},
"require": {
"ext-curl": "*",
"php": "^8.4",
"ext-mbstring": "*",
"php": ">=8.3",
"guzzlehttp/guzzle": "^7.10",
"uwil/arcadia-kiota-open-api-client": "^0.1.0",
"symfony/polyfill-php86": "^1.41"
"php-di/php-di": "^7.1"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
@ -46,18 +40,10 @@
"mockery/mockery": "^1.6",
"dg/bypass-finals": "^1.9"
},
"repositories": [
{
"type": "composer",
"url": "https://lab.s-web.fr/api/v4/group/23/-/packages/composer/packages.json"
}
],
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true,
"pestphp/pest-plugin": true,
"php-http/discovery": true,
"tbachert/spi": true
"pestphp/pest-plugin": true
}
}
}

6850
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,8 @@ parameters:
paths:
- src
level: 10
# Level 9 is the highest level
level: 8
# Enforce strict types analysis

View file

@ -1,7 +0,0 @@
sonar.projectKey=arcadia-genesis-php-client
sonar.projectName=Arcadia Genesis PHP Client
sonar.sources=src
sonar.tests=tests
sonar.sourceEncoding=UTF-8
sonar.exclusions=**/vendor/**,**/packages/**,**/coverage/**,junit.xml
sonar.php.exclusions=**/vendor/**,**/packages/**

View file

@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient;
use Uwil\ArcadiaApiPhpClient\Factory\ApiClientFactory;
use Uwil\ArcadiaApiPhpClient\Factory\ServiceFactory;
use Uwil\ArcadiaApiPhpClient\Lib\ApiConfig;
use Uwil\ArcadiaApiPhpClient\Service\ApiService;
use Uwil\ArcadiaApiPhpClient\Service\BrandService;
use Uwil\ArcadiaApiPhpClient\Service\EmployeeService;
use Uwil\ArcadiaApiPhpClient\Service\IncoService;
class ArcadiaApiClient
{
private readonly ServiceFactory $serviceFactory;
public function __construct(
private readonly ApiConfig $apiConfig
) {
$apiClientFactory = new ApiClientFactory($this->apiConfig);
$apiClient = $apiClientFactory->createClient();
$this->serviceFactory = new ServiceFactory(
$apiClient,
$this->apiConfig
);
}
public function apiService(): ApiService
{
return $this->serviceFactory->createApiService();
}
public function employees(): EmployeeService
{
return $this->serviceFactory->createEmployeeService();
}
public function incoSheets(): IncoService
{
return $this->serviceFactory->createIncoService();
}
public function brands(): BrandService
{
return $this->serviceFactory->createBrandService();
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient;
use DI\Container;
use DI\ContainerBuilder;
use Uwil\ArcadiaGenesisPhpClient\Di\PackageDi;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiConfig;
use Uwil\ArcadiaGenesisPhpClient\Service\EmployeeService;
use Uwil\ArcadiaGenesisPhpClient\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,100 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Auth;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\Utils;
use Psr\Http\Message\RequestInterface;
use Ramsey\Uuid\Uuid;
use Uwil\ArcadiaApiPhpClient\Enum\HttpMethod;
use Uwil\ArcadiaApiPhpClient\Lib\LibMiddleware;
/**
* @internal
*/
final class ApiMandatoryHeadersMiddleware implements LibMiddleware
{
public const string HANDLER_NAME = 'Arcadia API request Signature';
public const string HEADER_CLIENT_IDENTIFIER = 'X-Client-Identifier';
public const string HEADER_BODY_HASH = 'X-Body-Hash';
public const string HEADER_TIMESTAMP = 'X-Timestamp';
public const string HEADER_SIGNATURE = 'X-Signature';
public const string HEADER_NONCE = 'X-Nonce';
public static function signRequest(
ClientIdentifier $apiKey,
ClientSecret $withSecret,
RequestSignatureCalculator $signatureCalculator,
): callable {
return static function (callable $handler) use ($apiKey, $withSecret, $signatureCalculator): self {
return new self(
$handler,
$apiKey,
$withSecret,
$signatureCalculator
);
};
}
/**
* @param callable(RequestInterface, array<string, mixed>): PromiseInterface $nextHandler
*/
public function __construct(
private $nextHandler,
private readonly ClientIdentifier $apiKey,
private readonly ClientSecret $withSecret,
private readonly RequestSignatureCalculator $signatureCalculator,
) {
}
public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
$fn = $this->nextHandler;
return $fn($this->sign($request), $options);
}
private function sign(RequestInterface $request): RequestInterface
{
$body = $request->getBody();
$body->rewind();
$bodyContents = $body->getContents();
$timestamp = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$nonce = new Nonce(value: Uuid::uuid7());
$signatureResult = $this->signatureCalculator->sign(
new RequestSignaturePayload(
method: HttpMethod::from($request->getMethod()),
uri: $request->getUri(),
timestamp: $timestamp,
nonce: $nonce,
body: $bodyContents,
),
$this->withSecret,
);
$headers = [
self::HEADER_CLIENT_IDENTIFIER => $this->apiKey->value,
self::HEADER_TIMESTAMP => $timestamp->format(\DateTimeInterface::RFC3339),
self::HEADER_NONCE => $nonce->value->toString(),
self::HEADER_BODY_HASH => $signatureResult->bodyHash,
self::HEADER_SIGNATURE => $signatureResult->value,
];
return Utils::modifyRequest($request, [
'set_headers' => array_filter(
$headers,
static fn (string $name): bool => ! $request->hasHeader($name),
ARRAY_FILTER_USE_KEY
),
]);
}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Auth;
final readonly class ClientIdentifier
{
public function __construct(
#[\SensitiveParameter]
public string $value,
) {
if (empty(mb_trim($value))) {
throw new \InvalidArgumentException('ClientIdentifier cannot be empty.');
}
}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Auth;
final readonly class ClientSecret
{
public function __construct(
#[\SensitiveParameter]
public string $value,
) {
if (empty(mb_trim($value))) {
throw new \InvalidArgumentException('ClientSecret cannot be empty.');
}
}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Auth;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
final readonly class Nonce
{
public function __construct(
public readonly UuidInterface $value,
) {
if (! Uuid::isValid($value->toString())) {
throw new \InvalidArgumentException('Nonce value is not a valid UUID');
}
}
}

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Auth;
final readonly class RequestSignature
{
public function __construct(
public string $value,
public string $bodyHash,
) {
if (empty(mb_trim($value))) {
throw new \InvalidArgumentException('Signature value cannot be empty.');
}
if (empty(mb_trim($bodyHash))) {
throw new \InvalidArgumentException('Body hash cannot be empty.');
}
}
}

View file

@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Auth;
use Psr\Http\Message\UriInterface;
/**
* @internal
*/
class RequestSignatureCalculator
{
private const DATE_FORMAT = \DateTimeInterface::RFC3339;
private const HASH_ALGORITHM = 'sha256';
public function sign(RequestSignaturePayload $payload, ClientSecret $withSecret): RequestSignature
{
$bodyHash = hash(self::HASH_ALGORITHM, $payload->body);
$payloadToSign = [
$payload->method->value,
$this->normalizePathAndQuery($payload->uri),
$payload->timestamp->format(self::DATE_FORMAT),
$payload->nonce->value->toString(),
$bodyHash,
];
$signature = hash_hmac(
self::HASH_ALGORITHM,
implode('|', $payloadToSign),
$withSecret->value
);
return new RequestSignature(
value: $signature,
bodyHash: $bodyHash,
);
}
private function normalizePathAndQuery(UriInterface $uri): string
{
$path = $uri->getPath();
$query = $uri->getQuery();
if ($query === '') {
return $path;
}
parse_str($query, $queryParams);
ksort($queryParams);
$sortedQuery = http_build_query($queryParams);
return "{$path}?{$sortedQuery}";
}
}

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Auth;
use Psr\Http\Message\UriInterface;
use Uwil\ArcadiaApiPhpClient\Enum\HttpMethod;
final readonly class RequestSignaturePayload
{
public function __construct(
public HttpMethod $method,
public UriInterface $uri,
public \DateTimeInterface $timestamp,
public Nonce $nonce,
public string $body,
) {
}
}

86
src/Di/PackageDi.php Normal file
View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\Di;
use DI\Container;
use DI\ContainerBuilder;
use GuzzleHttp\Client;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiCaller;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiConfig;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Factory\EmployeeDtoFactory;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Factory\IncoCompoDtoFactory;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Factory\IncoDtoFactory;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Factory\IncoShortDtoFactory;
use Uwil\ArcadiaGenesisPhpClient\Lib\Psr7\RequestFactory;
use Uwil\ArcadiaGenesisPhpClient\Mapper\EmployeeMapper;
use Uwil\ArcadiaGenesisPhpClient\Mapper\IncoCompoMapper;
use Uwil\ArcadiaGenesisPhpClient\Mapper\IncoMapper;
use Uwil\ArcadiaGenesisPhpClient\Mapper\IncoShortMapper;
use Uwil\ArcadiaGenesisPhpClient\Repository\EmployeeGenesisRepository;
use Uwil\ArcadiaGenesisPhpClient\Repository\EmployeeRepository;
use Uwil\ArcadiaGenesisPhpClient\Repository\IncoCompoGenesisRepository;
use Uwil\ArcadiaGenesisPhpClient\Repository\IncoCompoRepository;
use Uwil\ArcadiaGenesisPhpClient\Repository\IncoGenesisRepository;
use Uwil\ArcadiaGenesisPhpClient\Repository\IncoRepository;
use Uwil\ArcadiaGenesisPhpClient\Repository\IncoShortGenesisRepository;
use Uwil\ArcadiaGenesisPhpClient\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

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\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,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Enum;
enum HttpMethod: string
{
case GET = 'GET';
case POST = 'POST';
case PUT = 'PUT';
case PATCH = 'PATCH';
case DELETE = 'DELETE';
case HEAD = 'HEAD';
case OPTIONS = 'OPTIONS';
}

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Error;
abstract readonly class AbstractApiError implements ApiError
{
public function __construct(
public string $message,
public int $statusCode = 0,
public ?\Throwable $previous = null,
) {
}
public function message(): string
{
return $this->message;
}
public function statusCode(): int
{
return $this->statusCode;
}
public function previous(): ?\Throwable
{
return $this->previous;
}
}

View file

@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Error;
interface ApiError
{
public function message(): string;
public function statusCode(): int;
public function previous(): ?\Throwable;
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Error;
final readonly class AuthenticationError extends AbstractApiError
{
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Error;
final readonly class NetworkError extends AbstractApiError
{
public function __construct(
string $message,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Error;
final readonly class RateLimitError extends AbstractApiError
{
public function __construct(
string $message,
public ?int $retryAfter = null,
?\Throwable $previous = null,
) {
parent::__construct($message, 429, $previous);
}
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Error;
final readonly class ServerError extends AbstractApiError
{
}

View file

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Error;
final readonly class UnexpectedApiError extends AbstractApiError
{
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Error;
final readonly class ValidationError extends AbstractApiError
{
/**
* @param array<string, string> $violations Map of invalid parameter => reason
*/
public function __construct(
string $message,
public array $violations = [],
?\Throwable $previous = null,
) {
parent::__construct($message, 422, $previous);
}
}

View file

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

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\Exception;
use Uwil\ArcadiaGenesisPhpClient\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

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

View file

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

View file

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

View file

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

View file

@ -1,96 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Factory;
use GuzzleHttp\Client;
use Microsoft\Kiota\Bundle\DefaultRequestAdapter;
use Microsoft\Kiota\Http\KiotaClientFactory;
use Microsoft\Kiota\Http\Middleware\KiotaMiddleware;
use Microsoft\Kiota\Http\Middleware\Options\RetryOption;
use Microsoft\Kiota\Http\Middleware\Options\UserAgentHandlerOption;
use Microsoft\Kiota\Http\Middleware\RetryHandler;
use Microsoft\Kiota\Http\Middleware\UserAgentHandler;
use Uwil\ArcadiaApiPhpClient\Auth\ApiMandatoryHeadersMiddleware;
use Uwil\ArcadiaApiPhpClient\Auth\RequestSignatureCalculator;
use Uwil\ArcadiaApiPhpClient\Lib\ApiConfig;
use Uwil\ArcadiaApiPhpClient\Lib\LibProperties;
use Uwil\ArcadiaApiPhpClient\Logger\ApiLoggerMiddleware;
use Uwil\ArcadiaApiPhpClient\Observability\ObservabilityOption;
use Uwil\ArcadiaKiotaOpenApiClient\ApiClient;
/**
* @internal
*/
final class ApiClientFactory
{
public function __construct(
private ApiConfig $apiConfig
) {
}
public function createClient(): ApiClient
{
$signatureCalculator = new RequestSignatureCalculator();
$libProperties = new LibProperties();
$uaOptions = new UserAgentHandlerOption();
$uaOptions->setProductName($libProperties->productName);
$uaOptions->setProductVersion($libProperties->productVersion);
$handlerStack = KiotaClientFactory::getDefaultHandlerStack();
if ($this->apiConfig->logger !== null) {
$handlerStack->push(
ApiLoggerMiddleware::log($this->apiConfig->logger),
ApiLoggerMiddleware::HANDLER_NAME
);
}
$handlerStack->push(
ApiMandatoryHeadersMiddleware::signRequest(
apiKey: $this->apiConfig->apiKey,
withSecret: $this->apiConfig->secret,
signatureCalculator: $signatureCalculator
),
ApiMandatoryHeadersMiddleware::HANDLER_NAME
);
$handlerStack->push(
KiotaMiddleware::userAgent($uaOptions),
UserAgentHandler::HANDLER_NAME
);
$handlerStack->push(
KiotaMiddleware::retry(
new RetryOption(
maxRetries: $this->apiConfig->maxRetries,
delaySeconds: $this->apiConfig->retryDelaySeconds
)
),
RetryHandler::HANDLER_NAME
);
$guzzleClient = new Client([
'base_uri' => $this->apiConfig->endpoint,
'timeout' => $this->apiConfig->curlTimeOut,
'connect_timeout' => min(5.0, $this->apiConfig->curlTimeOut),
'handler' => $handlerStack,
'verify' => ! $this->apiConfig->ignoreSelfSignedCert,
]);
$observabilityOption = new ObservabilityOption(
$libProperties,
$this->apiConfig->tracer
);
$adapter = DefaultRequestAdapter::builder()
->withGuzzleClient($guzzleClient)
->withObservabilityOption($observabilityOption)
->build();
$adapter->setBaseUrl($this->apiConfig->endpoint->__toString());
return new ApiClient($adapter);
}
}

View file

@ -1,106 +0,0 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Factory;
use Uwil\ArcadiaApiPhpClient\Lib\ApiConfig;
use Uwil\ArcadiaApiPhpClient\Mapper\BrandMapper;
use Uwil\ArcadiaApiPhpClient\Mapper\EmployeeMapper;
use Uwil\ArcadiaApiPhpClient\Mapper\IncoCompoMapper;
use Uwil\ArcadiaApiPhpClient\Mapper\IncoMapper;
use Uwil\ArcadiaApiPhpClient\Mapper\IncoShortMapper;
use Uwil\ArcadiaApiPhpClient\Repository\BrandApiRepository;
use Uwil\ArcadiaApiPhpClient\Repository\EmployeeApiRepository;
use Uwil\ArcadiaApiPhpClient\Repository\IncoApiRepository;
use Uwil\ArcadiaApiPhpClient\Repository\IncoCompoApiRepository;
use Uwil\ArcadiaApiPhpClient\Repository\IncoShortApiRepository;
use Uwil\ArcadiaApiPhpClient\Service\ApiService;
use Uwil\ArcadiaApiPhpClient\Service\ArcadiaApiService;
use Uwil\ArcadiaApiPhpClient\Service\ArcadiaBrandService;
use Uwil\ArcadiaApiPhpClient\Service\ArcadiaEmployeeService;
use Uwil\ArcadiaApiPhpClient\Service\ArcadiaIncoService;
use Uwil\ArcadiaApiPhpClient\Service\BrandService;
use Uwil\ArcadiaApiPhpClient\Service\EmployeeService;
use Uwil\ArcadiaApiPhpClient\Service\IncoService;
use Uwil\ArcadiaKiotaOpenApiClient\ApiClient;
/**
* @internal
*/
final class ServiceFactory
{
private ?ApiService $apiService = null;
private ?EmployeeService $employeeService = null;
private ?IncoService $incoService = null;
private ?BrandService $brandService = null;
public function __construct(
private readonly ApiClient $apiClient,
private readonly ApiConfig $apiConfig
) {
}
public function createApiService(): ApiService
{
return $this->apiService ??= new ArcadiaApiService(
$this->apiConfig
);
}
public function createEmployeeService(): EmployeeService
{
return $this->employeeService ??= new ArcadiaEmployeeService(
new EmployeeApiRepository(
$this->apiClient,
new EmployeeMapper()
)
);
}
public function createIncoService(): IncoService
{
return $this->incoService ??= new ArcadiaIncoService(
new IncoApiRepository(
$this->apiClient,
new IncoMapper()
),
new IncoShortApiRepository(
$this->apiClient,
new IncoShortMapper()
),
new IncoCompoApiRepository(
$this->apiClient,
new IncoCompoMapper()
)
);
}
public function createBrandService(): BrandService
{
return $this->brandService ??= new ArcadiaBrandService(
new BrandApiRepository(
$this->apiClient,
new BrandMapper()
)
);
}
public function employeeService(): EmployeeService
{
return $this->createEmployeeService();
}
public function incoService(): IncoService
{
return $this->createIncoService();
}
public function brandService(): BrandService
{
return $this->createBrandService();
}
}

208
src/Lib/ApiCaller.php Normal file
View file

@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\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\ArcadiaGenesisPhpClient\Enum\ArcadiaGenesisApiErrorCode;
use Uwil\ArcadiaGenesisPhpClient\Exception\ApiCallFailed;
use Uwil\ArcadiaGenesisPhpClient\Exception\ApiErrorException;
use Uwil\ArcadiaGenesisPhpClient\Exception\InvalidApiResponse;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiQueryBuilder\TableQuery;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiResponse\ApiResponse;
use Uwil\ArcadiaGenesisPhpClient\Lib\Psr7\RequestFactory;
use Uwil\ArcadiaGenesisPhpClient\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\ArcadiaGenesisPhpClient\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\ArcadiaGenesisPhpClient\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\ArcadiaGenesisPhpClient\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

@ -2,41 +2,28 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib;
namespace Uwil\ArcadiaGenesisPhpClient\Lib;
use OpenTelemetry\API\Trace\TracerInterface;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface;
use Uwil\ArcadiaApiPhpClient\Auth\ClientIdentifier;
use Uwil\ArcadiaApiPhpClient\Auth\ClientSecret;
final readonly class ApiConfig
readonly class ApiConfig
{
public function __construct(
#[\SensitiveParameter]
public UriInterface $endpoint,
public readonly UriInterface $endpoint,
#[\SensitiveParameter]
public ClientIdentifier $apiKey,
public readonly string $apiKey,
#[\SensitiveParameter]
public ClientSecret $secret,
public float $curlTimeOut = 20.0,
public int $maxRetries = 0,
public int $retryDelaySeconds = 1,
public bool $ignoreSelfSignedCert = false,
public ?LoggerInterface $logger = null,
public ?TracerInterface $tracer = null,
public readonly string $secret,
public readonly float $curlTimeOut = 20.0,
public readonly int $maxRetries = 0,
public readonly int $retryDelayMs = 500,
) {
if (! in_array(strtolower($endpoint->getScheme()), ['http', 'https'])) {
throw new \InvalidArgumentException('endpoint must have http or https scheme');
}
if ($curlTimeOut <= 0) {
throw new \InvalidArgumentException('curlTimeOut must be positive');
}
if ($maxRetries < 0) {
throw new \InvalidArgumentException('maxRetries must be non-negative');
}
if ($retryDelaySeconds < 0) {
throw new \InvalidArgumentException('retryDelaySeconds must be non-negative');
if ($retryDelayMs < 0) {
throw new \InvalidArgumentException('retryDelayMs must be non-negative');
}
}
}

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto;
interface Dto extends \JsonSerializable
{

View file

@ -2,21 +2,20 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\BrandCode;
use Uwil\ArcadiaGenesisPhpClient\Exception\DomainException;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiValueObject\BrandCode;
final readonly class Brand implements Dto
{
public function __construct(
public BrandCode $code,
public string $label,
public ?string $arcadiaId = null,
public ?\DateTimeInterface $createdAt = null,
public readonly BrandCode $code,
public readonly string $label
) {
if (empty(mb_trim($label))) {
throw new \DomainException(
if (empty(trim($label))) {
throw new DomainException(
"Invalid label '{$label}' should not be empty"
);
}
@ -25,10 +24,8 @@ final readonly class Brand implements Dto
public function jsonSerialize(): mixed
{
return [
'code' => $this->code->value,
'label' => $this->label,
'arcadiaId' => $this->arcadiaId,
'createdAt' => $this->createdAt?->format(\DateTimeInterface::ATOM),
'code' => $this->code->value,
'label' => $this->label,
];
}
}

View file

@ -2,14 +2,14 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use DateTimeInterface;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\ArcadiaEmployeeId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\EstablishmentCode;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\ServiceId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\SiteId;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiValueObject\ArcadiaEmployeeId;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiValueObject\EstablishmentCode;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiValueObject\ServiceId;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiValueObject\SiteId;
final readonly class Employee implements Dto
{

View file

@ -2,19 +2,20 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
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\StorageCondition;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoCompoId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoId;
use Uwil\ArcadiaGenesisPhpClient\Exception\UnexpectedErrorException;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\Additive;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\Allegations;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\Allergen;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\IncoWeight;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\Ingredients;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\Nutriscore;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\PortionAmount;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\StorageCondition;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiValueObject\IncoCompoId;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiValueObject\IncoId;
/**
* Represents an Inco sheet (ingredients / recipe).
@ -45,17 +46,17 @@ final readonly class Inco implements Dto
public ?StorageCondition $storageCondition,
public array $additives,
public ?IncoCompoId $compo,
public ?\DateTimeInterface $createdAt,
public ?\DateTimeInterface $updatedAt,
public ?\DateTimeInterface $created_at,
public ?\DateTimeInterface $updated_at,
) {
foreach ($this->allergens as $allergen) {
if (! is_object($allergen) || ! ($allergen instanceof Allergen)) {
throw new \InvalidArgumentException('Allergens shall be instance of ' . Allergen::class);
throw new UnexpectedErrorException('Allergens shall be instance of ' . Allergen::class);
}
}
foreach ($this->additives as $additive) {
if (! is_object($additive) || ! ($additive instanceof Additive)) {
throw new \InvalidArgumentException('Additives shall be instance of ' . Additive::class);
throw new UnexpectedErrorException('Additives shall be instance of ' . Additive::class);
}
}
}
@ -85,8 +86,8 @@ final readonly class Inco implements Dto
'storageCondition' => $this->storageCondition,
'additives' => $this->additives,
'compo' => $this->compo?->value,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View file

@ -2,9 +2,10 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Exception\DomainException;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
/**
* Represents a food additive identified by its E-number code.
@ -24,16 +25,16 @@ final readonly class Additive implements Dto
public readonly string $label,
) {
$this->code = $code;
$trimmedLabel = mb_trim($label);
$trimmedLabel = trim($label);
if (! preg_match('/^E\d+(?:[a-z]+(?:\([ivx]+\))?)?$/', $this->code)) {
throw new \DomainException(
throw new DomainException(
"Invalid additive code '{$code}': must match E-number format (e.g. E300, E471a, E160ai, E160a(ii), E1404)"
);
}
if ($trimmedLabel === '') {
throw new \DomainException(
throw new DomainException(
"Invalid label for additive '{$code}': must not be empty"
);
}

View file

@ -2,9 +2,9 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
final readonly class Allegations implements Dto
{
@ -35,10 +35,10 @@ final readonly class Allegations implements Dto
array $nutrition = [],
array $other = []
) {
$this->allegations = \array_filter($allegations, fn (string $el) => ! empty($el));
$this->health = \array_filter($health, fn (string $el) => ! empty($el));
$this->nutrition = \array_filter($nutrition, fn (string $el) => ! empty($el));
$this->other = \array_filter($other, fn (string $el) => ! empty($el));
$this->allegations = \array_filter($allegations, fn ($el) => ! empty($el));
$this->health = \array_filter($health, fn ($el) => ! empty($el));
$this->nutrition = \array_filter($nutrition, fn ($el) => ! empty($el));
$this->other = \array_filter($other, fn ($el) => ! empty($el));
}
// * Methods

View file

@ -2,9 +2,10 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Exception\DomainException;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
final readonly class Allergen implements Dto
{
@ -13,13 +14,13 @@ final readonly class Allergen implements Dto
public readonly string $label
) {
if ($code <= 0) {
throw new \DomainException(
throw new DomainException(
"Invalid code '{$code}' shall be greater than 0"
);
}
if (empty(mb_trim($label))) {
throw new \DomainException(
if (empty(trim($label))) {
throw new DomainException(
"Invalid label '{$label}' should not be empty"
);
}

View file

@ -2,9 +2,9 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
final readonly class IncoWeight implements Dto
{

View file

@ -2,9 +2,10 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Exception\DomainException;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
/**
* Represents a structured and parsed ingredient list.
@ -26,20 +27,20 @@ final readonly class Ingredients implements Dto
) {
foreach ($groups as $key => $lines) {
if (! is_array($lines)) {
throw new \DomainException(
throw new DomainException(
"Invalid ingredient group at key '{$key}': value must be an array of strings"
);
}
foreach ($lines as $i => $line) {
if (! is_string($line)) {
throw new \DomainException(
throw new DomainException(
"Invalid ingredient at group '{$key}', index '{$i}': value must be a string"
);
}
if (empty(mb_trim($line))) {
throw new \DomainException(
if (empty(trim($line))) {
throw new DomainException(
"Invalid ingredient at group '{$key}', index '{$i}': string should not be empty"
);
}

View file

@ -2,9 +2,10 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Exception\DomainException;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
/**
* Represents the Nutri-Score grade of a product as displayed on pack.
@ -19,7 +20,7 @@ final readonly class Nutriscore implements Dto
public readonly string $grade,
) {
if (! in_array($this->grade, self::VALID_GRADES, true)) {
throw new \DomainException(
throw new DomainException(
sprintf(
"Invalid Nutri-Score grade '%s': must be one of %s",
$grade,

View file

@ -2,16 +2,16 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
/**
* Si le type de portion est exact, seul la partie basse est utilisée,
* Si le type de portion est environ, la partie basse seule ou les deux peuvent
* etre utilisés.
*/
final readonly class PortionAmount implements \Stringable, Dto
final readonly class PortionAmount implements \JsonSerializable, \Stringable, Dto
{
/**
* Valeur minimale, ou exacte
@ -197,6 +197,6 @@ final readonly class PortionAmount implements \Stringable, Dto
$decimals = self::ALLOWED_DECIMAL_SIZE;
$format = number_format($value, $decimals, '.', '');
return mb_rtrim(mb_rtrim($format, '0'), '.');
return rtrim(rtrim($format, '0'), '.');
}
}

View file

@ -2,9 +2,9 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
enum PortionType: int implements Dto
{

View file

@ -2,9 +2,10 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Exception\DomainException;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
/**
* Represents the storage/conservation conditions of a product.
@ -16,8 +17,8 @@ final readonly class StorageCondition implements Dto
public function __construct(
public readonly string $instructions,
) {
if (empty(mb_trim($this->instructions))) {
throw new \DomainException(
if (empty(trim($this->instructions))) {
throw new DomainException(
'Invalid instructions: must not be empty'
);
}

View file

@ -2,25 +2,25 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
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;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoCompoId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoId;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\AcideGras;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\AcidesAmines;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\ApportsReference;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\ComposesSpeciaux;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\Energie;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\FruitLegumeNoix;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\GlucidesDetail;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\Identification;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\MacronutrimentsPortion;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\MacronutrimentsRef;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\Mineraux;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\NutriScore;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\PointsNutriScore;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\Vitamines;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiValueObject\IncoCompoId;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiValueObject\IncoId;
/**
* Agrégat racine regroupant toutes les sous-sections nutritionnelles d'une fiche INCO.

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo;
/**
* Base implementation for NutriDto.
@ -36,13 +36,13 @@ abstract readonly class AbstractIncoCompoPartDto implements IncoCompoPartDto
*/
public function toArray(): array
{
/** @var array<string, float|string|null> $result */
// Reflection on public properties of the concrete child class.
$ref = new \ReflectionObject($this);
$result = [];
foreach (get_object_vars($this) as $name => $value) {
if (\is_float($value) || \is_string($value) || $value === null) {
$result[(string) $name] = $value;
}
foreach ($ref->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
$name = $property->getName();
$result[$name] = $property->getValue($this);
}
return $result;

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class AcideGras extends AbstractIncoCompoPartDto
{

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class AcidesAmines extends AbstractIncoCompoPartDto
{

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class ApportsReference extends AbstractIncoCompoPartDto
{

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class ComposesSpeciaux extends AbstractIncoCompoPartDto
{

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class Energie extends AbstractIncoCompoPartDto
{

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class FruitLegumeNoix extends AbstractIncoCompoPartDto
{

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class GlucidesDetail extends AbstractIncoCompoPartDto
{

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class Identification extends AbstractIncoCompoPartDto
{

View file

@ -2,9 +2,9 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
/**
* Interface for nutritional DTOs that can provide units, human-readable labels,

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class MacronutrimentsPortion extends AbstractIncoCompoPartDto
{

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class MacronutrimentsRef extends AbstractIncoCompoPartDto
{

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class Mineraux extends AbstractIncoCompoPartDto
{

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class NutriScore extends AbstractIncoCompoPartDto
{

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class PointsNutriScore extends AbstractIncoCompoPartDto
{

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class Vitamines extends AbstractIncoCompoPartDto
{

View file

@ -2,10 +2,10 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoId;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiValueObject\IncoId;
/**
* Represents an Inco sheet resumed info

View file

@ -2,9 +2,9 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
/**
* A page of items with pagination metadata.

View file

@ -2,9 +2,9 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
final readonly class PaginationDto implements Dto
{

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Factory;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\PaginationDto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiResponse\ApiResponse;
use Uwil\ArcadiaGenesisPhpClient\Lib\Pagination;
use Uwil\ArcadiaGenesisPhpClient\Mapper\Mapper;
/**
* @implements DtoFactory<Mapper, \Uwil\ArcadiaGenesisPhpClient\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

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Factory;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\PageOf;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiResponse\ApiResponse;
use Uwil\ArcadiaGenesisPhpClient\Lib\Pagination;
use Uwil\ArcadiaGenesisPhpClient\Mapper\Mapper;
/**
* @template M of \Uwil\ArcadiaGenesisPhpClient\Mapper\Mapper
* @template D of \Uwil\ArcadiaGenesisPhpClient\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

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Factory;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Employee;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\PageOf;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiResponse\ApiResponse;
use Uwil\ArcadiaGenesisPhpClient\Lib\Pagination;
use Uwil\ArcadiaGenesisPhpClient\Mapper\EmployeeMapper;
use Uwil\ArcadiaGenesisPhpClient\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

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Factory;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\PageOf;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiResponse\ApiResponse;
use Uwil\ArcadiaGenesisPhpClient\Lib\Pagination;
use Uwil\ArcadiaGenesisPhpClient\Mapper\IncoCompoMapper;
use Uwil\ArcadiaGenesisPhpClient\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\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo|PageOf<\Uwil\ArcadiaGenesisPhpClient\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

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Factory;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\PageOf;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiResponse\ApiResponse;
use Uwil\ArcadiaGenesisPhpClient\Lib\Pagination;
use Uwil\ArcadiaGenesisPhpClient\Mapper\IncoMapper;
use Uwil\ArcadiaGenesisPhpClient\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

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Factory;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoShort;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\PageOf;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiResponse\ApiResponse;
use Uwil\ArcadiaGenesisPhpClient\Lib\Pagination;
use Uwil\ArcadiaGenesisPhpClient\Mapper\IncoShortMapper;
use Uwil\ArcadiaGenesisPhpClient\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\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoShort|PageOf<\Uwil\ArcadiaGenesisPhpClient\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

@ -0,0 +1,987 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiParser;
use Uwil\ArcadiaGenesisPhpClient\Exception\UnexpectedErrorException;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Brand;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\Additive;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\Allegations;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\Allergen;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\IncoWeight;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\Ingredients;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\Nutriscore;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\PortionAmount;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\PortionType;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\Inco\StorageCondition;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiValueObject\BrandCode;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiValueObject\IncoCompoId;
use Uwil\ArcadiaGenesisPhpClient\Utils\DateUtils;
use Uwil\ArcadiaGenesisPhpClient\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

@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiParser;
use Uwil\ArcadiaGenesisPhpClient\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

@ -0,0 +1,360 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiParser;
use Uwil\ArcadiaGenesisPhpClient\Exception\UnexpectedErrorException;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\AcideGras;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\AcidesAmines;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\ApportsReference;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\ComposesSpeciaux;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\Energie;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\FruitLegumeNoix;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\GlucidesDetail;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\Identification;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\MacronutrimentsPortion;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\MacronutrimentsRef;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\Mineraux;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\NutriScore;
use Uwil\ArcadiaGenesisPhpClient\Lib\ApiDto\Dto\IncoCompo\PointsNutriScore;
use Uwil\ArcadiaGenesisPhpClient\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

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

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\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

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\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

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

View file

@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiQueryBuilder;
use Uwil\ArcadiaGenesisPhpClient\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

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

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\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

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

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\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

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\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

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\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

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\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

@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiResponse;
use UnexpectedValueException;
use Uwil\ArcadiaGenesisPhpClient\Exception\InvalidApiResponse;
use Uwil\ArcadiaGenesisPhpClient\Utils\StringEncoder;
use Uwil\ArcadiaGenesisPhpClient\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

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

View file

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

View file

@ -2,9 +2,9 @@
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiSorter;
namespace Uwil\ArcadiaGenesisPhpClient\Lib\ApiValueObject;
enum EmployeeSortColumn implements SortColumn
enum EmployeeSortColum implements SortColumn
{
case arcadiaId;
case startDate;

Some files were not shown because too many files have changed in this diff Show more