diff --git a/src/Laravel/ApiPlatformDeferredProvider.php b/src/Laravel/ApiPlatformDeferredProvider.php index f69d2d3b5ad..5fdaf23441c 100644 --- a/src/Laravel/ApiPlatformDeferredProvider.php +++ b/src/Laravel/ApiPlatformDeferredProvider.php @@ -46,6 +46,7 @@ use ApiPlatform\Laravel\Metadata\ParameterValidationResourceMetadataCollectionFactory; use ApiPlatform\Laravel\State\ParameterValidatorProvider; use ApiPlatform\Laravel\State\SwaggerUiProcessor; +use ApiPlatform\Laravel\State\ValidateProvider; use ApiPlatform\Metadata\InflectorInterface; use ApiPlatform\Metadata\Operation\PathSegmentNameGeneratorInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -76,7 +77,6 @@ use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\ParameterProviderInterface; use ApiPlatform\State\ProcessorInterface; -use ApiPlatform\State\Provider\DeserializeProvider; use ApiPlatform\State\Provider\ParameterProvider; use ApiPlatform\State\Provider\SecurityParameterProvider; use ApiPlatform\State\ProviderInterface; @@ -133,7 +133,7 @@ public function register(): void return new ParameterProvider( new ParameterValidatorProvider( new SecurityParameterProvider( - $app->make(DeserializeProvider::class), + $app->make(ValidateProvider::class), $app->make(ResourceAccessCheckerInterface::class) ), ), diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index b52fef2d4ee..7cc8b6aa6ab 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -88,7 +88,6 @@ use ApiPlatform\Laravel\Eloquent\Metadata\ResourceClassResolver as EloquentResourceClassResolver; use ApiPlatform\Laravel\Eloquent\PropertyAccess\PropertyAccessor as EloquentPropertyAccessor; use ApiPlatform\Laravel\Eloquent\Serializer\SerializerContextBuilder as EloquentSerializerContextBuilder; -use ApiPlatform\Laravel\Eloquent\Serializer\SnakeCaseToCamelCaseNameConverter; use ApiPlatform\Laravel\Exception\ErrorHandler; use ApiPlatform\Laravel\GraphQl\Controller\EntrypointController as GraphQlEntrypointController; use ApiPlatform\Laravel\GraphQl\Controller\GraphiQlController; @@ -178,6 +177,7 @@ use Symfony\Component\Serializer\Mapping\Loader\LoaderInterface; use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer; use Symfony\Component\Serializer\Normalizer\DateIntervalNormalizer; @@ -343,12 +343,18 @@ public function register(): void return new SwaggerUiProvider($app->make(ReadProvider::class), $app->make(OpenApiFactoryInterface::class), $config->get('api-platform.swagger_ui.enabled', false)); }); - $this->app->singleton(ValidateProvider::class, function (Application $app) { - return new ValidateProvider($app->make(SwaggerUiProvider::class), $app); + $this->app->singleton(DeserializeProvider::class, function (Application $app) { + return new DeserializeProvider($app->make(SwaggerUiProvider::class), $app->make(SerializerInterface::class), $app->make(SerializerContextBuilderInterface::class)); }); - $this->app->singleton(DeserializeProvider::class, function (Application $app) { - return new DeserializeProvider($app->make(ValidateProvider::class), $app->make(SerializerInterface::class), $app->make(SerializerContextBuilderInterface::class)); + $this->app->singleton(ValidateProvider::class, function (Application $app) { + $config = $app['config']; + $nameConverter = $config->get('api-platform.name_converter', SnakeCaseToCamelCaseNameConverter::class); + if ($nameConverter && class_exists($nameConverter)) { + $nameConverter = $app->make($nameConverter); + } + + return new ValidateProvider($app->make(DeserializeProvider::class), $app, $app->make(ObjectNormalizer::class), $nameConverter); }); if (class_exists(JsonApiProvider::class)) { diff --git a/src/Laravel/Eloquent/Serializer/SnakeCaseToCamelCaseNameConverter.php b/src/Laravel/Eloquent/Serializer/SnakeCaseToCamelCaseNameConverter.php deleted file mode 100644 index 11b8862d9f4..00000000000 --- a/src/Laravel/Eloquent/Serializer/SnakeCaseToCamelCaseNameConverter.php +++ /dev/null @@ -1,71 +0,0 @@ - - * - * 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\Eloquent\Serializer; - -use Symfony\Component\Serializer\NameConverter\NameConverterInterface; - -/** - * Underscore to cameCase name converter. - * - * @internal - * - * @see Adapted from https://github.com/symfony/symfony/blob/7.2/src/Symfony/Component/Serializer/NameConverter/CamelCaseToSnakeCaseNameConverter.php. - * - * @author Kévin Dunglas - * @author Aurélien Pillevesse - * @copyright Fabien Potencier - */ -final class SnakeCaseToCamelCaseNameConverter implements NameConverterInterface -{ - /** - * @param string[]|null $attributes The list of attributes to rename or null for all attributes - */ - public function __construct( - private readonly ?array $attributes = null, - ) { - } - - /** - * @param class-string|null $class - * @param array $context - */ - public function normalize( - string $propertyName, ?string $class = null, ?string $format = null, array $context = [], - ): string { - if (null === $this->attributes || \in_array($propertyName, $this->attributes, true)) { - return lcfirst(preg_replace_callback( - '/(^|_|\.)+(.)/', - fn ($match) => ('.' === $match[1] ? '_' : '').strtoupper($match[2]), - $propertyName - )); - } - - return $propertyName; - } - - /** - * @param class-string|null $class - * @param array $context - */ - public function denormalize( - string $propertyName, ?string $class = null, ?string $format = null, array $context = [], - ): string { - $snakeCased = strtolower(preg_replace('/[A-Z]/', '_\\0', lcfirst($propertyName))); - if (null === $this->attributes || \in_array($snakeCased, $this->attributes, true)) { - return $snakeCased; - } - - return $propertyName; - } -} diff --git a/src/Laravel/State/ValidateProvider.php b/src/Laravel/State/ValidateProvider.php index 5e065ffd1b4..4e2272e4cae 100644 --- a/src/Laravel/State/ValidateProvider.php +++ b/src/Laravel/State/ValidateProvider.php @@ -14,12 +14,16 @@ namespace ApiPlatform\Laravel\State; use ApiPlatform\Metadata\Error; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** * @implements ProviderInterface @@ -34,12 +38,15 @@ final class ValidateProvider implements ProviderInterface public function __construct( private readonly ProviderInterface $inner, private readonly Application $app, + // TODO: trigger deprecation in API Platform 4.2 when this is not defined + private readonly ?NormalizerInterface $normalizer = null, + ?NameConverterInterface $nameConverter = null, ) { + $this->nameConverter = $nameConverter; } public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - $request = $context['request']; $body = $this->inner->provide($operation, $uriVariables, $context); if ($operation instanceof Error) { @@ -74,12 +81,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $body; } - // In Symfony, validation is done on the Resource object (here $body) using Deserialization before Validation - // Here, we did not deserialize yet, we validate on the raw body before. - $validationBody = $request->request->all(); - if ('jsonapi' === $request->getRequestFormat()) { - $validationBody = $validationBody['data']['attributes']; - } + $validationBody = $this->getBodyForValidation($body); $validator = Validator::make($validationBody, $rules); if ($validator->fails()) { @@ -88,4 +90,35 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $body; } + + /** + * @return array + */ + private function getBodyForValidation(mixed $body): array + { + if (!$body) { + return []; + } + + if ($body instanceof Model) { + return $body->toArray(); + } + + if ($this->normalizer) { + if (!\is_array($v = $this->normalizer->normalize($body))) { + throw new RuntimeException('An array is expected.'); + } + + return $v; + } + + // hopefully this path never gets used, its there for BC-layer only + // TODO: deprecation in API Platform 4.2 + // TODO: remove in 5.0 + if ($s = json_encode($body)) { + return json_decode($s, true); + } + + throw new RuntimeException('Could not transform the denormalized body in an array for validation'); + } } diff --git a/src/Laravel/State/ValidationErrorTrait.php b/src/Laravel/State/ValidationErrorTrait.php index 3d2b3ade662..b6a358dcb04 100644 --- a/src/Laravel/State/ValidationErrorTrait.php +++ b/src/Laravel/State/ValidationErrorTrait.php @@ -16,16 +16,19 @@ use ApiPlatform\Laravel\ApiResource\ValidationError; use Illuminate\Contracts\Validation\Validator; use Illuminate\Validation\ValidationException; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; trait ValidationErrorTrait { + private ?NameConverterInterface $nameConverter = null; + private function getValidationError(Validator $validator, ValidationException $e): ValidationError { $errors = $validator->errors(); $violations = []; $id = hash('xxh3', implode(',', $errors->keys())); foreach ($errors->messages() as $prop => $message) { - $violations[] = ['propertyPath' => $prop, 'message' => implode(\PHP_EOL, $message)]; + $violations[] = ['propertyPath' => $this->nameConverter ? $this->nameConverter->normalize($prop) : $prop, 'message' => implode(\PHP_EOL, $message)]; } return new ValidationError($e->getMessage(), $id, $e, $violations); diff --git a/src/Laravel/Tests/SnakeCaseApiTest.php b/src/Laravel/Tests/SnakeCaseApiTest.php index f9d0f9b6fb1..2efa56c0835 100644 --- a/src/Laravel/Tests/SnakeCaseApiTest.php +++ b/src/Laravel/Tests/SnakeCaseApiTest.php @@ -69,4 +69,20 @@ public function testRelationIsHandledOnCreateWithNestedDataSnakeCase(): void ], ]); } + + public function testFailWithCamelCase(): void + { + $cartData = [ + 'productSku' => 'SKU_TEST_001', + 'quantity' => 2, + 'priceAtAddition' => '19.99', + 'shoppingCart' => [ + 'userIdentifier' => 'user-'.Str::uuid()->toString(), + 'status' => 'active', + ], + ]; + + $response = $this->postJson('/api/cart_items', $cartData, ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); + $response->assertStatus(422); + } } diff --git a/src/Laravel/Tests/ValidationTest.php b/src/Laravel/Tests/ValidationTest.php new file mode 100644 index 00000000000..b5dec205689 --- /dev/null +++ b/src/Laravel/Tests/ValidationTest.php @@ -0,0 +1,60 @@ + + * + * 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\Application; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +class ValidationTest extends TestCase +{ + use ApiTestAssertionsTrait; + use RefreshDatabase; + use WithWorkbench; + + /** + * @param Application $app + */ + protected function defineEnvironment($app): void + { + tap($app['config'], 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 testValidationCamelCase(): void + { + $data = [ + 'surName' => '', + ]; + + $response = $this->postJson('/api/issue_6932', $data, ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); + $response->assertJsonFragment(['violations' => [['propertyPath' => 'surName', 'message' => 'The sur name field is required.']]]); // validate that the name has been converted + $response->assertStatus(422); + } + + public function testValidationSnakeCase(): void + { + $data = [ + 'sur_name' => 'test', + ]; + + $response = $this->postJson('/api/issue_6932', $data, ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); + $response->assertStatus(422); + } +} diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 8fd2331ae43..da46f60dcf9 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -11,10 +11,10 @@ declare(strict_types=1); -use ApiPlatform\Laravel\Eloquent\Serializer\SnakeCaseToCamelCaseNameConverter; use ApiPlatform\Metadata\UrlGeneratorInterface; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\AuthenticationException; +use Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter; return [ 'title' => 'API Platform', diff --git a/src/Laravel/workbench/app/ApiResource/RuleValidation.php b/src/Laravel/workbench/app/ApiResource/RuleValidation.php index 2922b5d66cd..34f94be1e4e 100644 --- a/src/Laravel/workbench/app/ApiResource/RuleValidation.php +++ b/src/Laravel/workbench/app/ApiResource/RuleValidation.php @@ -23,7 +23,7 @@ )] class RuleValidation { - public function __construct(public int $prop, public ?int $max = null) + public function __construct(public ?int $prop = null, public ?int $max = null) { } } diff --git a/src/Laravel/workbench/app/Models/CartItem.php b/src/Laravel/workbench/app/Models/CartItem.php index 6026b7ddca2..0a9d4011e05 100644 --- a/src/Laravel/workbench/app/Models/CartItem.php +++ b/src/Laravel/workbench/app/Models/CartItem.php @@ -20,7 +20,15 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Symfony\Component\Serializer\Attribute\Groups; -#[ApiResource(denormalizationContext: ['groups' => ['cart_item.write']], normalizationContext: ['groups' => ['cart_item.write']])] +#[ApiResource( + denormalizationContext: ['groups' => ['cart_item.write']], + normalizationContext: ['groups' => ['cart_item.write']], + rules: [ + 'product_sku' => 'required', + 'quantity' => 'required', + 'price_at_addition' => 'required', + ] +)] #[Groups('cart_item.write')] #[ApiProperty(serialize: new Groups(['shopping_cart.write']), property: 'product_sku')] #[ApiProperty(serialize: new Groups(['shopping_cart.write']), property: 'price_at_addition')] diff --git a/src/Laravel/workbench/app/Models/Issue6932.php b/src/Laravel/workbench/app/Models/Issue6932.php new file mode 100644 index 00000000000..00a73e51c16 --- /dev/null +++ b/src/Laravel/workbench/app/Models/Issue6932.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace App\Models; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; +use Illuminate\Database\Eloquent\Model; + +#[ApiResource( + operations: [ + new Post( + uriTemplate: '/issue_6932', + rules: [ + 'sur_name' => 'required', + ] + ), + ], +)] +class Issue6932 extends Model +{ + protected $table = 'issue6932'; +} diff --git a/src/Laravel/workbench/database/migrations/2025_05_21_160249_create_issue6932.php b/src/Laravel/workbench/database/migrations/2025_05_21_160249_create_issue6932.php new file mode 100644 index 00000000000..54b2a2f42b0 --- /dev/null +++ b/src/Laravel/workbench/database/migrations/2025_05_21_160249_create_issue6932.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration { + public function up(): void + { + Schema::create('issue6932', function (Blueprint $table): void { + $table->id(); + $table->string('sur_name'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('issue6932'); + } +};