Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@
use ApiPlatform\State\Provider\ObjectMapperProvider;
use ApiPlatform\State\Provider\ParameterProvider;
use ApiPlatform\State\Provider\ReadProvider;
use ApiPlatform\Laravel\State\DenormalizationErrorHandler as LaravelDenormalizationErrorHandler;
use ApiPlatform\State\DenormalizationErrorHandlerInterface;
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\State\SerializerContextBuilderInterface;
use Http\Discovery\Psr17Factory;
Expand Down Expand Up @@ -422,8 +424,18 @@ public function register(): void
);
});

$this->app->singleton(DenormalizationErrorHandlerInterface::class, static function () {
return new LaravelDenormalizationErrorHandler();
});

$this->app->singleton(DeserializeProvider::class, static function (Application $app) {
return new DeserializeProvider($app->make(SwaggerUiProvider::class), $app->make(SerializerInterface::class), $app->make(SerializerContextBuilderInterface::class));
return new DeserializeProvider(
$app->make(SwaggerUiProvider::class),
$app->make(SerializerInterface::class),
$app->make(SerializerContextBuilderInterface::class),
null,
$app->make(DenormalizationErrorHandlerInterface::class),
);
});

$this->app->singleton(ValidateProvider::class, static function (Application $app) {
Expand Down
226 changes: 226 additions & 0 deletions src/Laravel/State/DenormalizationErrorHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Laravel\State;

use ApiPlatform\Laravel\ApiResource\ValidationError;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\DenormalizationErrorHandlerInterface;
use Illuminate\Contracts\Validation\Rule as LaravelRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;

/**
* Laravel-flavored denormalization error handler — translates Symfony serializer type
* errors into a 422 {@see ValidationError} when the Operation's Laravel rules describe
* the property.
*
* Reads rules declared on the operation (string|array form, e.g. `'required|string'`
* or `['required', 'string']`). FormRequest-class rules and pure-callable rule sets
* are intentionally skipped in v1: a FormRequest-based contract typically runs in the
* validation phase against the raw request, not the denormalized body.
*
* Mapping:
*
* | Exception "current type" | Matching Laravel rule | Emitted code |
* |--------------------------|---------------------------------------------|----------------|
* | null | required, filled | blank |
* | null | present | null |
* | any wrong type | string, integer, int, numeric, boolean, | invalid_type |
* | | bool, array, date, json | |
* | any wrong type | any other rule (no `nullable`) | invalid_type |
* | null | nullable (no required/present/filled) | (no match) |
* | any | (no rule) | (no match) |
*
* In collect mode, unconstrained errors still emit a generic `invalid_type` entry so
* the response surface stays consistent with prior behavior.
*
* Codes are plain semantic strings — the Laravel package does not depend on Symfony
* Validator.
*
* @author Antoine Bluchet <soyuka@gmail.com>
*/
final class DenormalizationErrorHandler implements DenormalizationErrorHandlerInterface
{
public const CODE_BLANK = 'blank';
public const CODE_NULL = 'null';
public const CODE_INVALID_TYPE = 'invalid_type';

private const REQUIRED_RULES = ['required' => true, 'filled' => true];
private const PRESENT_RULES = ['present' => true];
private const TYPE_RULES = [
'string' => true,
'integer' => true,
'int' => true,
'numeric' => true,
'boolean' => true,
'bool' => true,
'array' => true,
'date' => true,
'json' => true,
];

public function handle(NotNormalizableValueException|PartialDenormalizationException $exception, Operation $operation): void
{
if ($exception instanceof PartialDenormalizationException) {
$violations = [];
foreach ($exception->getErrors() as $error) {
if (!$error instanceof NotNormalizableValueException) {
continue;
}
$violations[] = $this->buildViolation($error, $operation) ?? $this->buildGenericViolation($error);
}

if (!$violations) {
return;
}

$paths = array_filter(array_map(static fn (array $v): string => $v['propertyPath'], $violations));
$message = implode('; ', array_map(static fn (array $v): string => $v['propertyPath'].': '.$v['message'], $violations));

throw new ValidationError($message, $this->makeId($paths), $exception, $violations);
}

$violation = $this->buildViolation($exception, $operation);
if (null === $violation) {
return;
}

throw new ValidationError($violation['message'], $this->makeId([$violation['propertyPath']]), $exception, [$violation]);
}

/**
* @return array{propertyPath: string, message: string, code: string}|null
*/
private function buildViolation(NotNormalizableValueException $exception, Operation $operation): ?array
{
$rules = $operation->getRules();
if (\is_callable($rules)) {
$rules = $rules();
}

if (\is_string($rules) && is_a($rules, FormRequest::class, true)) {
return null;
}

if (!\is_array($rules)) {
return null;
}

$path = $exception->getPath();
if (null === $path || '' === $path || !\array_key_exists($path, $rules)) {
return null;
}

$propertyRules = $this->extractRuleTokens($rules[$path]);
if (!$propertyRules) {
return null;
}

$isNull = 'null' === strtolower((string) $exception->getCurrentType());

if ($isNull) {
$hasRequired = (bool) array_intersect_key(self::REQUIRED_RULES, $propertyRules);
$hasPresent = (bool) array_intersect_key(self::PRESENT_RULES, $propertyRules);

// `nullable` explicitly permits null when no required/present/filled is set.
if (isset($propertyRules['nullable']) && !$hasRequired && !$hasPresent) {
return null;
}

if ($hasRequired) {
return $this->violation($path, 'This value should not be blank.', self::CODE_BLANK);
}
if ($hasPresent) {
return $this->violation($path, 'This value should not be null.', self::CODE_NULL);
}
}

return $this->violation($path, $this->typeMessage($exception), self::CODE_INVALID_TYPE);
}

/**
* @return array<string, true> rule tokens as a keyed map for O(1) lookup
*/
private function extractRuleTokens(mixed $raw): array
{
if (\is_string($raw)) {
$items = explode('|', $raw);
} elseif (\is_array($raw)) {
$items = $raw;
} else {
return [];
}

$tokens = [];
foreach ($items as $item) {
if ($item instanceof LaravelRule || $item instanceof ValidationRule || \is_object($item)) {
continue;
}
if (!\is_string($item)) {
continue;
}
$name = strtolower(strstr($item, ':', true) ?: $item);
if ('' === $name) {
continue;
}
$tokens[$name] = true;
}

return $tokens;
}

/**
* @return array{propertyPath: string, message: string, code: string}
*/
private function violation(string $path, string $message, string $code): array
{
return [
'propertyPath' => $path,
'message' => $message,
'code' => $code,
];
}

/**
* @return array{propertyPath: string, message: string, code: string}
*/
private function buildGenericViolation(NotNormalizableValueException $exception): array
{
return $this->violation(
(string) $exception->getPath(),
$exception->canUseMessageForUser() ? $exception->getMessage() : $this->typeMessage($exception),
self::CODE_INVALID_TYPE,
);
}

private function typeMessage(NotNormalizableValueException $exception): string
{
$expectedTypes = array_filter($exception->getExpectedTypes() ?? [], static fn ($t): bool => \is_string($t));
if (!$expectedTypes) {
return 'This value should be of the right type.';
}

return \sprintf('This value should be of type %s.', implode('|', $expectedTypes));
}

/**
* @param string[] $paths
*/
private function makeId(array $paths): string
{
return hash('xxh3', implode(',', $paths) ?: 'denormalization');
}
}
79 changes: 79 additions & 0 deletions src/Laravel/Tests/DenormalizationValidationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Laravel\Tests;

use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\Concerns\WithWorkbench;
use Orchestra\Testbench\TestCase;

/**
* @see https://github.com/api-platform/core/issues/7981
*/
class DenormalizationValidationTest extends TestCase
{
use ApiTestAssertionsTrait;
use RefreshDatabase;
use WithWorkbench;

protected function defineEnvironment($app): void
{
tap($app['config'], static function (Repository $config): void {
$config->set('api-platform.formats', ['jsonld' => ['application/ld+json']]);
$config->set('api-platform.docs_formats', ['jsonld' => ['application/ld+json']]);
});
}

public function testWrongTypeOnTypedDtoWithRuleProduces422(): void
{
$response = $this->postJson(
'/api/issue6745/rule_validations',
['prop' => 'abc'],
['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']
);

$response->assertStatus(422);
$body = json_decode($response->getContent(), true);
$this->assertSame('ValidationError', $body['@type'] ?? null);
$this->assertNotEmpty($body['violations'] ?? []);
$this->assertSame('prop', $body['violations'][0]['propertyPath']);
}

public function testWrongTypeWithoutRuleRethrows(): void
{
// `max` rule is `lt:2` (no required, no type rule) — but per the rule table, ANY rule
// on the property triggers a generic Type @ 422 (consistent with Symfony's
// "any wrong type | any other constraint" branch).
$response = $this->postJson(
'/api/issue6745/rule_validations',
['max' => 'abc'],
['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']
);

$response->assertStatus(422);
}

public function testEloquentNullOnRequiredFieldStillReturns422(): void
{
// Eloquent dynamic attrs → no denormalization error. Validation layer catches null + required.
$response = $this->postJson(
'/api/issue_6932',
['sur_name' => null],
['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']
);

$response->assertStatus(422);
}
}
51 changes: 51 additions & 0 deletions src/State/DenormalizationErrorHandlerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\State;

use ApiPlatform\Metadata\Operation;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;

/**
* Translates Symfony serializer denormalization errors into HTTP-level validation
* exceptions when the target {@see Operation} declares a matching validation contract.
*
* Each framework integration provides its own implementation: the Symfony bundle reads
* Symfony Validator metadata and throws {@see \ApiPlatform\Validator\Exception\ValidationException};
* the Laravel package reads Illuminate validation rules and throws Laravel's native
* {@see \ApiPlatform\Laravel\ApiResource\ValidationError}. Implementations must NOT
* depend on a sibling framework's validation stack.
*
* Contract: throw an HTTP exception (typically 422) when at least one error has a
* matching validation contract; return void when nothing matches so the caller can
* rethrow the original denormalization exception for an honest 400.
*
* @author Antoine Bluchet <soyuka@gmail.com>
*
* @see https://github.com/api-platform/core/issues/7981
*/
interface DenormalizationErrorHandlerInterface
{
/**
* Handles a denormalization error.
*
* Accepts either a single {@see NotNormalizableValueException} (raised when the
* serializer fails on the first type mismatch) or a {@see PartialDenormalizationException}
* (raised when `collect_denormalization_errors=true` collects every type mismatch in
* a batch). Implementations dispatch on the concrete type.
*
* @throws \Throwable when at least one error has a matching validation contract
*/
public function handle(NotNormalizableValueException|PartialDenormalizationException $exception, Operation $operation): void;
}
Loading
Loading