diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..be8e9d2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + tests: + name: PHP ${{ matrix.php }} (${{ matrix.dependencies }} dependencies) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + # highest: Symfony 7.4 on PHP 8.2/8.3, Symfony 8 on PHP 8.4+ + php: ['8.2', '8.3', '8.4', '8.5'] + dependencies: [highest] + include: + # lowest: Symfony 6.4, PSR-7 v1.1, guzzlehttp/psr7 v2 + - php: '8.2' + dependencies: lowest + + steps: + - uses: actions/checkout@v5 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - run: composer validate --strict --no-check-lock + + - uses: ramsey/composer-install@v3 + with: + dependency-versions: ${{ matrix.dependencies }} + + - run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index bd8dd6a..4b45471 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ composer.lock # PHPUnit cache .phpunit.result.cache +.phpunit.cache/ diff --git a/.openapi-generator-ignore b/.openapi-generator-ignore index 7484ee5..81cb411 100644 --- a/.openapi-generator-ignore +++ b/.openapi-generator-ignore @@ -21,3 +21,11 @@ #docs/*.md # Then explicitly reverse the ignore rule for a single file: #!docs/README.md + +# Hand-maintained files: keep them when regenerating the SDK +.gitignore +.php-cs-fixer.dist.php +.travis.yml +README.md +composer.json +phpunit.xml.dist diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index af9cf39..ea83b73 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -12,7 +12,9 @@ ; $config = new PhpCsFixer\Config(); -return $config->setRules([ +return $config + ->setRiskyAllowed(true) + ->setRules([ '@PSR12' => true, 'phpdoc_order' => true, 'array_syntax' => [ 'syntax' => 'short' ], diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 667b815..0000000 --- a/.travis.yml +++ /dev/null @@ -1,8 +0,0 @@ -language: php -# Bionic environment has preinstalled PHP from 7.1 to 7.4 -# https://docs.travis-ci.com/user/reference/bionic/#php-support -dist: bionic -php: - - 7.4 -before_install: "composer install" -script: "vendor/bin/phpunit" diff --git a/README.md b/README.md index 2d7715d..3dbe1da 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ -# OpenAPIClient-php +# eConnect PSB PHP SDK -PHP SDK for econnect PSB (https://psb.econnect.eu/?urls.primaryName=V1) +PHP SDK for the eConnect Procurement Service Bus (PSB) API (https://psb.econnect.eu/?urls.primaryName=V1) ## Installation & Usage ### Requirements -PHP 8.2 and later. +- PHP 8.2 or later +- A [PSR-18](https://packagist.org/providers/psr/http-client-implementation) HTTP client with an [HTTPlug async](https://packagist.org/providers/php-http/async-client-implementation) implementation, such as Symfony HttpClient or Guzzle +- Compatible with Symfony 6.4, 7.x and 8.x (Symfony 8 itself requires PHP 8.4 or later) ### Composer @@ -22,23 +24,23 @@ To install the bindings via [Composer](https://getcomposer.org/), add the follow } ], "require": { - "QbilSoftware/econnect-psb-php": "@dev-main" + "qbil-software/econnect-psb-php": "dev-main" } } ``` -Then run `composer install` +Then run `composer install`. -Your project is free to choose the http client of your choice -Please require packages that will provide http client functionality: -https://packagist.org/providers/psr/http-client-implementation -https://packagist.org/providers/php-http/async-client-implementation -https://packagist.org/providers/psr/http-factory-implementation +Your project is free to choose the HTTP client of your choice. In a Symfony application, use Symfony HttpClient: -As an example: +``` +composer require symfony/http-client guzzlehttp/promises +``` + +Or use Guzzle 7 with its HTTPlug adapter: ``` -composer require guzzlehttp/guzzle php-http/guzzle7-adapter http-interop/http-factory-guzzle +composer require guzzlehttp/guzzle php-http/guzzle7-adapter ``` ### Manual Installation @@ -47,7 +49,7 @@ Download the files and include `autoload.php`: ```php setApiKey('Subscription-Key', 'YOUR_API_KEY'); -// Uncomment below to setup prefix (e.g. Bearer) for API key, if needed -// $config = EConnect\Psb\Configuration::getDefaultConfiguration()->setApiKeyPrefix('Subscription-Key', 'Bearer'); - -$config - ->setUsername("{username}") - ->setPassword("{password}"); - ->setHost("https://psb.econnect.eu") +$config = (new EConnect\Psb\Configuration()) + ->setHost('https://psb.econnect.eu') + ->setUsername('{username}') + ->setPassword('{password}') ->setApiKey('Subscription-Key', '{subscription key}'); - -$authN = new \EConnect\Psb\OidcAuthenticator( - \Http\Discovery\Psr18ClientDiscovery::find(), - \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(), - \Http\Discovery\Psr17FactoryDiscovery::findStreamFactory(), - new \Symfony\Component\Clock\NativeClock(), + +$authenticator = new EConnect\Psb\OidcAuthenticator( + Http\Discovery\Psr18ClientDiscovery::find(), + Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(), + Http\Discovery\Psr17FactoryDiscovery::findStreamFactory(), + new Symfony\Component\Clock\NativeClock(), $config, ); -$authN->login('{clientId}', '{clientSecret}'); -$apiInstance = new EConnect\Psb\Api\HookApi( - // If you want use custom http client, pass your client which implements `Psr\Http\Client\ClientInterface`. - // This is optional, `Psr18ClientDiscovery` will be used to find http client. For instance `GuzzleHttp\Client` implements that interface - \Http\Discovery\Psr18ClientDiscovery::find(), - $config -); +// Fetches an access token and stores it in $config. The token is cached and renewed +// shortly before it expires, so it is cheap to call this before every batch of API calls. +$authenticator->authenticate('{clientId}', '{clientSecret}'); + +// The HTTP client is optional: when omitted, it is discovered with `Psr18ClientDiscovery`. +$apiInstance = new EConnect\Psb\Api\HookApi(null, $config); try { $result = $apiInstance->getHookConfigs(); print_r($result); -} catch (Exception $e) { +} catch (EConnect\Psb\ApiException $e) { echo 'Exception when calling HookApi->getHookConfigs: ', $e->getMessage(), PHP_EOL; } +``` +### Symfony + +With `symfony/framework-bundle` and `symfony/http-client` installed, register the SDK as services, for example in `config/services.yaml`: + +```yaml +services: + EConnect\Psb\Configuration: + calls: + - setHost: ['%env(PSB_HOST)%'] + - setUsername: ['%env(PSB_USERNAME)%'] + - setPassword: ['%env(PSB_PASSWORD)%'] + - setApiKey: ['Subscription-Key', '%env(PSB_SUBSCRIPTION_KEY)%'] + + EConnect\Psb\OidcAuthenticator: + arguments: + $httpClient: '@psr18.http_client' + $requestFactory: '@psr18.http_client' + $streamFactory: '@psr18.http_client' + $clock: '@clock' + $config: '@EConnect\Psb\Configuration' + + EConnect\Psb\Api\: + resource: '%kernel.project_dir%/vendor/qbil-software/econnect-psb-php/lib/Api/' + autowire: true ``` +The API classes then use Symfony HttpClient for both synchronous and asynchronous requests. Inject `OidcAuthenticator` and the API classes you need into your own services, and call `authenticate()` before using the APIs. + ## API Endpoints -All URIs are relative to *http://localhost* +All URIs are relative to the configured host, e.g. *https://psb.econnect.eu* Class | Method | HTTP request | Description ------------ | ------------- | ------------- | ------------- @@ -112,6 +133,13 @@ Class | Method | HTTP request | Description *MeApi* | [**getUser**](docs/Api/MeApi.md#getuser) | **GET** /api/v1/me | Get user. *MeApi* | [**getUserParty**](docs/Api/MeApi.md#getuserparty) | **GET** /api/v1/me/party | Get parties. *PeppolApi* | [**getDeliveryOptions**](docs/Api/PeppolApi.md#getdeliveryoptions) | **GET** /api/v1/peppol/deliveryOption | Advanced recipient party lookup in Peppol. +*PeppolApi* | [**getPeppolConfig**](lib/Api/PeppolApi.php) | **GET** /api/v1/peppol/config | Get default Peppol configuration +*PeppolApi* | [**getPeppolParties**](lib/Api/PeppolApi.php) | **GET** /api/v1/peppol/config/party | Get your Peppol participants +*PeppolApi* | [**getPeppolPartyConfig**](lib/Api/PeppolApi.php) | **GET** /api/v1/peppol/config/party/{partyId} | Get participant Peppol configuration +*PeppolApi* | [**removePeppolPartyConfig**](lib/Api/PeppolApi.php) | **DELETE** /api/v1/peppol/config/party/{partyId} | Un register (Remove) participant in Peppol +*PeppolApi* | [**requestPartyPrepareToMigrate**](lib/Api/PeppolApi.php) | **GET** /api/v1/peppol/{partyId}/prepareToMigrate | Generates a migration key and registers it with the SML to enable participant transfer between SMPs +*PeppolApi* | [**setPeppolConfig**](lib/Api/PeppolApi.php) | **PUT** /api/v1/peppol/config | Add or update default Peppol configuration +*PeppolApi* | [**setPeppolPartyConfig**](lib/Api/PeppolApi.php) | **PUT** /api/v1/peppol/config/party/{partyId} | Register or update participant in Peppol *PurchaseInvoiceApi* | [**deletePurchaseInvoice**](docs/Api/PurchaseInvoiceApi.md#deletepurchaseinvoice) | **DELETE** /api/v1/{partyId}/purchaseInvoice/{documentId} | Delete purchase invoice. *PurchaseInvoiceApi* | [**downloadPurchaseInvoice**](docs/Api/PurchaseInvoiceApi.md#downloadpurchaseinvoice) | **GET** /api/v1/{partyId}/purchaseInvoice/{documentId}/download | Download purchase invoice. *PurchaseInvoiceApi* | [**getPurchaseInvoiceStatuses**](docs/Api/PurchaseInvoiceApi.md#getpurchaseinvoicestatuses) | **GET** /api/v1/{partyId}/purchaseInvoice/{documentId}/status | Get purchase invoice statuses (FOR DEBUGGING ONLY). @@ -140,6 +168,7 @@ Class | Method | HTTP request | Description ## Models +- [BusinessCardProperty](lib/Model/BusinessCardProperty.php) - [DeliveryOption](docs/Model/DeliveryOption.md) - [Document](docs/Model/Document.md) - [DocumentFamily](docs/Model/DocumentFamily.md) @@ -157,7 +186,12 @@ Class | Method | HTTP request | Description - [Party](docs/Model/Party.md) - [PartyPagedResult](docs/Model/PartyPagedResult.md) - [PartyPermissions](docs/Model/PartyPermissions.md) +- [PeppolBusinessCard](lib/Model/PeppolBusinessCard.php) +- [PeppolCapability](lib/Model/PeppolCapability.php) +- [PeppolConfig](lib/Model/PeppolConfig.php) - [PeppolMigrateConfig](docs/Model/PeppolMigrateConfig.md) +- [PeppolPartyConfig](lib/Model/PeppolPartyConfig.php) +- [PeppolPartyVerification](lib/Model/PeppolPartyVerification.php) - [PrepareToMigrateResponse](docs/Model/PrepareToMigrateResponse.md) - [ProcessId](docs/Model/ProcessId.md) - [TransportProtocol](docs/Model/TransportProtocol.md) @@ -189,9 +223,21 @@ To run the tests, use: ```bash composer install -vendor/bin/phpunit +composer test ``` +The `unit` test suite in `test/Unit` covers authentication, (de)serialization and request building, including a run on top of Symfony HttpClient. The `generated` suite in `test/Api` and `test/Model` contains the placeholders created by OpenAPI Generator. + +## Regenerating the SDK + +Hand-maintained files are listed in `.openapi-generator-ignore` and are kept when regenerating. The generated code also carries the following patches, which have to be re-applied after regenerating: + +- `lib/Api/*.php`: `createRequest()` always builds the request through the PSR-17 factories, and multipart requests set `Content-Type: multipart/form-data; boundary=...` from the `MultipartStream`. +- `lib/ObjectSerializer.php`: dates are detected with `\DateTimeInterface`, so `\DateTimeImmutable` values are serialized as well. +- `lib/DebugPlugin.php`: failed requests are logged as ` code message`. + +`lib/functions.php` provides `GuzzleHttp\Psr7\try_fopen()`, which the generated file uploads call but `guzzlehttp/psr7` 2.0 and later no longer ship. + ## Author techsupport@econnect.eu diff --git a/composer.json b/composer.json index 9708515..f6c2728 100644 --- a/composer.json +++ b/composer.json @@ -1,18 +1,26 @@ { "name": "qbil-software/econnect-psb-php", - "description": "No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)", + "description": "PHP SDK for the eConnect Procurement Service Bus (PSB) API", + "type": "library", "keywords": [ - "openapitools", - "openapi-generator", + "econnect", + "psb", + "peppol", + "e-invoicing", "openapi", "php", "sdk", "rest", - "api" + "api", + "symfony" ], - "homepage": "https://openapi-generator.tech", - "license": "unlicense", + "homepage": "https://github.com/QbilSoftware/econnect-psb-php", + "license": "MIT", "authors": [ + { + "name": "Qbil Software", + "homepage": "https://www.qbilsoftware.com" + }, { "name": "OpenAPI-Generator contributors", "homepage": "https://openapi-generator.tech" @@ -26,25 +34,27 @@ }, "require": { "php": "^8.2", - "ext-curl": "*", "ext-json": "*", "ext-mbstring": "*", - "guzzlehttp/psr7": "^1.8 || ^2.0", + "guzzlehttp/psr7": "^2.12.3 || ^3.0", "php-http/async-client-implementation": "^1.0", "php-http/client-common": "^2.4", - "php-http/discovery": "^1.14", + "php-http/discovery": "^1.15", "php-http/httplug": "^2.2", + "psr/clock": "^1.0", + "psr/http-client": "^1.0", "psr/http-client-implementation": "^1.0", "psr/http-factory": "^1.0", "psr/http-factory-implementation": "^1.0", - "psr/http-message": "^1.0", - "symfony/clock": "^7.2" + "psr/http-message": "^1.1 || ^2.0", + "symfony/clock": "^6.4 || ^7.0 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^8.0 || ^9.0", - "friendsofphp/php-cs-fixer": "^v3.7", - "guzzlehttp/guzzle": "^7.0", - "php-http/guzzle7-adapter": "^1.0" + "friendsofphp/php-cs-fixer": "^3.7", + "guzzlehttp/promises": "^2.0 || ^3.0", + "php-http/mock-client": "^1.6", + "phpunit/phpunit": "^11.5 || ^12.0 || ^13.0", + "symfony/http-client": "^6.4 || ^7.0 || ^8.0" }, "autoload": { "psr-4": { "EConnect\\Psb\\" : "lib/" }, @@ -52,5 +62,10 @@ }, "autoload-dev": { "psr-4": { "EConnect\\Psb\\Test\\" : "test/" } + }, + "scripts": { + "test": "phpunit", + "cs-check": "php-cs-fixer fix --dry-run --diff", + "cs-fix": "php-cs-fixer fix" } } diff --git a/lib/DebugPlugin.php b/lib/DebugPlugin.php index feae8ba..f764379 100644 --- a/lib/DebugPlugin.php +++ b/lib/DebugPlugin.php @@ -97,7 +97,7 @@ private function logError(RequestInterface $request, ClientExceptionInterface $e $protocol = $request->getProtocolVersion(); $error = $exception->getMessage(); $responseCode = $exception->getCode(); - \fprintf($this->output, '<%s HTTP/%s> %s %s', $methodAndPath, $responseCode, $error, $protocol); + \fprintf($this->output, '<%s HTTP/%s> %s %s', $methodAndPath, $protocol, $responseCode, $error); \fwrite($this->output, "\n"); } } \ No newline at end of file diff --git a/lib/ObjectSerializer.php b/lib/ObjectSerializer.php index acaa53a..3edadc4 100644 --- a/lib/ObjectSerializer.php +++ b/lib/ObjectSerializer.php @@ -71,7 +71,7 @@ public static function sanitizeForSerialization($data, $type = null, $format = n return $data; } - if ($data instanceof \DateTime) { + if ($data instanceof \DateTimeInterface) { return ($format === 'date') ? $data->format('Y-m-d') : $data->format(self::$dateTimeFormat); } @@ -243,7 +243,7 @@ public static function toQueryValue( } # Handle DateTime objects in query - if ($openApiType === "\\DateTime" && $value instanceof \DateTime) { + if ($openApiType === "\\DateTime" && $value instanceof \DateTimeInterface) { return ["{$paramName}" => $value->format(self::$dateTimeFormat)]; } @@ -353,13 +353,13 @@ public static function toFormValue($value) * If it's a datetime object, format it in ISO8601 * If it's a boolean, convert it to "true" or "false". * - * @param float|int|bool|\DateTime $value the value of the parameter + * @param float|int|bool|\DateTimeInterface $value the value of the parameter * * @return string the header string */ public static function toString($value) { - if ($value instanceof \DateTime) { // datetime in ISO8601 format + if ($value instanceof \DateTimeInterface) { // datetime in ISO8601 format return $value->format(self::$dateTimeFormat); } elseif (is_bool($value)) { return $value ? 'true' : 'false'; diff --git a/lib/OidcAuthenticator.php b/lib/OidcAuthenticator.php index f80de4d..98f7f53 100644 --- a/lib/OidcAuthenticator.php +++ b/lib/OidcAuthenticator.php @@ -5,12 +5,21 @@ namespace EConnect\Psb; use Psr\Clock\ClockInterface; +use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamFactoryInterface; class OidcAuthenticator { + /** + * Seconds before the reported expiry at which a token is renewed, so that + * requests in flight don't reach the API with an expired token. + */ + private const EXPIRY_LEEWAY = 60; + private \DateTimeImmutable $validUntil; public function __construct( @@ -27,7 +36,7 @@ public function authenticate(string $clientId, string $clientSecret): string return $this->config->getAccessToken(); } - $identityUrl = str_replace('psb', 'identity', $this->config->getHost()); + $identityUrl = rtrim(str_replace('psb', 'identity', $this->config->getHost()), '/'); $discoveryUrl = "{$identityUrl}/.well-known/openid-configuration"; // 1. Fetch OpenID Configuration @@ -35,12 +44,12 @@ public function authenticate(string $clientId, string $clientSecret): string ->requestFactory ->createRequest('GET', $discoveryUrl); - $discoveryResponse = $this->httpClient->sendRequest($discoveryRequest); - $metadata = json_decode((string) $discoveryResponse->getBody(), true, 512, JSON_THROW_ON_ERROR); + $discoveryResponse = $this->send($discoveryRequest); + $metadata = $this->decode($discoveryRequest, $discoveryResponse, 'OpenID discovery failed'); $tokenEndpoint = $metadata['token_endpoint'] ?? null; - if (!$tokenEndpoint) { + if (!is_string($tokenEndpoint) || $tokenEndpoint === '') { throw new ApiException( 'Missing token_endpoint in OpenID configuration.', $discoveryRequest, @@ -64,10 +73,10 @@ public function authenticate(string $clientId, string $clientSecret): string ->withHeader('Content-Type', 'application/x-www-form-urlencoded') ->withBody($this->streamFactory->createStream($formBody)); - $tokenResponse = $this->httpClient->sendRequest($tokenRequest); - $tokenData = json_decode((string) $tokenResponse->getBody(), true, 512, JSON_THROW_ON_ERROR); + $tokenResponse = $this->send($tokenRequest); + $tokenData = $this->decode($tokenRequest, $tokenResponse, 'OIDC login failed'); - if (empty($tokenData['access_token']) || empty($tokenData['expires_in'])) { + if (empty($tokenData['access_token']) || !is_string($tokenData['access_token']) || !is_numeric($tokenData['expires_in'] ?? null)) { throw new ApiException( 'OIDC login failed: missing access token or expiry.', $tokenRequest, @@ -76,9 +85,45 @@ public function authenticate(string $clientId, string $clientSecret): string } // 3. Store token - $this->validUntil = $this->clock->now()->add(new \DateInterval("PT{$tokenData['expires_in']}S")); + $lifetime = max(0, (int) $tokenData['expires_in'] - self::EXPIRY_LEEWAY); + $this->validUntil = $this->clock->now()->add(new \DateInterval("PT{$lifetime}S")); $this->config->setAccessToken($tokenData['access_token']); return $this->config->getAccessToken(); } + + private function send(RequestInterface $request): ResponseInterface + { + try { + return $this->httpClient->sendRequest($request); + } catch (ClientExceptionInterface $e) { + throw new ApiException("[{$e->getCode()}] {$e->getMessage()}", $request, null, $e); + } + } + + /** + * @return array + */ + private function decode(RequestInterface $request, ResponseInterface $response, string $error): array + { + $statusCode = $response->getStatusCode(); + $data = json_decode((string) $response->getBody(), true); + + if ($statusCode < 200 || $statusCode > 299) { + // OAuth 2.0 error responses describe the failure in `error_description` / `error` (RFC 6749, section 5.2) + $reason = $data['error_description'] ?? $data['error'] ?? null; + + throw new ApiException( + sprintf('%s: [%d] %s', $error, $statusCode, is_string($reason) ? $reason : $response->getReasonPhrase()), + $request, + $response + ); + } + + if (!is_array($data)) { + throw new ApiException("{$error}: response is not a JSON object.", $request, $response); + } + + return $data; + } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 485899a..5d2f969 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,18 +1,31 @@ - - - - ./lib/Api - ./lib/Model - - + - + + ./test/Unit + + ./test/Api ./test/Model + + + ./lib + + - + diff --git a/test/Unit/ApiClientTest.php b/test/Unit/ApiClientTest.php new file mode 100644 index 0000000..195d3f8 --- /dev/null +++ b/test/Unit/ApiClientTest.php @@ -0,0 +1,146 @@ +client = new Client(); + $this->config = (new Configuration()) + ->setHost('https://psb.example.test') + ->setApiKey('Subscription-Key', 'subscription-key') + ->setAccessToken('access-token'); + } + + public function testRequestCarriesAuthenticationAndDeserializesResponse(): void + { + $this->client->addResponse($this->jsonResponse(200, [[ + 'id' => 'hook-1', + 'name' => 'Default', + 'action' => 'https://example.test/hook', + 'createdOn' => '2026-01-02T03:04:05+00:00', + ]])); + + $hooks = $this->createApi(HookApi::class)->getHookConfigs(); + + self::assertCount(1, $hooks); + self::assertInstanceOf(Hook::class, $hooks[0]); + self::assertSame('hook-1', $hooks[0]->getId()); + + $request = $this->client->getLastRequest(); + self::assertSame('GET', $request->getMethod()); + self::assertSame('https://psb.example.test/api/v1/hook', (string) $request->getUri()); + self::assertSame('subscription-key', $request->getHeaderLine('Subscription-Key')); + self::assertSame('Bearer access-token', $request->getHeaderLine('Authorization')); + self::assertSame('application/json', $request->getHeaderLine('Accept')); + } + + public function testPathParametersAreEncodedAndBodyIsSerialized(): void + { + $this->client->addResponse($this->jsonResponse(200, [ + 'id' => 'hook-1', + 'name' => 'Default', + 'action' => 'https://example.test/hook', + ])); + + $hook = (new Hook()) + ->setId('hook-1') + ->setName('Default') + ->setAction('https://example.test/hook') + ->setCreatedOn(new \DateTimeImmutable('2026-01-02T03:04:05+00:00')); + + $this->createApi(HookApi::class)->subscribe('NL:KVK:12345678', $hook); + + $request = $this->client->getLastRequest(); + self::assertSame('PUT', $request->getMethod()); + self::assertSame('/api/v1/NL%3AKVK%3A12345678/hook', $request->getUri()->getPath()); + self::assertSame('application/json', $request->getHeaderLine('Content-Type')); + self::assertSame([ + 'id' => 'hook-1', + 'name' => 'Default', + 'action' => 'https://example.test/hook', + 'createdOn' => '2026-01-02T03:04:05+00:00', + ], json_decode((string) $request->getBody(), true, 512, JSON_THROW_ON_ERROR)); + } + + public function testFileUploadSendsMultipartBodyWithBoundary(): void + { + $this->client->addResponse($this->jsonResponse(200, ['id' => 'document-1'])); + + $file = tempnam(sys_get_temp_dir(), 'psb'); + file_put_contents($file, 'UBL'); + + try { + $document = $this->createApi(SalesInvoiceApi::class)->sendSalesInvoice('NL:KVK:12345678', new \SplFileObject($file), null, 'peppol'); + } finally { + unlink($file); + } + + self::assertInstanceOf(Document::class, $document); + self::assertSame('document-1', $document->getId()); + + $request = $this->client->getLastRequest(); + self::assertSame('POST', $request->getMethod()); + self::assertSame('channel=peppol', $request->getUri()->getQuery()); + self::assertMatchesRegularExpression('~^multipart/form-data; boundary=(\S+)$~', $request->getHeaderLine('Content-Type')); + + $boundary = substr($request->getHeaderLine('Content-Type'), strlen('multipart/form-data; boundary=')); + $body = (string) $request->getBody(); + self::assertStringStartsWith("--{$boundary}\r\n", $body); + self::assertStringContainsString('Content-Disposition: form-data; name="file"; filename="' . basename($file) . '"', $body); + self::assertStringContainsString('UBL', $body); + } + + public function testErrorResponsesAreConvertedToApiException(): void + { + $this->client->addResponse($this->jsonResponse(404, ['title' => 'Not Found'])); + + try { + $this->createApi(HookApi::class)->getHookConfigs(); + self::fail('Expected an ApiException'); + } catch (ApiException $e) { + self::assertSame(404, $e->getCode()); + self::assertSame('[404] Error connecting to the API (https://psb.example.test/api/v1/hook)', $e->getMessage()); + self::assertSame('{"title":"Not Found"}', $e->getResponseBody()); + } + } + + /** + * @template T of object + * + * @param class-string $class + * + * @return T + */ + private function createApi(string $class): object + { + $factory = new HttpFactory(); + + return new $class($this->client, $this->config, $this->client, $factory, $factory, $factory); + } + + private function jsonResponse(int $status, mixed $body): Response + { + return new Response($status, ['Content-Type' => 'application/json'], json_encode($body, JSON_THROW_ON_ERROR)); + } +} diff --git a/test/Unit/DebugPluginTest.php b/test/Unit/DebugPluginTest.php new file mode 100644 index 0000000..b8dd365 --- /dev/null +++ b/test/Unit/DebugPluginTest.php @@ -0,0 +1,65 @@ +output = fopen('php://memory', 'w+b'); + } + + protected function tearDown(): void + { + fclose($this->output); + } + + public function testLogsSuccessfulResponses(): void + { + $request = new Request('GET', 'https://psb.example.test/api/v1/me'); + + (new DebugPlugin($this->output)) + ->handleRequest($request, static fn () => new HttpFulfilledPromise(new Response(200)), static fn () => null) + ->wait(); + + self::assertSame(" 200\n", $this->readOutput()); + } + + public function testLogsFailedRequests(): void + { + $request = new Request('GET', 'https://psb.example.test/api/v1/me'); + $exception = new NetworkException('Connection refused', $request); + + $promise = (new DebugPlugin($this->output)) + ->handleRequest($request, static fn () => new HttpRejectedPromise($exception), static fn () => null); + + try { + $promise->wait(); + self::fail('Expected the exception to be rethrown'); + } catch (NetworkException $e) { + self::assertSame($exception, $e); + } + + self::assertSame(" 0 Connection refused\n", $this->readOutput()); + } + + private function readOutput(): string + { + rewind($this->output); + + return stream_get_contents($this->output); + } +} diff --git a/test/Unit/ObjectSerializerTest.php b/test/Unit/ObjectSerializerTest.php new file mode 100644 index 0000000..c4a221f --- /dev/null +++ b/test/Unit/ObjectSerializerTest.php @@ -0,0 +1,76 @@ +setId('hook-1') + ->setName('Default') + ->setAction('https://example.test/hook') + ->setCreatedOn(new \DateTimeImmutable('2026-01-02T03:04:05+00:00')); + + $serialized = ObjectSerializer::sanitizeForSerialization($hook); + + self::assertSame('2026-01-02T03:04:05+00:00', $serialized->createdOn); + } + + public function testDateTimeValuesAreFormattedForQueryAndPath(): void + { + $date = new \DateTimeImmutable('2026-01-02T03:04:05+00:00'); + + self::assertSame(['since' => '2026-01-02T03:04:05+00:00'], ObjectSerializer::toQueryValue($date, 'since', '\DateTime')); + self::assertSame('2026-01-02T03%3A04%3A05%2B00%3A00', ObjectSerializer::toPathValue($date)); + } + + public function testDeserializeHydratesModelsFromJson(): void + { + $json = json_encode([[ + 'id' => 'hook-1', + 'name' => 'Default', + 'action' => 'https://example.test/hook', + 'topics' => ['invoice-received'], + 'isActive' => true, + 'createdOn' => '2026-01-02T03:04:05.1234567+00:00', + ]], JSON_THROW_ON_ERROR); + + /** @var Hook[] $hooks */ + $hooks = ObjectSerializer::deserialize($json, '\EConnect\Psb\Model\Hook[]'); + + self::assertCount(1, $hooks); + self::assertInstanceOf(Hook::class, $hooks[0]); + self::assertSame('hook-1', $hooks[0]->getId()); + self::assertSame(['invoice-received'], $hooks[0]->getTopics()); + self::assertTrue($hooks[0]->getIsActive()); + self::assertSame('2026-01-02T03:04:05.123456+00:00', $hooks[0]->getCreatedOn()->format('Y-m-d\TH:i:s.uP')); + self::assertTrue($hooks[0]->valid()); + } + + public function testModelRoundTripsThroughJson(): void + { + $hook = new Hook([ + 'id' => 'hook-1', + 'name' => 'Default', + 'action' => 'https://example.test/hook', + 'is_active' => false, + 'created_on' => new \DateTime('2026-01-02T03:04:05+00:00'), + ]); + + /** @var Hook $copy */ + $copy = ObjectSerializer::deserialize(json_encode($hook, JSON_THROW_ON_ERROR), Hook::class); + + self::assertSame($hook->getId(), $copy->getId()); + self::assertSame($hook->getName(), $copy->getName()); + self::assertSame($hook->getAction(), $copy->getAction()); + self::assertFalse($copy->getIsActive()); + self::assertEquals($hook->getCreatedOn(), $copy->getCreatedOn()); + } +} diff --git a/test/Unit/OidcAuthenticatorTest.php b/test/Unit/OidcAuthenticatorTest.php new file mode 100644 index 0000000..85661f4 --- /dev/null +++ b/test/Unit/OidcAuthenticatorTest.php @@ -0,0 +1,173 @@ +client = new Client(); + $this->clock = new MockClock('2026-01-01 12:00:00'); + $this->config = (new Configuration()) + ->setHost('https://psb.example.test') + ->setUsername('jane') + ->setPassword('s3cr3t'); + + $factory = new HttpFactory(); + $this->authenticator = new OidcAuthenticator($this->client, $factory, $factory, $this->clock, $this->config); + } + + public function testAuthenticateFetchesTokenUsingDiscoveredEndpoint(): void + { + $this->queueSuccessfulLogin('token-1'); + + self::assertSame('token-1', $this->authenticator->authenticate('client-id', 'client-secret')); + self::assertSame('token-1', $this->config->getAccessToken()); + + [$discoveryRequest, $tokenRequest] = $this->client->getRequests(); + + self::assertSame('GET', $discoveryRequest->getMethod()); + self::assertSame('https://identity.example.test/.well-known/openid-configuration', (string) $discoveryRequest->getUri()); + + self::assertSame('POST', $tokenRequest->getMethod()); + self::assertSame('https://identity.example.test/connect/token', (string) $tokenRequest->getUri()); + self::assertSame('application/x-www-form-urlencoded', $tokenRequest->getHeaderLine('Content-Type')); + + parse_str((string) $tokenRequest->getBody(), $form); + self::assertSame([ + 'grant_type' => 'password', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'username' => 'jane', + 'password' => 's3cr3t', + 'scope' => 'ap', + ], $form); + } + + public function testAuthenticateIgnoresTrailingSlashInHost(): void + { + $this->config->setHost('https://psb.example.test/'); + $this->queueSuccessfulLogin('token-1'); + + $this->authenticator->authenticate('client-id', 'client-secret'); + + self::assertSame( + 'https://identity.example.test/.well-known/openid-configuration', + (string) $this->client->getRequests()[0]->getUri() + ); + } + + public function testAuthenticateReusesTokenUntilShortlyBeforeExpiry(): void + { + $this->queueSuccessfulLogin('token-1', 3600); + $this->queueSuccessfulLogin('token-2', 3600); + + $this->authenticator->authenticate('client-id', 'client-secret'); + + $this->clock->sleep(3600 - 61); + self::assertSame('token-1', $this->authenticator->authenticate('client-id', 'client-secret')); + self::assertCount(2, $this->client->getRequests()); + + // Within the leeway window the token is renewed instead of being sent while about to expire. + $this->clock->sleep(1); + self::assertSame('token-2', $this->authenticator->authenticate('client-id', 'client-secret')); + self::assertCount(4, $this->client->getRequests()); + } + + public function testAuthenticateReportsOAuthErrorDescription(): void + { + $this->client->addResponse($this->jsonResponse(200, ['token_endpoint' => 'https://identity.example.test/connect/token'])); + $this->client->addResponse($this->jsonResponse(400, ['error' => 'invalid_grant', 'error_description' => 'invalid_username_or_password'])); + + try { + $this->authenticator->authenticate('client-id', 'client-secret'); + self::fail('Expected an ApiException'); + } catch (ApiException $e) { + self::assertSame('OIDC login failed: [400] invalid_username_or_password', $e->getMessage()); + self::assertSame(400, $e->getCode()); + self::assertStringContainsString('invalid_grant', (string) $e->getResponseBody()); + } + + self::assertSame('', $this->config->getAccessToken()); + } + + public function testAuthenticateWrapsNonJsonErrorResponse(): void + { + $this->client->addResponse(new Response(502, ['Content-Type' => 'text/html'], 'Bad Gateway')); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('OpenID discovery failed: [502] Bad Gateway'); + + $this->authenticator->authenticate('client-id', 'client-secret'); + } + + public function testAuthenticateFailsWhenTokenEndpointIsMissing(): void + { + $this->client->addResponse($this->jsonResponse(200, ['issuer' => 'https://identity.example.test'])); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Missing token_endpoint in OpenID configuration.'); + + $this->authenticator->authenticate('client-id', 'client-secret'); + } + + public function testAuthenticateFailsWhenTokenResponseHasNoExpiry(): void + { + $this->client->addResponse($this->jsonResponse(200, ['token_endpoint' => 'https://identity.example.test/connect/token'])); + $this->client->addResponse($this->jsonResponse(200, ['access_token' => 'token-1'])); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('OIDC login failed: missing access token or expiry.'); + + $this->authenticator->authenticate('client-id', 'client-secret'); + } + + public function testAuthenticateWrapsTransportErrors(): void + { + $factory = new HttpFactory(); + $this->client->addException(new NetworkException('Connection refused', $factory->createRequest('GET', 'https://identity.example.test'))); + + try { + $this->authenticator->authenticate('client-id', 'client-secret'); + self::fail('Expected an ApiException'); + } catch (ApiException $e) { + self::assertSame('[0] Connection refused', $e->getMessage()); + self::assertInstanceOf(NetworkException::class, $e->getPrevious()); + } + } + + private function queueSuccessfulLogin(string $token, int $expiresIn = 3600): void + { + $this->client->addResponse($this->jsonResponse(200, ['token_endpoint' => 'https://identity.example.test/connect/token'])); + $this->client->addResponse($this->jsonResponse(200, [ + 'access_token' => $token, + 'expires_in' => $expiresIn, + 'token_type' => 'Bearer', + ])); + } + + /** + * @param array $body + */ + private function jsonResponse(int $status, array $body): Response + { + return new Response($status, ['Content-Type' => 'application/json'], json_encode($body, JSON_THROW_ON_ERROR)); + } +} diff --git a/test/Unit/SymfonyHttpClientTest.php b/test/Unit/SymfonyHttpClientTest.php new file mode 100644 index 0000000..b409aad --- /dev/null +++ b/test/Unit/SymfonyHttpClientTest.php @@ -0,0 +1,91 @@ +}> */ + private array $requests = []; + + public function testAuthenticatesAndCallsApiThroughSymfonyHttpClient(): void + { + $httpClient = $this->createHttpClient([ + new MockResponse('{"token_endpoint":"https://identity.example.test/connect/token"}'), + new MockResponse('{"access_token":"access-token","expires_in":3600}'), + new MockResponse('{"name":"jane"}'), + ]); + + $factory = new HttpFactory(); + $psr18Client = new Psr18Client($httpClient, $factory, $factory); + $config = (new Configuration()) + ->setHost('https://psb.example.test') + ->setUsername('jane') + ->setPassword('s3cr3t') + ->setApiKey('Subscription-Key', 'subscription-key'); + + (new OidcAuthenticator($psr18Client, $factory, $factory, new NativeClock(), $config)) + ->authenticate('client-id', 'client-secret'); + + $user = (new MeApi($psr18Client, $config, new HttplugClient($httpClient, $factory, $factory), $factory, $factory, $factory)) + ->getUser(); + + self::assertInstanceOf(User::class, $user); + self::assertSame('jane', $user->getName()); + + [, $url, $options] = $this->requests[2]; + self::assertSame('https://psb.example.test/api/v1/me', $url); + self::assertContains('Authorization: Bearer access-token', $options['headers']); + self::assertContains('Subscription-Key: subscription-key', $options['headers']); + } + + public function testAsyncRequestsResolveThroughHttplugClient(): void + { + $httpClient = $this->createHttpClient([ + new MockResponse('{"name":"jane"}'), + new MockResponse('{"title":"Unauthorized"}', ['http_code' => 401]), + ]); + + $factory = new HttpFactory(); + $api = new MeApi(new Psr18Client($httpClient, $factory, $factory), new Configuration(), new HttplugClient($httpClient, $factory, $factory), $factory, $factory, $factory); + + $user = $api->getUserAsync()->wait(); + + self::assertInstanceOf(User::class, $user); + self::assertSame('jane', $user->getName()); + + $this->expectException(ApiException::class); + $this->expectExceptionCode(401); + + $api->getUserAsync()->wait(); + } + + /** + * @param list $responses + */ + private function createHttpClient(array $responses): MockHttpClient + { + return new MockHttpClient(function (string $method, string $url, array $options) use (&$responses): MockResponse { + $this->requests[] = [$method, $url, $options]; + + return array_shift($responses); + }); + } +}