diff --git a/CHANGELOG.md b/CHANGELOG.md index a22ca2a..a3c7ce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,42 +1,69 @@ # Changelog -## [3.0.0] — unreleased +## [3.2.0] — unreleased + +### Added +- `Auth` — credentials as a transport-independent value: `Auth::token()`, `Auth::oauth2()`, `Auth::basic()`. The constructor is private, so the set of valid schemes is closed by construction; empty tokens and usernames are rejected on creation instead of surfacing as a 401 later +- `ZammadClient::create(string $url, Auth $auth, ?ConnectionConfig $config = null)` — one-line entry point for the default (Guzzle) transport +- `RequestHandler` accepts an optional `auth:` argument and applies the `Authorization` header per request + +### Changed +- Authentication no longer depends on the HTTP client supporting client-level default headers. It is applied by `RequestHandler` when composing each request, so any PSR-18 transport authenticates the same way — the hand-written `AuthMiddleware` that the docs previously required for Symfony's `Psr18Client` is obsolete +- `GuzzleClientFactory` takes `(string $url, Auth $auth, ?ConnectionConfig $config)` and no longer sets an `Authorization` default header on the Guzzle client. Its constructor was private before, so no caller is affected +- The `@deprecated` marker on `DTOInterface::id()`'s trait implementation is withdrawn. It announced a removal in 4.0 that was never planned, was never mentioned in the 3.0.0 changelog that introduced it, and contradicted both the interface — which requires the method and is not deprecated — and the library's own use of it in `Resource`. Reading `$dto->id` directly remains the more readable form +- Documentation: corrected examples that referenced `ZammadClient::withToken()` and the impersonation methods removed in 3.0.0 ([#168](https://github.com/zammad/zammad-api-client-php/issues/168)) +- **Fixed:** the Laravel override and custom-middleware examples in the custom HTTP client guide built a client with no credentials at all, so every request would have failed with HTTP 401. They now pass an `Auth` to the `RequestHandler` +- **Fixed:** `SymfonyBundle` registered the client with `ContainerBuilder::set()`, which stores a live instance rather than a definition. Instances set that way do not survive container compilation, so any service injecting `ZammadClient` failed the build with *"has a dependency on a non-existent service"* — the bundle never worked in a compiled container. It now registers a lazy `Definition` with `ZammadClient::create()` as its factory and an inlined `Auth` definition, so nothing is instantiated at compile time and the credentials are resolved from `%env(ZAMMAD_TOKEN)%` at runtime instead of being written into `var/cache` +- **Fixed:** the password parameters of `Auth::basic()` and `GuzzleClientFactory::withBasicAuth()` are marked `#[\SensitiveParameter]`. An empty username throws from inside `Auth::basic()`, so with PHP's default `zend.exception_ignore_args=0` the password appeared verbatim in the stack trace. The attribute is honoured from PHP 8.2 on and inert on 8.1 +- **Fixed:** the Symfony service IDs in the custom HTTP client guide. `@Symfony\Component\HttpClient\Psr18Client` and `@Nyholm\Psr7\Factory\Psr17Factory` are class names, not registered services, so copying that YAML failed the build. FrameworkBundle aliases `Psr\Http\Client\ClientInterface`, and the `nyholm/psr7` Flex recipe registers `nyholm.psr7.psr17_factory` + +`GuzzleClientFactory::withToken()` / `withOAuth2()` / `withBasicAuth()` keep their signatures and now delegate to `Auth`. They remain supported shorthand for the explicit-`Auth` constructor and are not scheduled for removal; `ZammadClient::create()` is simply the recommended entry point for new code. + +**One behavioural change to be aware of:** an empty or whitespace-only token (or username) is now rejected with an `InvalidArgumentException` at construction, where it previously produced a client that failed with HTTP 401 on its first request. Code that builds a client from an unset environment variable therefore fails earlier and more clearly than before. Both framework bridges resolve the client lazily, so this surfaces the first time something asks the container for it, not while the container is built: Laravel falls back to an empty token (`env('ZAMMAD_TOKEN', '')`) and throws `InvalidArgumentException`, while the Symfony bundle falls back to `%env(ZAMMAD_TOKEN)%` and throws `EnvNotFoundException` when the variable is unset, or `InvalidArgumentException` when it is set but empty. The Symfony bundle only became lazy in this release — see the fix above; before it, an empty token made `cache:clear` (and with it `composer install`) fail outright. The bundles are still covered by neither tests nor static analysis ([#169](https://github.com/zammad/zammad-api-client-php/issues/169)); the bundle change above was verified by compiling and dumping a container against symfony/dependency-injection 7.4 outside this repository. + +## [3.1.0] — 2026-10-02 + +### Added +- `RoleRepository` / `RoleDTO` for `/api/v1/roles`, accessible via `$client->role()` — resolve role names to the IDs needed for `UserDTO::$role_ids`; `permission_ids`, `group_ids` and `default_at_signup` are writable on create and update ([#167](https://github.com/zammad/zammad-api-client-php/pull/167)) + +### Changed +- Documentation: README restructured for newcomers (install / Packagist section), cookbook setup instructions and a broken personal access token link fixed + +## [3.0.0] — 2026-09-11 ### Added -- `RoleRepository` / `RoleDTO` for `/api/v1/roles`, accessible via `$client->role()` — resolve role names to the IDs needed for `UserDTO::$role_ids`; `permission_ids` and `group_ids` are writable on create and update - PSR-18 / PSR-17 compliant HTTP layer (`RequestHandler`, `RetryAfterMiddleware`) -- Typed DTOs for all 11 resources (`Ticket`, `User`, `Organization`, `Group`, `Role`, `TicketArticle`, `TicketState`, `TicketPriority`, `Tag`, `TextModule`, `Link`) +- Typed DTOs for all 10 resources (`Ticket`, `User`, `Organization`, `Group`, `TicketArticle`, `TicketState`, `TicketPriority`, `Tag`, `TextModule`, `Link`) - Repository pattern with generator-based pagination (`AbstractRepository`) - `patch()` method for partial updates via `array` or `TicketUpdateDTO` -- Proper exception hierarchy: `AuthenticationException`, `NotFoundException`, `ValidationException`, `RateLimitException`, `ServerErrorException`, `NetworkException` -- `ZammadClient::withToken()` / `withOAuth2()` / `withBasicAuth()` factory methods +- Proper exception hierarchy: `AuthenticationException`, `ForbiddenException`, `NotFoundException`, `BadRequestException`, `ValidationException`, `RateLimitException`, `ServerErrorException`, `NetworkException` +- `GuzzleClientFactory::withToken()` / `withOAuth2()` / `withBasicAuth()` factory methods (namespace `ZammadAPIClient\Factory`) - PSR-3 Logger injection - OpenAPI schema validation in integration tests - Framework bridges for Laravel and Symfony - `TicketArticleType` enum for article channel types -- `ClientFactoryInterface` interface + `GuzzleClientFactory` — `createHandler()` baut den `RequestHandler` -- `ImpersonationHandler` — stateless decorator fur API-Impersonation -- `ImpersonationHandler` — scoped via `new ZammadClient(new ImpersonationHandler($handler, $userId))` +- `ClientFactoryInterface` interface + `GuzzleClientFactory` — `createHandler()` builds the `RequestHandler` +- `ImpersonationHandler` — stateless decorator for API impersonation, scoped via `new ZammadClient(new ImpersonationHandler($handler, $userId))` ### Changed - **Breaking:** PHP >= 8.1 required -- **Breaking:** `ZammadClient::withToken()` / `withOAuth2()` / `withBasicAuth()` → `new ZammadClient(GuzzleClientFactory::with*())` (Namespace `ZammadAPIClient\Factory`). +- **Breaking:** `ZammadClient::withToken()` / `withOAuth2()` / `withBasicAuth()` → `new ZammadClient(GuzzleClientFactory::with*())` (namespace `ZammadAPIClient\Factory`). Non-Guzzle via `new ZammadClient(new RequestHandler($psr18Client, $psr17Factory, $url))`. -- **Breaking:** `ZammadClient::withClient()` entfernt; `ZammadClient` constructor akzeptiert `RequestHandlerInterface|ClientFactoryInterface` -- **Breaking:** `ZammadClient::setOnBehalfOfUser()` / `unsetOnBehalfOfUser()` / `onBehalfOf()` / `performOnBehalfOf()` entfernt. +- **Breaking:** `ZammadClient::withClient()` removed; the `ZammadClient` constructor accepts `RequestHandlerInterface|ClientFactoryInterface` +- **Breaking:** `ZammadClient::setOnBehalfOfUser()` / `unsetOnBehalfOfUser()` / `onBehalfOf()` / `performOnBehalfOf()` removed. Impersonation via `new ZammadClient(new ImpersonationHandler($handler, $userId))`. -- **Breaking:** `RequestHandlerInterface::setOnBehalfOfUser()` / `getOnBehalfOfUser()` entfernt. - `RequestHandler` halt keinen Impersonation-State mehr. -- **Breaking:** `RequestHandlerInterface::getRaw()` Signatur um `$headers`-Parameter erweitert +- **Breaking:** `RequestHandlerInterface::setOnBehalfOfUser()` / `getOnBehalfOfUser()` removed. + `RequestHandler` no longer holds any impersonation state. +- **Breaking:** `RequestHandlerInterface::getRaw()` signature extended by a `$headers` parameter - **Breaking:** `Client` class replaced by `ZammadClient` with repository accessors - **Breaking:** Array return values replaced by typed DTOs -- **Breaking:** Guzzle used as default transport; `withClient()` supports any PSR-18 client -- `getListKey()` in Repository-Klassen nicht mehr abstrakt; Default ist `$this->resourcePath` +- **Breaking:** Guzzle used as default transport; any PSR-18 client can be injected via `RequestHandler` +- `getListKey()` in repository classes is no longer abstract; it defaults to `$this->resourcePath` - Typed repository accessors (`$client->ticket()`, `$client->user()`, …) are the recommended public API; the underlying `repo()` method is `@internal`. ### Removed -- `ZammadClient::aliasMap()`, `resolveAlias()`, `__call()` — deprecated in v3.0, jetzt entfernt -- Shared mutable Impersonation-State aus `RequestHandler` entfernt +- `ZammadClient::aliasMap()`, `resolveAlias()`, `__call()` — deprecated in v3.0, now removed +- Shared mutable impersonation state in `RequestHandler` --- diff --git a/README.md b/README.md index 595a6b3..9af345b 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,10 @@ resolve `ZammadClient` from the container instead of constructing it by hand. ```php require __DIR__ . '/vendor/autoload.php'; // skip if your framework autoloads +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\ZammadClient; -$client = ZammadClient::withToken('https://zammad.example', 'your-token'); +$client = ZammadClient::create('https://zammad.example', Auth::token('your-token')); ``` **3. Try it** — fetch, create, update and search tickets: @@ -86,10 +87,11 @@ Repositories are accessed via typed accessors: `$client->ticket()`, `$client->us ### Connecting ```php +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\ZammadClient; -// ZammadClient normalizes the URL — /api/v1 is appended automatically -$client = ZammadClient::withToken('https://zammad.example', 'your-token'); +// The URL is normalized — /api/v1 is appended automatically +$client = ZammadClient::create('https://zammad.example', Auth::token('your-token')); ``` ### Fetching @@ -361,27 +363,49 @@ For user and organization CRUD examples, refer to the integration tests in [`tes ## Authentication +Credentials are a value of their own. `Auth` has a private constructor, so the +only way to build one is through the three named constructors below — an empty +token or username is rejected right there instead of surfacing as a 401 later. + ```php +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\ConnectionConfig; +use ZammadAPIClient\ZammadClient; + // Token — sends Authorization: Token token=your-token (Zammad personal access token) -ZammadClient::withToken($url, 'your-token'); +$client = ZammadClient::create($url, Auth::token('your-token')); // OAuth2 — sends Authorization: Bearer your-oauth-token (OAuth2 access token) -ZammadClient::withOAuth2($url, 'your-oauth-token'); +$client = ZammadClient::create($url, Auth::oauth2('your-oauth-token')); // Basic Auth — sends Authorization: Basic base64(user:pass) -ZammadClient::withBasicAuth($url, 'admin@example.com', 'test'); +$client = ZammadClient::create($url, Auth::basic('admin@example.com', 'test')); // Options -ZammadClient::withToken($url, 'your-token', +$client = ZammadClient::create($url, Auth::token('your-token'), new ConnectionConfig(verifySsl: false, maxRetries: 5), ); // Pass a PSR-3 Logger to log HTTP requests and retries -ZammadClient::withToken($url, 'your-token', +$client = ZammadClient::create($url, Auth::token('your-token'), new ConnectionConfig(logger: $myLogger), ); ``` +The credential is applied per request by the transport layer, so it works the +same with any PSR-18 client. Guzzle is only the default — to use another one, +build the handler yourself: + +```php +use ZammadAPIClient\Core\Transport\RequestHandler; + +$handler = new RequestHandler($psr18Client, $psr17Factory, $url, auth: Auth::token('your-token')); +$client = new ZammadClient($handler); +``` + +Runnable example: [`examples/cookbook/07-custom-http-client.php`](examples/cookbook/07-custom-http-client.php). +Container wiring and security notes: [Custom HTTP client](docs/custom-http-client.md). + | ConnectionConfig property | Type | Default | Description | |---------------------------|------|---------|-------------| | `maxRetries` | `int` | `3` | Number of retries on HTTP 429 before throwing `RateLimitException` | @@ -396,8 +420,8 @@ Prefer to resolve `ZammadClient` from your framework's container instead of constructing it by hand? Bridges are provided for Laravel and Symfony — configure credentials once, then inject the same shared client everywhere. -Full runnable setups: [`examples/cookbook/07-laravel.php`](examples/cookbook/07-laravel.php) -and [`examples/cookbook/08-symfony.php`](examples/cookbook/08-symfony.php). +Full runnable setups: [`examples/cookbook/08-laravel.php`](examples/cookbook/08-laravel.php) +and [`examples/cookbook/09-symfony.php`](examples/cookbook/09-symfony.php). ### Laravel @@ -705,18 +729,31 @@ $client->ticket()->patch(42, new TicketUpdateDTO( ## Impersonation +Impersonation is scoped to its own client instance — wrap the handler in an +`ImpersonationHandler`. It decorates the handler you hand it, so the credentials +of the original client carry over; the only thing it adds is the `From` header. +That original client keeps acting as the authenticated user, so there is no state +to reset and nothing can leak between calls. + ```php -// Temporary — auto-cleanup via finally -// Accepts user ID (int), login, or email (string) -$client->performOnBehalfOf(1, fn() => $client->ticket()->find(42)); -$client->performOnBehalfOf('agent@example.com', fn() => $client->ticket()->find(42)); - -// Persistent — same parameter types -$client->setOnBehalfOfUser(1); -// ... all subsequent requests act as user #1 ... -$client->unsetOnBehalfOfUser(); +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\Transport\ImpersonationHandler; +use ZammadAPIClient\ZammadClient; + +$client = ZammadClient::create('https://zammad.example', Auth::token('your-token')); + +// Accepts user ID (int), login, or email (string); no credentials needed here — +// they come from $client's handler +$asUser = new ZammadClient(new ImpersonationHandler($client->getHandler(), 1)); +$ticket = $asUser->ticket()->find(42); + +$asAgent = new ZammadClient(new ImpersonationHandler($client->getHandler(), 'agent@example.com')); + +// $client itself is unaffected ``` +A runnable recipe is in [`examples/cookbook/05-impersonation.php`](examples/cookbook/05-impersonation.php). + ## Development ```bash @@ -752,7 +789,7 @@ v2 reference documentation is preserved in [docs/v2-reference.md](docs/v2-refere | v2 | v3 | |----|----| -| `new Client(['url' => ..., 'http_token' => ...])` | `ZammadClient::withToken($url, ...)` | +| `new Client(['url' => ..., 'http_token' => ...])` | `ZammadClient::create($url, Auth::token(...))` | | `$client->resource(TICKET)->get(1)` | `$client->ticket()->find(1)` | | `$ticket->getValue('title')` | `$ticket->title` | | `$ticket->getValues()` | `$ticket->toArray()` | diff --git a/docs/alternative-clients.md b/docs/alternative-clients.md deleted file mode 100644 index 186ddfc..0000000 --- a/docs/alternative-clients.md +++ /dev/null @@ -1,144 +0,0 @@ -# Using an Alternative PSR-18 HTTP Client - -The `ZammadClient` constructor accepts any PSR-18 `ClientInterface` implementation via `RequestHandlerInterface`. The factory methods (`withToken()`, `withOAuth2()`, `withBasicAuth()`) use Guzzle by default for convenience, but you can inject any PSR-18 client directly. - -Rate-limit retry (HTTP 429) is handled automatically by `RequestHandler` — no manual wiring needed. - -## Symfony HttpClient - -Requires `composer require symfony/http-client nyholm/psr7` (or `guzzlehttp/psr7` for PSR-17 factories). - -```php -use Symfony\Component\HttpClient\HttpClient; -use Symfony\Component\HttpClient\Psr18Client; -use GuzzleHttp\Psr7\HttpFactory; -use ZammadAPIClient\Core\RequestHandler; -use ZammadAPIClient\ZammadClient; - -$handler = new RequestHandler( - new Psr18Client(HttpClient::create([ - 'timeout' => 30, - 'max_duration' => 30, - 'verify_peer' => true, - 'verify_host' => true, - ])), - new HttpFactory(), - 'https://zammad.example', - maxRetries: 3, -); - -$client = new ZammadClient($handler); -``` - -### With Authentication Headers - -Symfony's `Psr18Client` does not accept global default headers. Instead, configure them on the `RequestHandler` by passing them via `$options` on each `request()` call, or use a PSR-18 middleware to inject the `Authorization` header. - -**Option A — Inject headers via a PSR-18 decorator middleware:** - -```php -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; -use Symfony\Component\HttpClient\HttpClient; -use Symfony\Component\HttpClient\Psr18Client; - -final class AuthMiddleware implements ClientInterface -{ - public function __construct( - private ClientInterface $next, - private string $token, - ) {} - - public function sendRequest(RequestInterface $request): ResponseInterface - { - return $this->next->sendRequest( - $request->withHeader('Authorization', "Token token={$this->token}") - ); - } -} - -$http = new AuthMiddleware( - new Psr18Client(HttpClient::create(['timeout' => 30])), - 'your-zammad-api-token', -); - -// Rate-limit retry is handled automatically by RequestHandler -$handler = new RequestHandler($http, new HttpFactory(), 'https://zammad.example'); -``` - -**Option B — Build the Guzzle client with auth headers, then swap to Symfony later:** - -If you're migrating away from Guzzle, start with `ZammadClient::withToken()` and refactor step by step. - -## Laravel - -The `LaravelServiceProvider` binds `ZammadClient` as a singleton. To use Symfony instead, override the binding in your `AppServiceProvider`: - -```php -use Symfony\Component\HttpClient\HttpClient; -use Symfony\Component\HttpClient\Psr18Client; -use GuzzleHttp\Psr7\HttpFactory; -use ZammadAPIClient\Core\RequestHandler; -use ZammadAPIClient\ZammadClient; - -class AppServiceProvider extends ServiceProvider -{ - public function register(): void - { - $this->app->singleton(ZammadClient::class, function () { - $handler = new RequestHandler( - new Psr18Client(HttpClient::create([ - 'timeout' => config('zammad.timeout', 30), - ])), - new HttpFactory(), - config('zammad.url'), - maxRetries: 3, - ); - return new ZammadClient($handler); - }); - } -} -``` - -## Symfony - -The `SymfonyBundle` can be extended or replaced entirely by defining your own service definition: - -```yaml -# config/services.yaml -services: - ZammadAPIClient\ZammadClient: - factory: ['ZammadAPIClient\ZammadClient', 'withToken'] - arguments: - $url: '%env(ZAMMAD_URL)%' - $token: '%env(ZAMMAD_TOKEN)%' - - # Or with a custom PSR-18 stack: - app.zammad_client: - class: ZammadAPIClient\ZammadClient - arguments: - - '@app.zammad_request_handler' -``` - -## Custom PSR-18 Middleware Stack - -`RequestHandler` internally applies rate-limit retry via `RetryAfterMiddleware`. You only need to compose your own middleware around the base client — retry behavior is added automatically (set `maxRetries: 0` to disable). - -```php -$http = new LoggingMiddleware( - new MetricsMiddleware( - new Psr18Client(), - ), -); - -// Rate-limit retry is applied internally by RequestHandler -$handler = new RequestHandler($http, new HttpFactory(), 'https://zammad.example', maxRetries: 5); -``` - -## Important Security Notes - -- **Configure timeouts**: Always set `timeout` and connection timeout on your PSR-18 client. The factory methods default to 30s/10s respectively. -- **Verify TLS**: Do not disable TLS verification in production. The factory methods default to `verifySsl = true`. -- **Redirects**: The factory methods disable redirect following (`allow_redirects = false`). If using your own PSR-18 client, configure redirect behavior carefully — following redirects may leak the `Authorization` header to unintended hosts. -- **Debug logging**: Use a PSR-3 logger via `ConnectionConfig`. Never enable raw HTTP debug output in production — it will expose API tokens in log files. diff --git a/docs/custom-http-client.md b/docs/custom-http-client.md new file mode 100644 index 0000000..68f7ead --- /dev/null +++ b/docs/custom-http-client.md @@ -0,0 +1,164 @@ +# Using a Custom HTTP Client + +The `ZammadClient` constructor accepts any PSR-18 `ClientInterface` implementation via `RequestHandlerInterface`. `ZammadClient::create()` uses Guzzle for convenience, but nothing is lost by building the handler yourself: credentials, retry behaviour and logging are configured the same way either side. + +Rate-limit retry (HTTP 429) is handled automatically by `RequestHandler` — no manual wiring needed. + +A runnable version of the setup below is in [`examples/cookbook/07-custom-http-client.php`](../examples/cookbook/07-custom-http-client.php). + +## Symfony HttpClient + +Requires `composer require symfony/http-client nyholm/psr7`. Any PSR-17 factory works; `guzzlehttp/psr7` is already installed as a dependency if you prefer it. + +```php +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\HttpClient\Psr18Client; +use Nyholm\Psr7\Factory\Psr17Factory; +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\Transport\RequestHandler; +use ZammadAPIClient\ZammadClient; + +$handler = new RequestHandler( + new Psr18Client(HttpClient::create([ + 'timeout' => 30, + 'max_duration' => 30, + 'verify_peer' => true, + 'verify_host' => true, + ])), + new Psr17Factory(), + 'https://zammad.example', + maxRetries: 3, + auth: Auth::token('your-zammad-api-token'), +); + +$client = new ZammadClient($handler); +``` + +### Authentication + +Pass an `Auth` to the `RequestHandler`, as above. It applies the `Authorization` +header to each outgoing request, so it does not rely on the PSR-18 client +supporting client-level default headers — PSR-18 has no such concept, and +Symfony's `Psr18Client` does not offer one. + +`Auth::token()`, `Auth::oauth2()` and `Auth::basic()` are the available schemes. +If you need credentials that change at runtime (an OAuth2 token being refreshed, +say), wrap your PSR-18 client in a decorator that sets the header per request and +leave `auth` unset. + +If you're migrating away from Guzzle, start with `ZammadClient::create()` and +refactor step by step — the handler built above supports exactly the same options. + +## Laravel + +The `LaravelServiceProvider` binds `ZammadClient` as a singleton. To use Symfony instead, override the binding in your `AppServiceProvider`: + +```php +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\HttpClient\Psr18Client; +use Nyholm\Psr7\Factory\Psr17Factory; +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\Transport\RequestHandler; +use ZammadAPIClient\ZammadClient; + +class AppServiceProvider extends ServiceProvider +{ + public function register(): void + { + $this->app->singleton(ZammadClient::class, function () { + $handler = new RequestHandler( + new Psr18Client(HttpClient::create([ + 'timeout' => config('zammad.timeout', 30), + ])), + new Psr17Factory(), + config('zammad.url'), + maxRetries: 3, + auth: Auth::token(config('zammad.token')), + ); + return new ZammadClient($handler); + }); + } +} +``` + +## Symfony + +The `SymfonyBundle` can be extended or replaced entirely by defining your own service definition: + +```yaml +# config/services.yaml +services: + app.zammad_auth: + class: ZammadAPIClient\Core\Auth + factory: ['ZammadAPIClient\Core\Auth', 'token'] + arguments: + $token: '%env(ZAMMAD_TOKEN)%' + + ZammadAPIClient\ZammadClient: + factory: ['ZammadAPIClient\ZammadClient', 'create'] + arguments: + $url: '%env(ZAMMAD_URL)%' + $auth: '@app.zammad_auth' + + # Or with a custom PSR-18 stack: + app.zammad_request_handler: + class: ZammadAPIClient\Core\Transport\RequestHandler + arguments: + $httpClient: '@Psr\Http\Client\ClientInterface' + $factory: '@nyholm.psr7.psr17_factory' + $baseUrl: '%env(ZAMMAD_URL)%' + $auth: '@app.zammad_auth' + + app.zammad_client: + class: ZammadAPIClient\ZammadClient + arguments: + - '@app.zammad_request_handler' +``` + +The two service IDs in the custom stack are the ones Symfony actually registers: +FrameworkBundle aliases `Psr\Http\Client\ClientInterface` to its `psr18.http_client` +service as soon as `symfony/http-client` is installed, and `nyholm.psr7.psr17_factory` +comes from the `nyholm/psr7` Flex recipe. The class names are *not* service IDs — the +package itself ships no DI configuration. If you installed it without Flex, declare the +factory yourself: + +```yaml + Nyholm\Psr7\Factory\Psr17Factory: ~ +``` + +## Custom PSR-18 Middleware Stack + +`RequestHandler` internally applies rate-limit retry via `RetryAfterMiddleware`. You only need to compose your own middleware around the base client — retry behavior is added automatically (set `maxRetries: 0` to disable). + +`LoggingMiddleware` and `MetricsMiddleware` below stand in for your own decorators. + +```php +use Nyholm\Psr7\Factory\Psr17Factory; +use Symfony\Component\HttpClient\Psr18Client; +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\Transport\RequestHandler; + +$http = new LoggingMiddleware( + new MetricsMiddleware( + new Psr18Client(), + ), +); + +// Rate-limit retry is applied internally by RequestHandler +$handler = new RequestHandler( + $http, + new Psr17Factory(), + 'https://zammad.example', + maxRetries: 5, + auth: Auth::token('your-zammad-api-token'), +); +``` + +## Important Security Notes + +Building the handler yourself means the defaults `ZammadClient::create()` applies are no longer applied for you. Each of these is now your responsibility: + +- **Configure timeouts**: Always set a request and a connection timeout on your PSR-18 client. `ZammadClient::create()` defaults to 30s and 10s (`ConnectionConfig::$timeout` / `$connectTimeout`). +- **Verify TLS**: Do not disable TLS verification in production. `ZammadClient::create()` defaults to `verifySsl = true`. +- **Redirects**: `ZammadClient::create()` disables redirect following (`allow_redirects = false`). Configure your own client carefully — following redirects may leak the `Authorization` header to unintended hosts. +- **Debug logging**: Pass a PSR-3 logger as the `logger:` argument of `RequestHandler` (on the `create()` path it comes from `ConnectionConfig::$logger`). Never enable raw HTTP debug output in production — it will expose API tokens in log files. diff --git a/docs/migration-v3-examples.md b/docs/migration-v3-examples.md index 02d55ef..505ba5a 100644 --- a/docs/migration-v3-examples.md +++ b/docs/migration-v3-examples.md @@ -14,11 +14,12 @@ $client = new Client([ **v3:** ```php +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\ZammadClient; -$client = ZammadClient::withToken( +$client = ZammadClient::create( 'https://zammad.example', - 'your-token', + Auth::token('your-token'), ); ``` @@ -35,9 +36,9 @@ new Client(['url' => $url, 'username' => '...', 'password' => '...']); // Basic **v3:** ```php -ZammadClient::withToken($url, '...'); // Token -ZammadClient::withOAuth2($url, '...'); // OAuth2 -ZammadClient::withBasicAuth($url, '...', '...'); // Basic +ZammadClient::create($url, Auth::token('...')); // Token +ZammadClient::create($url, Auth::oauth2('...')); // OAuth2 +ZammadClient::create($url, Auth::basic('...', '...')); // Basic ``` --- @@ -342,18 +343,17 @@ $client->unsetOnBehalfOfUser(); **v3:** ```php -// Temporary (auto-cleanup on callback return or exception) -$client->performOnBehalfOf(1, function () use ($client) { - $client->ticket()->find(42); -}); +use ZammadAPIClient\Core\Transport\ImpersonationHandler; -// Persistent -$client->setOnBehalfOfUser(1); -// ... API calls ... -$client->unsetOnBehalfOfUser(); +$asUser = new ZammadClient(new ImpersonationHandler($client->getHandler(), 1)); +$asUser->ticket()->find(42); + +// $client itself keeps acting as the authenticated user ``` -> **Breaking:** v2 used a username string (`'myuser'`). v3 requires a **numeric user ID**. Use `$client->user()->search('login:myuser')` to resolve a username to an ID if needed. +> **Breaking:** v2 toggled impersonation on the client itself, so the state was global and had to be reset. v3 scopes it to a separate client instance — there is nothing to unset and nothing can leak between calls. A user ID, login or email all work (`int|string`). +> +> No credentials are passed to `ImpersonationHandler`: it decorates `$client`'s existing handler and inherits its authentication, adding only the `From` header. --- diff --git a/docs/migration-v3.md b/docs/migration-v3.md index 484d0fa..113c81a 100644 --- a/docs/migration-v3.md +++ b/docs/migration-v3.md @@ -4,7 +4,7 @@ | v2 | v3 | |---|---| -| `new Client(['url' => ..., 'http_token' => ...])` | `ZammadClient::withToken($url, $token)` | +| `new Client(['url' => ..., 'http_token' => ...])` | `ZammadClient::create($url, Auth::token($token))` | | `$client->resource(TICKET)` | `$client->ticket()` | | `$ticket->get(1)` | `$client->ticket()->find(1)` | | `$ticket->getValue('title')` | `$ticket->title` | @@ -41,9 +41,9 @@ $client = new \ZammadAPIClient\Client([ ]); // v3 -$client = \ZammadAPIClient\ZammadClient::withToken( +$client = \ZammadAPIClient\ZammadClient::create( 'https://zammad.example', - 'your-token' + \ZammadAPIClient\Core\Auth::token('your-token') ); ``` diff --git a/examples/cookbook/00-plain.php b/examples/cookbook/00-connect.php similarity index 52% rename from examples/cookbook/00-plain.php rename to examples/cookbook/00-connect.php index eb7a6cc..96e395e 100644 --- a/examples/cookbook/00-plain.php +++ b/examples/cookbook/00-connect.php @@ -1,17 +1,21 @@ ['User-Agent' => 'Zammad API PHP'], + 'timeout' => 30, + ]), +); + +$handler = new RequestHandler( + $httpClient, + new Psr17Factory(), + $url, + maxRetries: 3, + auth: Auth::token($token), +); + +$client = new ZammadClient($handler); + +echo "Connected to {$url} (Symfony HttpClient)\n"; +echo "Ticket #1: " . $client->ticket()->find(1)->title . "\n"; diff --git a/examples/cookbook/07-laravel.php b/examples/cookbook/08-laravel.php similarity index 100% rename from examples/cookbook/07-laravel.php rename to examples/cookbook/08-laravel.php diff --git a/examples/cookbook/09-slim.php b/examples/cookbook/09-slim.php deleted file mode 100644 index 4baae3a..0000000 --- a/examples/cookbook/09-slim.php +++ /dev/null @@ -1,44 +0,0 @@ -create([ - 'headers' => [ - 'User-Agent' => 'Zammad API PHP', - 'Authorization' => "Token token={$token}", - ], - ]), -); - -$psr17 = new Psr17Factory(); - -$handler = new RequestHandler($symfonyClient, $psr17, $url); -$client = new ZammadClient($handler); - -echo "Connected to {$url} (Symfony HttpClient)\n"; -echo "Ticket #1: " . $client->ticket()->find(1)->title . "\n"; diff --git a/examples/cookbook/08-symfony.php b/examples/cookbook/09-symfony.php similarity index 100% rename from examples/cookbook/08-symfony.php rename to examples/cookbook/09-symfony.php diff --git a/examples/cookbook/10-slim.php b/examples/cookbook/10-slim.php new file mode 100644 index 0000000..7bb9149 --- /dev/null +++ b/examples/cookbook/10-slim.php @@ -0,0 +1,78 @@ +addDefinitions([ + * ZammadClient::class => fn (): ZammadClient => ZammadClient::create( + * $_ENV['ZAMMAD_URL'], + * Auth::token($_ENV['ZAMMAD_TOKEN']), + * ), + * ]); + * + * AppFactory::setContainer($builder->build()); + * $app = AppFactory::create(); + * $app->addErrorMiddleware(true, true, true); + * + * $app->get('/tickets/{id}', ShowTicket::class); + * $app->run(); + * + * The action gets the client injected — PHP-DI autowires the constructor, so + * nothing else has to know where the client comes from: + * + * use Psr\Http\Message\ResponseInterface; + * use Psr\Http\Message\ServerRequestInterface; + * use ZammadAPIClient\Exceptions\NotFoundException; + * use ZammadAPIClient\ZammadClient; + * + * final class ShowTicket + * { + * public function __construct(private ZammadClient $zammad) {} + * + * public function __invoke( + * ServerRequestInterface $request, + * ResponseInterface $response, + * array $args, + * ): ResponseInterface { + * try { + * $ticket = $this->zammad->ticket()->find((int) $args['id']); + * } catch (NotFoundException) { + * return $response->withStatus(404); + * } + * + * $response->getBody()->write((string) json_encode($ticket->toArray())); + * + * return $response->withHeader('Content-Type', 'application/json'); + * } + * } + * + * Definitions are lazy: the client is built the first time something asks for + * it, not on every request. Because Auth rejects an empty token, a missing + * ZAMMAD_TOKEN surfaces as an InvalidArgumentException at that point rather + * than as a confusing 401 later. + * + * To use a different HTTP client here, return a client built from your own + * RequestHandler instead — see 07-custom-http-client.php. + */ + +declare(strict_types=1); + +// Wiring happens in your Slim application — this file is for reference. +fwrite(STDOUT, "See docblock above for Slim 4 setup instructions.\n"); diff --git a/examples/cookbook/README.md b/examples/cookbook/README.md index 49ea9e2..52a00e9 100644 --- a/examples/cookbook/README.md +++ b/examples/cookbook/README.md @@ -12,18 +12,31 @@ export ZAMMAD_TOKEN=your-token ## Recipes +**Getting started** + | File | Description | |---|---| -| [`00-plain.php`](00-plain.php) | Guzzle setup (used by recipes 01-06). Standalone, copy-paste-ready. | +| [`00-connect.php`](00-connect.php) | Connect to Zammad. Standalone, copy-paste-ready; recipes 01-06 repeat this block. | | [`01-quick-start.php`](01-quick-start.php) | Client instantiation + find ticket #1. | | [`02-crud.php`](02-crud.php) | Create, read, and delete tickets. | | [`03-listing.php`](03-listing.php) | `all()` streaming, `list()` pagination, `totalCount()`. | | [`04-updates.php`](04-updates.php) | `patch()` partial update + `TicketUpdateDTO`. | | [`05-impersonation.php`](05-impersonation.php) | `ImpersonationHandler` for scoped on-behalf-of requests. | | [`06-search.php`](06-search.php) | Full-text search via `search()` and `searchList()`. | -| [`07-laravel.php`](07-laravel.php) | Laravel service container setup. | -| [`08-symfony.php`](08-symfony.php) | Symfony bundle setup. | -| [`09-slim.php`](09-slim.php) | Non-Guzzle setup (Symfony HttpClient + Nyholm PSR-17). | + +**Custom HTTP client** + +| File | Description | +|---|---| +| [`07-custom-http-client.php`](07-custom-http-client.php) | Use your own PSR-18 client instead of Guzzle (example: Symfony HttpClient + Nyholm PSR-17). See [docs/custom-http-client.md](../../docs/custom-http-client.md). | + +**Framework integration** + +| File | Description | +|---|---| +| [`08-laravel.php`](08-laravel.php) | Laravel service container setup. | +| [`09-symfony.php`](09-symfony.php) | Symfony bundle setup. | +| [`10-slim.php`](10-slim.php) | Slim 4 / PHP-DI setup. No bridge needed — the pattern for any PSR-11 container. | ## Run diff --git a/src/Bridge/LaravelServiceProvider.php b/src/Bridge/LaravelServiceProvider.php index 90f3c0e..e986d09 100644 --- a/src/Bridge/LaravelServiceProvider.php +++ b/src/Bridge/LaravelServiceProvider.php @@ -5,7 +5,7 @@ namespace ZammadAPIClient\Bridge; use Illuminate\Support\ServiceProvider; -use ZammadAPIClient\Factory\GuzzleClientFactory; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\ZammadClient; /** @@ -69,9 +69,13 @@ * * 1. `config/zammad.php` values (after `vendor:publish`) * 2. `ZAMMAD_URL` / `ZAMMAD_TOKEN` environment variables (`.env`) - * 3. Built-in defaults (`http://127.0.0.1:8098`, empty token) + * 3. Built-in default URL (`http://127.0.0.1:8098`) * - * @see ZammadClient::withToken() + * A token is required: there is no usable default for it. If neither + * `config('zammad.token')` nor `ZAMMAD_TOKEN` is set, resolving + * {@see ZammadClient} from the container throws `InvalidArgumentException`. + * + * @see ZammadClient::create() */ final class LaravelServiceProvider extends ServiceProvider { @@ -88,9 +92,7 @@ public function register(): void $url = config('zammad.url') ?: env('ZAMMAD_URL', 'http://127.0.0.1:8098'); $token = config('zammad.token') ?: env('ZAMMAD_TOKEN', ''); - return new ZammadClient( - GuzzleClientFactory::withToken($url, $token), - ); + return ZammadClient::create($url, Auth::token($token)); }); } } diff --git a/src/Bridge/SymfonyBundle.php b/src/Bridge/SymfonyBundle.php index 8f4a138..545e92d 100644 --- a/src/Bridge/SymfonyBundle.php +++ b/src/Bridge/SymfonyBundle.php @@ -5,9 +5,10 @@ namespace ZammadAPIClient\Bridge; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; use Symfony\Component\HttpKernel\Bundle\Bundle; -use ZammadAPIClient\Factory\GuzzleClientFactory; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\ZammadClient; /** @@ -66,7 +67,7 @@ * // $container->get('zammad_client')->ticket()->all(); * ``` * - * @see ZammadClient::withToken() + * @see ZammadClient::create() */ final class SymfonyBundle extends Bundle { @@ -85,15 +86,24 @@ public function load(array $configs, ContainerBuilder $container): void $resolved = array_merge($resolved, $config); } - $url = $resolved['url'] ?? (string) ($_ENV['ZAMMAD_URL'] ?? 'http://127.0.0.1:8098'); - $token = $resolved['token'] ?? (string) ($_ENV['ZAMMAD_TOKEN'] ?? ''); + $container->setParameter('zammad.default_url', 'http://127.0.0.1:8098'); - $client = new ZammadClient( - GuzzleClientFactory::withToken($url, $token), + $url = $resolved['url'] ?? '%env(default:zammad.default_url:ZAMMAD_URL)%'; + $token = $resolved['token'] ?? '%env(ZAMMAD_TOKEN)%'; + + $auth = (new Definition(Auth::class)) + ->setFactory([Auth::class, 'token']) + ->setArguments([$token]); + + $container->setDefinition( + ZammadClient::class, + (new Definition(ZammadClient::class)) + ->setFactory([ZammadClient::class, 'create']) + ->setArguments([$url, $auth]) + ->setPublic(true), ); - $container->set(ZammadClient::class, $client); - $container->setAlias('zammad_client', ZammadClient::class); - $container->registerForAutoconfiguration(ZammadClient::class)->setAutowired(true); + + $container->setAlias('zammad_client', ZammadClient::class)->setPublic(true); } public function getNamespace(): string diff --git a/src/Core/Auth.php b/src/Core/Auth.php new file mode 100644 index 0000000..cede846 --- /dev/null +++ b/src/Core/Auth.php @@ -0,0 +1,105 @@ +`. + * + * @throws InvalidArgumentException If $token is empty. + */ + public static function token(string $token): self + { + return new self('Token token=' . self::requireNonEmpty($token, 'API token')); + } + + /** + * OAuth2 access token — sends `Authorization: Bearer `. + * + * @throws InvalidArgumentException If $token is empty. + */ + public static function oauth2(string $token): self + { + return new self('Bearer ' . self::requireNonEmpty($token, 'OAuth2 token')); + } + + /** + * HTTP basic auth — sends `Authorization: Basic base64(user:password)`. + * + * An empty password is permitted (RFC 7617); the username is required. + * + * `$password` is marked `#[\SensitiveParameter]` so that it is redacted from + * stack traces: an empty username throws from this very frame, and PHP's + * default `zend.exception_ignore_args=0` would otherwise print the password + * as a literal argument. The attribute is inert on PHP 8.1. + * + * @throws InvalidArgumentException If $user is empty. + */ + public static function basic(string $user, #[\SensitiveParameter] string $password): self + { + return new self( + 'Basic ' . base64_encode(self::requireNonEmpty($user, 'Username') . ':' . $password), + ); + } + + /** + * Returns the ready-to-send `Authorization` header value. + * + * @internal Consumed by the transport layer; not part of the public API. + */ + public function header(): string + { + return $this->header; + } + + /** + * Keeps the credential out of var_dump(), print_r() and stack traces. + * + * @return array + */ + public function __debugInfo(): array + { + return ['header' => '***redacted***']; + } + + /** + * Returns the value with surrounding whitespace removed. + * + * Trimming is not cosmetic. A credential read from a file or an environment + * variable commonly carries a trailing newline, which PSR-7 rejects as an + * invalid header value — the error then points at the transport rather than + * at the token. Leading whitespace is worse: it survives into the header and + * the request simply comes back 401. + */ + private static function requireNonEmpty(string $value, string $label): string + { + $trimmed = trim($value); + + return $trimmed === '' + ? throw new InvalidArgumentException("{$label} must not be empty.") + : $trimmed; + } +} diff --git a/src/Core/Traits/SerializesToArray.php b/src/Core/Traits/SerializesToArray.php index a4534dc..8bd9b10 100644 --- a/src/Core/Traits/SerializesToArray.php +++ b/src/Core/Traits/SerializesToArray.php @@ -105,8 +105,9 @@ public function toArray(): array * The consuming class must declare `public readonly ?int $id = null`. * This method reads it directly; no property access indirection is used. * - * @deprecated Use the `$dto->id` property directly. The method will be - * removed in v4.0 alongside the DTOInterface contract change. + * Required by {@see \ZammadAPIClient\Core\Contracts\DTOInterface} and used + * internally by {@see \ZammadAPIClient\Core\Repository\Resource}. Reading + * `$dto->id` directly is equivalent and reads better in application code. */ public function id(): ?int { diff --git a/src/Core/Transport/RequestHandler.php b/src/Core/Transport/RequestHandler.php index 65a4b28..be48b1c 100644 --- a/src/Core/Transport/RequestHandler.php +++ b/src/Core/Transport/RequestHandler.php @@ -12,6 +12,7 @@ use Psr\Http\Message\StreamFactoryInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; use ZammadAPIClient\Exceptions\AuthenticationException; use ZammadAPIClient\Exceptions\BadRequestException; @@ -46,6 +47,7 @@ final class RequestHandler implements RequestHandlerInterface private StreamFactoryInterface $streamFactory; private string $baseUrl; private LoggerInterface $logger; + private ?Auth $auth; private ?ResponseInterface $lastResponse = null; /** @@ -54,6 +56,9 @@ final class RequestHandler implements RequestHandlerInterface * @param string $baseUrl Base URL; the API prefix (`/api/v1`) is appended if missing. * @param LoggerInterface $logger PSR-3 logger; defaults to NullLogger. * @param int $maxRetries Max retries on HTTP 429 (0 = disable). + * @param Auth|null $auth Credentials; sent as `Authorization` on every request. + * Null leaves the header unset, e.g. when the PSR-18 client + * already adds it. */ public function __construct( ClientInterface $httpClient, @@ -61,6 +66,7 @@ public function __construct( string $baseUrl, LoggerInterface $logger = new NullLogger(), int $maxRetries = 3, + ?Auth $auth = null, ) { if (!$factory instanceof StreamFactoryInterface) { throw new InvalidArgumentException( @@ -74,6 +80,7 @@ public function __construct( $this->streamFactory = $factory; $this->baseUrl = self::normalizeBaseUrl($baseUrl); $this->logger = $logger; + $this->auth = $auth; } /** @@ -235,6 +242,12 @@ private function dispatch(string $method, string $uri, array $options): Response $request = $this->requestFactory->createRequest($method, $fullUri) ->withHeader('Accept', 'application/json'); + // Set before the per-request headers below so callers keep the last word, + // matching how a client-level default header behaves. + if ($this->auth !== null) { + $request = $request->withHeader('Authorization', $this->auth->header()); + } + if (isset($options['headers']) && is_array($options['headers'])) { foreach ($options['headers'] as $name => $value) { $request = $request->withHeader($name, $value); diff --git a/src/Factory/GuzzleClientFactory.php b/src/Factory/GuzzleClientFactory.php index 4d6d923..a3ffa21 100644 --- a/src/Factory/GuzzleClientFactory.php +++ b/src/Factory/GuzzleClientFactory.php @@ -7,49 +7,70 @@ use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Psr7\HttpFactory; use Psr\Log\NullLogger; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\ConnectionConfig; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; use ZammadAPIClient\Core\Contracts\ClientFactoryInterface; use ZammadAPIClient\Core\Transport\RequestHandler; +/** + * Builds a Guzzle-backed {@see RequestHandler}. + * + * Most callers never touch this class — {@see \ZammadAPIClient\ZammadClient::create()} + * wires it up for them. Use it directly only to hand a pre-built factory to the + * `ZammadClient` constructor, e.g. in a DI container definition. + */ final class GuzzleClientFactory implements ClientFactoryInterface { public const USER_AGENT = 'Zammad API PHP'; - private function __construct( + public function __construct( private readonly string $url, - private readonly string $authHeader, - private ?ConnectionConfig $config = null, + private readonly Auth $auth, + private readonly ?ConnectionConfig $config = null, ) { } + /** + * Shorthand for `new self($url, Auth::token($token))`. + */ public static function withToken( string $url, string $token, ?ConnectionConfig $config = null, ): self { - return new self($url, "Token token={$token}", $config); + return new self($url, Auth::token($token), $config); } + /** + * Shorthand for `new self($url, Auth::oauth2($token))`. + */ public static function withOAuth2( string $url, string $token, ?ConnectionConfig $config = null, ): self { - return new self($url, "Bearer {$token}", $config); + return new self($url, Auth::oauth2($token), $config); } + /** + * Shorthand for `new self($url, Auth::basic($user, $pass))`. + */ public static function withBasicAuth( string $url, string $user, - string $pass, + #[\SensitiveParameter] string $pass, ?ConnectionConfig $config = null, ): self { - return new self($url, 'Basic ' . base64_encode("{$user}:{$pass}"), $config); + return new self($url, Auth::basic($user, $pass), $config); } /** * Creates a request handler configured with this factory's credentials. + * + * The `Authorization` header is not set on the Guzzle client here — it is + * applied per request by {@see RequestHandler}, which keeps authentication + * working for any PSR-18 transport. */ public function createHandler(): RequestHandlerInterface { @@ -57,8 +78,7 @@ public function createHandler(): RequestHandlerInterface $httpClient = new GuzzleClient([ 'headers' => [ - 'User-Agent' => self::USER_AGENT, - 'Authorization' => $this->authHeader, + 'User-Agent' => self::USER_AGENT, ], 'verify' => $config->verifySsl, 'timeout' => $config->timeout, @@ -72,6 +92,7 @@ public function createHandler(): RequestHandlerInterface $this->url, logger: $config->logger ?? new NullLogger(), maxRetries: $config->maxRetries, + auth: $this->auth, ); } } diff --git a/src/ZammadClient.php b/src/ZammadClient.php index 23f89d9..bd561f3 100644 --- a/src/ZammadClient.php +++ b/src/ZammadClient.php @@ -4,9 +4,12 @@ namespace ZammadAPIClient; +use ZammadAPIClient\Core\Auth; +use ZammadAPIClient\Core\ConnectionConfig; use ZammadAPIClient\Core\Contracts\ClientFactoryInterface; use ZammadAPIClient\Core\Contracts\ClientInterface as ZammadClientInterface; use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; +use ZammadAPIClient\Factory\GuzzleClientFactory; use ZammadAPIClient\Core\Repository\AbstractRepository; use ZammadAPIClient\Core\Repository\RepositoryRegistry; use ZammadAPIClient\Core\Traits\RepositoryAccessors; @@ -14,13 +17,17 @@ /** * Entry point for the Zammad API. * - * $client = new ZammadClient( - * GuzzleClientFactory::withToken('https://zammad.example', 'your-token'), - * ); + * $client = ZammadClient::create('https://zammad.example', Auth::token('your-token')); * * $client->ticket()->find(1); * $client->user()->all(); * + * To use a different HTTP transport, build the handler yourself and pass it to + * the constructor — credentials and retry behaviour work exactly the same: + * + * $handler = new RequestHandler($psr18, $psr17, $url, auth: Auth::token('your-token')); + * $client = new ZammadClient($handler); + * * @internal Prefer {@see ZammadClientInterface} for type hints. */ final class ZammadClient implements ZammadClientInterface @@ -40,6 +47,23 @@ public function __construct( : $source; } + /** + * Creates a client for the given Zammad instance, using Guzzle as transport. + * + * This is the shortcut for the default transport, not the only full-featured + * way in: passing your own {@see RequestHandlerInterface} to the constructor + * supports the same credentials and options. + * + * @param string $url The Zammad base URL; `/api/v1` is appended if missing. + */ + public static function create( + string $url, + Auth $auth, + ?ConnectionConfig $config = null, + ): self { + return new self(new GuzzleClientFactory($url, $auth, $config)); + } + public function getHandler(): RequestHandlerInterface { return $this->handler; diff --git a/test/Unit/Core/AuthTest.php b/test/Unit/Core/AuthTest.php new file mode 100644 index 0000000..a179896 --- /dev/null +++ b/test/Unit/Core/AuthTest.php @@ -0,0 +1,137 @@ +header()); + } + + public function testOAuth2BuildsBearerHeader(): void + { + self::assertSame('Bearer secret', Auth::oauth2('secret')->header()); + } + + public function testBasicBuildsBase64EncodedHeader(): void + { + self::assertSame( + 'Basic ' . base64_encode('admin@example.com:hunter2'), + Auth::basic('admin@example.com', 'hunter2')->header(), + ); + } + + public function testEmptyTokenIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('API token must not be empty.'); + + Auth::token(''); + } + + public function testWhitespaceOnlyTokenIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + Auth::token(" \t "); + } + + public function testEmptyOAuth2TokenIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('OAuth2 token must not be empty.'); + + Auth::oauth2(''); + } + + public function testEmptyUsernameIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Username must not be empty.'); + + Auth::basic('', 'hunter2'); + } + + /** + * RFC 7617 permits an empty password, so only the username is mandatory. + */ + public function testEmptyPasswordIsAccepted(): void + { + self::assertSame( + 'Basic ' . base64_encode('admin:'), + Auth::basic('admin', '')->header(), + ); + } + + /** + * A token read from a file or an environment variable commonly carries a + * trailing newline. Untrimmed it reaches PSR-7, which rejects it as an + * invalid header value and blames the transport; leading whitespace is + * worse, surviving into the header and coming back as a plain 401. + * + * @return iterable + */ + public static function paddedTokens(): iterable + { + yield 'trailing newline' => ["secret\n"]; + yield 'trailing space' => ['secret ']; + yield 'leading space' => [' secret']; + yield 'leading tab' => ["\tsecret"]; + yield 'both sides' => [" secret\r\n"]; + } + + #[DataProvider('paddedTokens')] + public function testSurroundingWhitespaceIsStripped(string $padded): void + { + self::assertSame('Token token=secret', Auth::token($padded)->header()); + } + + public function testUsernameIsTrimmedButPasswordIsNot(): void + { + self::assertSame( + 'Basic ' . base64_encode('admin: hunter2 '), + Auth::basic(' admin ', ' hunter2 ')->header(), + ); + } + + public function testDebugOutputDoesNotLeakTheCredential(): void + { + $dump = print_r(Auth::token('super-secret'), true); + + self::assertStringNotContainsString('super-secret', $dump); + self::assertStringContainsString('redacted', $dump); + } + + /** + * An empty username throws from inside {@see Auth::basic()}, so that frame — + * password and all — lands in the stack trace. `#[\SensitiveParameter]` is what + * keeps the password out of it; without the attribute this test fails. + */ + public function testStackTraceDoesNotLeakTheBasicAuthPassword(): void + { + if (PHP_VERSION_ID < 80200) { + self::markTestSkipped('#[\SensitiveParameter] is honoured from PHP 8.2 on.'); + } + if (ini_get('zend.exception_ignore_args')) { + self::markTestSkipped('Arguments are stripped from traces by zend.exception_ignore_args.'); + } + + try { + Auth::basic('', 'hunter2'); + self::fail('Expected an InvalidArgumentException for the empty username.'); + } catch (InvalidArgumentException $e) { + self::assertStringNotContainsString('hunter2', $e->getTraceAsString()); + self::assertStringContainsString('SensitiveParameterValue', $e->getTraceAsString()); + } + } +} diff --git a/test/Unit/Core/RequestHandlerTest.php b/test/Unit/Core/RequestHandlerTest.php index 98ad0a1..1b0a02b 100644 --- a/test/Unit/Core/RequestHandlerTest.php +++ b/test/Unit/Core/RequestHandlerTest.php @@ -14,6 +14,7 @@ use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\Transport\RequestHandler; use ZammadAPIClient\Exceptions\AuthenticationException; use ZammadAPIClient\Exceptions\BadRequestException; @@ -366,4 +367,61 @@ public function testDeleteReturnsEmptyArrayOnEmptyBody(): void self::assertSame([], $result); } + + /** + * The recording client below is a plain PSR-18 implementation, not Guzzle — + * so this also demonstrates that authentication no longer depends on the + * transport offering client-level default headers. + */ + public function testAuthorizationHeaderIsSentWhenAuthIsConfigured(): void + { + $handler = $this->createHandler($this->httpClient, auth: Auth::token('secret')); + $this->httpClient->response = new Response(200, [], '{}'); + + $handler->get('tickets'); + + self::assertSame( + 'Token token=secret', + $this->httpClient->lastRequest?->getHeaderLine('Authorization'), + ); + } + + public function testNoAuthorizationHeaderWithoutAuth(): void + { + $this->httpClient->response = new Response(200, [], '{}'); + + $this->handler->get('tickets'); + + self::assertFalse($this->httpClient->lastRequest?->hasHeader('Authorization')); + } + + /** + * getRaw() is the attachment path and used to rely on the client-level + * default header, so it is worth pinning down separately. + */ + public function testAuthorizationHeaderIsSentOnRawRequests(): void + { + $handler = $this->createHandler($this->httpClient, auth: Auth::token('secret')); + $this->httpClient->response = new Response(200, [], 'binary-payload'); + + $handler->getRaw('ticket_attachment/1/2/3'); + + self::assertSame( + 'Token token=secret', + $this->httpClient->lastRequest?->getHeaderLine('Authorization'), + ); + } + + public function testPerRequestHeaderOverridesConfiguredAuth(): void + { + $handler = $this->createHandler($this->httpClient, auth: Auth::token('secret')); + $this->httpClient->response = new Response(200, [], '{}'); + + $handler->request('GET', 'tickets', ['headers' => ['Authorization' => 'Bearer override']]); + + self::assertSame( + 'Bearer override', + $this->httpClient->lastRequest?->getHeaderLine('Authorization'), + ); + } } diff --git a/test/Unit/Core/Traits/CreatesRequestHandler.php b/test/Unit/Core/Traits/CreatesRequestHandler.php index 77d3ddc..91a49c9 100644 --- a/test/Unit/Core/Traits/CreatesRequestHandler.php +++ b/test/Unit/Core/Traits/CreatesRequestHandler.php @@ -6,6 +6,7 @@ use GuzzleHttp\Psr7\HttpFactory; use Psr\Http\Client\ClientInterface; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\Transport\RequestHandler; /** @@ -22,8 +23,17 @@ private function setUpRequestHandler(): void $this->httpFactory = new HttpFactory(); } - private function createHandler(ClientInterface $client, int $maxRetries = 0): RequestHandler - { - return new RequestHandler($client, $this->httpFactory, self::BASE_URL, maxRetries: $maxRetries); + private function createHandler( + ClientInterface $client, + int $maxRetries = 0, + ?Auth $auth = null, + ): RequestHandler { + return new RequestHandler( + $client, + $this->httpFactory, + self::BASE_URL, + maxRetries: $maxRetries, + auth: $auth, + ); } } diff --git a/test/Unit/GuzzleClientFactoryTest.php b/test/Unit/GuzzleClientFactoryTest.php index fceaa26..ba9e5b3 100644 --- a/test/Unit/GuzzleClientFactoryTest.php +++ b/test/Unit/GuzzleClientFactoryTest.php @@ -5,8 +5,13 @@ namespace ZammadAPIClient\Tests\Unit; use Mockery\Adapter\Phpunit\MockeryTestCase; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use ReflectionProperty; +use ZammadAPIClient\Core\Auth; use ZammadAPIClient\Core\Contracts\ClientInterface; +use ZammadAPIClient\Core\Contracts\RequestHandlerInterface; +use ZammadAPIClient\Core\Transport\RequestHandler; use ZammadAPIClient\Factory\GuzzleClientFactory; use ZammadAPIClient\ZammadClient; @@ -42,4 +47,60 @@ public function testFactoryWrappedInZammadClient(): void self::assertInstanceOf(ClientInterface::class, $client); } + + /** + * The three static helpers are shorthand for constructing the factory with an + * explicit {@see Auth}, so these calls are deliberate: they pin down that both + * forms stay equivalent. Do not remove them as leftovers. + * + * @return iterable + */ + public static function equivalentFactories(): iterable + { + $url = 'https://zammad.example'; + + yield 'token' => [ + GuzzleClientFactory::withToken($url, 'test-token'), + new GuzzleClientFactory($url, Auth::token('test-token')), + ]; + yield 'oauth2' => [ + GuzzleClientFactory::withOAuth2($url, 'oauth-token'), + new GuzzleClientFactory($url, Auth::oauth2('oauth-token')), + ]; + yield 'basic' => [ + GuzzleClientFactory::withBasicAuth($url, 'admin', 'test'), + new GuzzleClientFactory($url, Auth::basic('admin', 'test')), + ]; + } + + #[DataProvider('equivalentFactories')] + public function testLegacyHelperMatchesExplicitAuthConstructor( + GuzzleClientFactory $legacy, + GuzzleClientFactory $explicit, + ): void { + self::assertSame( + self::authHeaderOf($legacy->createHandler()), + self::authHeaderOf($explicit->createHandler()), + ); + } + + public function testZammadClientCreateUsesTheGivenAuth(): void + { + $client = ZammadClient::create('https://zammad.example', Auth::token('test-token')); + + self::assertSame('Token token=test-token', self::authHeaderOf($client->getHandler())); + } + + /** + * Reads the credential the handler will apply. White-box on purpose: the + * Guzzle-backed path cannot be observed without making a real request. + */ + private static function authHeaderOf(RequestHandlerInterface $handler): string + { + $auth = (new ReflectionProperty(RequestHandler::class, 'auth'))->getValue($handler); + + self::assertInstanceOf(Auth::class, $auth); + + return $auth->header(); + } }