mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-07 16:18:33 +00:00
Throw ValidationFailedException with per-field codes for 422 responses
This commit is contained in:
4 files changed
+119
-2
No files matched your search
@@ -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,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Exceptions;
|
||||
|
||||
/**
|
||||
* Anikeen ID refused the input (422). `error` is the first code; endpoints with per-field codes
|
||||
* (the profile API) send them as `codes`: `{field: code}`, e.g. `["username" => "username_taken"]`.
|
||||
* Branch on the codes, the messages are for people and may change.
|
||||
*/
|
||||
class ValidationFailedException extends ApiException
|
||||
{
|
||||
/**
|
||||
* @return array<string, string> 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<string, list<string>>
|
||||
*/
|
||||
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) : [];
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Tests;
|
||||
|
||||
use Anikeen\Id\AnikeenId;
|
||||
use Anikeen\Id\Billable;
|
||||
use Anikeen\Id\Exceptions\PreconditionFailedException;
|
||||
use Anikeen\Id\Exceptions\ValidationFailedException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ValidationFailedTest extends TestCase
|
||||
{
|
||||
private function owner(): Model
|
||||
{
|
||||
$owner = new class extends Model {
|
||||
use Billable;
|
||||
|
||||
protected $guarded = [];
|
||||
};
|
||||
$owner->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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user