mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-06 23:58:35 +00:00
Send extra request headers and throw PreconditionFailedException on 412
This commit is contained in:
5 files changed
+164
-18
No files matched your search
+12
-10
@@ -369,10 +369,12 @@ class AnikeenId
|
||||
/**
|
||||
* Build query & execute.
|
||||
*
|
||||
* @param array<string, string> $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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Exceptions;
|
||||
|
||||
/**
|
||||
* A conditional write failed (412): the stored version wasn't the one sent with `If-Match`
|
||||
* ("preference_version_mismatch"), or the key was already set and the write was create-only
|
||||
* ("preference_exists"). Reload, merge and retry with currentVersion().
|
||||
*/
|
||||
class PreconditionFailedException extends ApiException
|
||||
{
|
||||
/**
|
||||
* The version stored now, null when the key isn't set (or Anikeen ID didn't send one).
|
||||
*/
|
||||
public function currentVersion(): ?int
|
||||
{
|
||||
$version = $this->body()->current_version ?? null;
|
||||
|
||||
return is_numeric($version) ? (int) $version : null;
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> $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) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Tests;
|
||||
|
||||
use Anikeen\Id\AnikeenId;
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
use Anikeen\Id\Exceptions\CollectionException;
|
||||
use Anikeen\Id\Exceptions\PreconditionFailedException;
|
||||
use Anikeen\Id\Resources\SshKeys;
|
||||
use Anikeen\Id\Support\AppRequest;
|
||||
|
||||
class RequestHeadersTest extends TestCase
|
||||
{
|
||||
public function test_requests_without_extra_headers_send_the_same_headers_as_before(): void
|
||||
{
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user