From d76095cc948417b2638d25f7130579eb58ebc3da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Fri, 6 Nov 2026 19:25:51 +0000 Subject: [PATCH] Send extra request headers and throw PreconditionFailedException on 412 --- src/Id/AnikeenId.php | 22 ++-- src/Id/Exceptions/ApiException.php | 8 +- .../PreconditionFailedException.php | 21 ++++ src/Id/Support/AppRequest.php | 18 +-- tests/Id/RequestHeadersTest.php | 113 ++++++++++++++++++ 5 files changed, 164 insertions(+), 18 deletions(-) create mode 100644 src/Id/Exceptions/PreconditionFailedException.php create mode 100644 tests/Id/RequestHeadersTest.php diff --git a/src/Id/AnikeenId.php b/src/Id/AnikeenId.php index 93f504b..f19fee0 100644 --- a/src/Id/AnikeenId.php +++ b/src/Id/AnikeenId.php @@ -369,10 +369,12 @@ class AnikeenId /** * Build query & execute. * + * @param array $headers Extra request headers, e.g. `If-Match` for conditional + * writes; they are sent in addition to (and may not replace) Client-ID, Accept and Authorization. * @throws GuzzleException * @throws RequestRequiresClientIdException */ - public function request(string $method, string $path, null|array $payload = null, array $parameters = [], ?Paginator $paginator = null, bool $useClientSecret = false): Result + public function request(string $method, string $path, null|array $payload = null, array $parameters = [], ?Paginator $paginator = null, bool $useClientSecret = false, array $headers = []): Result { if ($paginator !== null) { $parameters[$paginator->action] = $paginator->cursor(); @@ -380,7 +382,7 @@ class AnikeenId try { $response = $this->client->request($method, $path, [ - 'headers' => $this->buildHeaders((bool)$payload, $useClientSecret), + 'headers' => $this->buildHeaders((bool)$payload, $useClientSecret) + $headers, 'query' => Query::build($parameters), 'json' => $payload ?: null, ]); @@ -419,35 +421,35 @@ class AnikeenId * @throws GuzzleException * @throws RequestRequiresClientIdException */ - public function get(string $path, array $parameters = [], ?Paginator $paginator = null): Result + public function get(string $path, array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result { - return $this->request('GET', $path, null, $parameters, $paginator); + return $this->request('GET', $path, null, $parameters, $paginator, false, $headers); } /** * @throws GuzzleException * @throws RequestRequiresClientIdException */ - public function post(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result + public function post(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result { - return $this->request('POST', $path, $payload, $parameters, $paginator); + return $this->request('POST', $path, $payload, $parameters, $paginator, false, $headers); } /** * @throws GuzzleException * @throws RequestRequiresClientIdException */ - public function put(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result + public function put(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result { - return $this->request('PUT', $path, $payload, $parameters, $paginator); + return $this->request('PUT', $path, $payload, $parameters, $paginator, false, $headers); } /** * @throws GuzzleException * @throws RequestRequiresClientIdException */ - public function delete(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result + public function delete(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result { - return $this->request('DELETE', $path, $payload, $parameters, $paginator); + return $this->request('DELETE', $path, $payload, $parameters, $paginator, false, $headers); } } diff --git a/src/Id/Exceptions/ApiException.php b/src/Id/Exceptions/ApiException.php index 237cf16..e218dba 100644 --- a/src/Id/Exceptions/ApiException.php +++ b/src/Id/Exceptions/ApiException.php @@ -28,10 +28,16 @@ class ApiException extends ResourceException public static function fromResult(Result $result): static { $body = is_object($result->data) ? $result->data : null; + $status = $result->response()?->getStatusCode() ?? 0; + + // a failed conditional write; subclasses (CollectionException) keep their own type + if ($status === 412 && static::class === self::class) { + return PreconditionFailedException::fromResult($result); + } return new static( rtrim((string) ($body->message ?? $result->error()), '.'), - $result->response()?->getStatusCode() ?? 0, + $status, $body->error ?? null, $result->exception, ); diff --git a/src/Id/Exceptions/PreconditionFailedException.php b/src/Id/Exceptions/PreconditionFailedException.php new file mode 100644 index 0000000..99e86c1 --- /dev/null +++ b/src/Id/Exceptions/PreconditionFailedException.php @@ -0,0 +1,21 @@ +body()->current_version ?? null; + + return is_numeric($version) ? (int) $version : null; + } +} diff --git a/src/Id/Support/AppRequest.php b/src/Id/Support/AppRequest.php index 9070d4f..4fa2240 100644 --- a/src/Id/Support/AppRequest.php +++ b/src/Id/Support/AppRequest.php @@ -5,36 +5,40 @@ namespace Anikeen\Id\Support; use Anikeen\Id\AnikeenId; use Anikeen\Id\Contracts\AppTokenRepository; use Anikeen\Id\Exceptions\ApiException; +use Anikeen\Id\Exceptions\PreconditionFailedException; use Anikeen\Id\Result; use GuzzleHttp\Exception\GuzzleException; use Throwable; /** * Sends a request with the app's own token (client credentials). A rejected token is renewed - * once; every failure ends in an ApiException with status and error code. + * once; every failure ends in an ApiException with status and error code (a 412 in a + * PreconditionFailedException). */ final class AppRequest { /** + * @param array $headers Extra request headers, e.g. `If-Match`. * @throws ApiException */ - public static function send(string $method, string $path, ?array $payload = null, array $query = []): Result + public static function send(string $method, string $path, ?array $payload = null, array $query = [], array $headers = []): Result { - $result = self::attempt($method, $path, $payload, $query); + $result = self::attempt($method, $path, $payload, $query, $headers); if ($result->status === 401) { $tokens = app(AppTokenRepository::class); if (method_exists($tokens, 'forgetAccessToken')) { $tokens->forgetAccessToken(); - $result = self::attempt($method, $path, $payload, $query); + $result = self::attempt($method, $path, $payload, $query, $headers); } } if (!$result->success()) { $body = is_object($result->data) ? $result->data : null; + $exception = $result->status === 412 ? PreconditionFailedException::class : ApiException::class; - throw new ApiException( + throw new $exception( sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()), $result->response()?->getStatusCode() ?? 0, $body->error ?? null, @@ -48,10 +52,10 @@ final class AppRequest /** * @throws ApiException */ - private static function attempt(string $method, string $path, ?array $payload, array $query): Result + private static function attempt(string $method, string $path, ?array $payload, array $query, array $headers = []): Result { try { - return app(AnikeenId::class)->asApp()->request($method, $path, $payload, $query); + return app(AnikeenId::class)->asApp()->request($method, $path, $payload, $query, null, false, $headers); } catch (ApiException $e) { throw $e; } catch (GuzzleException|Throwable $e) { diff --git a/tests/Id/RequestHeadersTest.php b/tests/Id/RequestHeadersTest.php new file mode 100644 index 0000000..ac03b30 --- /dev/null +++ b/tests/Id/RequestHeadersTest.php @@ -0,0 +1,113 @@ +respond(200, ['data' => []]); + + AppRequest::send('GET', 'v1/client/subscriptions', null, ['page' => 1]); + + $request = $this->sent[0]['request']; + $this->assertSame(['Bearer app-token'], $request->getHeader('Authorization')); + $this->assertSame(['app-client'], $request->getHeader('Client-ID')); + $this->assertFalse($request->hasHeader('If-Match')); + $this->assertFalse($request->hasHeader('If-None-Match')); + $this->assertSame('page=1', $request->getUri()->getQuery()); + } + + public function test_app_requests_send_extra_headers_also_on_the_retry_with_a_fresh_token(): void + { + $this->respond(401, ['message' => 'Unauthenticated.']); + $this->respond(200, ['data' => []]); + + AppRequest::send('PUT', 'v1/x', ['value' => 1], [], ['If-Match' => '"7"']); + + $this->assertSame('"7"', $this->sent[0]['request']->getHeaderLine('If-Match')); + $this->assertSame('"7"', $this->sent[1]['request']->getHeaderLine('If-Match')); + $this->assertSame('Bearer fresh-app-token', $this->sent[1]['request']->getHeaderLine('Authorization')); + } + + public function test_extra_headers_do_not_replace_the_client_and_token_headers(): void + { + $this->respond(200, ['data' => []]); + + app(AnikeenId::class)->withToken('user-token') + ->put('v1/x', ['value' => 1], [], null, ['If-None-Match' => '*', 'Authorization' => 'Bearer other']); + + $request = $this->sent[0]['request']; + $this->assertSame('*', $request->getHeaderLine('If-None-Match')); + $this->assertSame('Bearer user-token', $request->getHeaderLine('Authorization')); + } + + public function test_get_and_delete_helpers_take_headers(): void + { + $this->respond(200, ['data' => []]); + $this->respond(204); + + $anikeenId = app(AnikeenId::class)->withToken('user-token'); + $anikeenId->get('v1/x', [], null, ['If-None-Match' => '"3"']); + $anikeenId->delete('v1/x', [], [], null, ['If-Match' => '"3"']); + + $this->assertSame('"3"', $this->sent[0]['request']->getHeaderLine('If-None-Match')); + $this->assertSame('"3"', $this->sent[1]['request']->getHeaderLine('If-Match')); + } + + public function test_a_412_of_an_app_request_is_a_precondition_failed_exception(): void + { + $this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => 12]); + + try { + AppRequest::send('PUT', 'v1/x', ['value' => 1], [], ['If-Match' => '"7"']); + $this->fail('no exception'); + } catch (PreconditionFailedException $e) { + $this->assertSame(412, $e->status); + $this->assertSame('preference_version_mismatch', $e->error); + $this->assertSame(12, $e->currentVersion()); + } + } + + public function test_a_412_of_a_user_request_is_a_precondition_failed_exception(): void + { + $this->respond(412, ['message' => 'The preference is already set.', 'error' => 'preference_exists', 'current_version' => null]); + + $result = app(AnikeenId::class)->withToken('user-token')->put('v1/x', ['value' => 1]); + $e = ApiException::fromResult($result); + + $this->assertInstanceOf(PreconditionFailedException::class, $e); + $this->assertSame('preference_exists', $e->error); + $this->assertNull($e->currentVersion()); + } + + public function test_list_errors_keep_their_collection_exception_type(): void + { + $this->respond(412, ['message' => 'x', 'error' => 'x']); + + $this->expectException(CollectionException::class); + + $anikeenId = app(AnikeenId::class)->withToken('user-token'); + SshKeys::builder(fn () => $anikeenId->get('v1/ssh-keys')); + } + + public function test_other_errors_stay_plain_api_exceptions(): void + { + $this->respond(409, ['message' => 'Taken.', 'error' => 'preference_value_taken']); + + try { + AppRequest::send('PUT', 'v1/x', ['value' => 1]); + $this->fail('no exception'); + } catch (ApiException $e) { + $this->assertSame(ApiException::class, $e::class); + $this->assertTrue($e->isConflict()); + } + } +}