From d458d5d7fc5460b217cfe85eea23a0db0eb70913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sat, 7 Nov 2026 00:33:38 +0000 Subject: [PATCH] Throw ValidationFailedException with per-field codes for 422 responses --- src/Id/Exceptions/ApiException.php | 5 ++ .../Exceptions/ValidationFailedException.php | 46 ++++++++++++++ src/Id/Support/AppRequest.php | 9 ++- tests/Id/ValidationFailedTest.php | 61 +++++++++++++++++++ 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 src/Id/Exceptions/ValidationFailedException.php create mode 100644 tests/Id/ValidationFailedTest.php diff --git a/src/Id/Exceptions/ApiException.php b/src/Id/Exceptions/ApiException.php index a6b2d0b..9b3e200 100644 --- a/src/Id/Exceptions/ApiException.php +++ b/src/Id/Exceptions/ApiException.php @@ -35,6 +35,11 @@ class ApiException extends ResourceException return PreconditionFailedException::fromResult($result); } + // refused input, with per-field codes on some endpoints + if ($status === 422 && static::class === self::class) { + return ValidationFailedException::fromResult($result); + } + return new static( rtrim((string) ($body->message ?? $result->error()), '.'), $status, diff --git a/src/Id/Exceptions/ValidationFailedException.php b/src/Id/Exceptions/ValidationFailedException.php new file mode 100644 index 0000000..f9ffd6a --- /dev/null +++ b/src/Id/Exceptions/ValidationFailedException.php @@ -0,0 +1,46 @@ + "username_taken"]`. + * Branch on the codes, the messages are for people and may change. + */ +class ValidationFailedException extends ApiException +{ + /** + * @return array field => code; empty when the endpoint sends none + */ + public function errorCodes(): array + { + $codes = $this->body()->codes ?? null; + + return is_object($codes) || is_array($codes) ? array_map('strval', (array) $codes) : []; + } + + public function errorCodeFor(string $field): ?string + { + return $this->errorCodes()[$field] ?? null; + } + + /** + * Whether any field failed with $code, or the response's `error` is $code. + */ + public function hasErrorCode(string $code): bool + { + return $this->error === $code || in_array($code, $this->errorCodes(), true); + } + + /** + * The messages per field (`errors`), for showing them to people. + * + * @return array> + */ + public function errors(): array + { + $errors = $this->body()->errors ?? null; + + return is_object($errors) || is_array($errors) ? array_map(fn ($messages) => array_values((array) $messages), (array) $errors) : []; + } +} diff --git a/src/Id/Support/AppRequest.php b/src/Id/Support/AppRequest.php index 4fa2240..3b8182e 100644 --- a/src/Id/Support/AppRequest.php +++ b/src/Id/Support/AppRequest.php @@ -6,6 +6,7 @@ use Anikeen\Id\AnikeenId; use Anikeen\Id\Contracts\AppTokenRepository; use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\PreconditionFailedException; +use Anikeen\Id\Exceptions\ValidationFailedException; use Anikeen\Id\Result; use GuzzleHttp\Exception\GuzzleException; use Throwable; @@ -13,7 +14,7 @@ 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 (a 412 in a - * PreconditionFailedException). + * PreconditionFailedException, a 422 in a ValidationFailedException). */ final class AppRequest { @@ -36,7 +37,11 @@ final class AppRequest if (!$result->success()) { $body = is_object($result->data) ? $result->data : null; - $exception = $result->status === 412 ? PreconditionFailedException::class : ApiException::class; + $exception = match ($result->status) { + 412 => PreconditionFailedException::class, + 422 => ValidationFailedException::class, + default => ApiException::class, + }; throw new $exception( sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()), diff --git a/tests/Id/ValidationFailedTest.php b/tests/Id/ValidationFailedTest.php new file mode 100644 index 0000000..1234306 --- /dev/null +++ b/tests/Id/ValidationFailedTest.php @@ -0,0 +1,61 @@ +anikeen_id = '01JUSER'; + $owner->anikeen_id_access_token = 'user-token'; + + return $owner; + } + + public function test_422_of_app_requests_is_a_validation_exception(): void + { + $this->respond(422, ['message' => 'The value is too large.', 'error' => 'value_too_large']); + + try { + app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'example', 'theme', str_repeat('x', 10)); + $this->fail('No exception thrown'); + } catch (ValidationFailedException $e) { + $this->assertSame('value_too_large', $e->error); + $this->assertSame([], $e->errorCodes()); + $this->assertTrue($e->hasErrorCode('value_too_large')); + } + } + + public function test_412_stays_a_precondition_failure(): void + { + $this->respond(412, ['message' => 'Version mismatch.', 'error' => 'preference_version_mismatch', 'current_version' => 3]); + + $this->expectException(PreconditionFailedException::class); + $this->owner()->setGlobalPreference('appearance', 'dark', ifVersion: 2); + } + + public function test_422_of_user_requests_is_a_validation_exception(): void + { + $this->respond(422, ['message' => 'Unknown value.', 'error' => 'invalid_shared_value']); + + try { + $this->owner()->setGlobalPreference('appearance', 'purple'); + $this->fail('No exception thrown'); + } catch (ValidationFailedException $e) { + $this->assertSame(422, $e->status); + $this->assertSame('invalid_shared_value', $e->error); + $this->assertSame([], $e->errorCodes()); + } + } +}