From efd00d1c26380de0648b7f5efb521e69ac4e14a2 Mon Sep 17 00:00:00 2001 From: Maxime Helias Date: Mon, 19 May 2025 10:39:54 +0200 Subject: [PATCH 01/23] chore(hydra)!: remove 3.4 deprecation (#7155) --- src/Hydra/Serializer/CollectionNormalizer.php | 7 +------ src/Laravel/ApiPlatformProvider.php | 1 - src/Symfony/Bundle/Resources/config/hydra.xml | 1 - 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index 8485789f0a2..4f6343b5511 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -17,7 +17,6 @@ use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait; use ApiPlatform\JsonLd\Serializer\JsonLdContextTrait; use ApiPlatform\Metadata\IriConverterInterface; -use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Serializer\AbstractCollectionNormalizer; @@ -41,14 +40,10 @@ final class CollectionNormalizer extends AbstractCollectionNormalizer self::IRI_ONLY => false, ]; - public function __construct(private readonly ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, array $defaultContext = []) + public function __construct(private readonly ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, array $defaultContext = []) { $this->defaultContext = array_merge($this->defaultContext, $defaultContext); - if ($resourceMetadataCollectionFactory) { - trigger_deprecation('api-platform/core', '3.0', \sprintf('Injecting "%s" within "%s" is not needed anymore and this dependency will be removed in 4.0.', ResourceMetadataCollectionFactoryInterface::class, self::class)); - } - parent::__construct($resourceClassResolver, ''); } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 51a0ec70501..a530db4225c 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -756,7 +756,6 @@ public function register(): void $app->make(ContextBuilderInterface::class), $app->make(ResourceClassResolverInterface::class), $app->make(IriConverterInterface::class), - $app->make(ResourceMetadataCollectionFactoryInterface::class), $defaultContext ), $app->make(ResourceMetadataCollectionFactoryInterface::class), diff --git a/src/Symfony/Bundle/Resources/config/hydra.xml b/src/Symfony/Bundle/Resources/config/hydra.xml index 6fb156141ab..ec1236f8162 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.xml +++ b/src/Symfony/Bundle/Resources/config/hydra.xml @@ -43,7 +43,6 @@ - null %api_platform.serializer.default_context% From 5459ba375b0e7ffd1c783a6e18a6452769eaff46 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 20 May 2025 11:29:57 +0200 Subject: [PATCH 02/23] fix(metadata): parameter cast to array flag (#7160) --- src/Metadata/Parameter.php | 14 ++++++++ src/State/Util/ParameterParserTrait.php | 8 +++-- .../TestBundle/ApiResource/WithParameter.php | 17 ++++++++- tests/Functional/Parameters/ParameterTest.php | 35 +++++++++++++++++++ 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index 6fdd9ff98f3..0e070435156 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -50,6 +50,7 @@ public function __construct( protected ?array $extraProperties = [], protected array|string|null $filterContext = null, protected ?Type $nativeType = null, + protected ?bool $castToArray = null, ) { } @@ -312,4 +313,17 @@ public function withNativeType(Type $nativeType): self return $self; } + + public function getCastToArray(): ?bool + { + return $this->castToArray; + } + + public function withCastToArray(bool $castToArray): self + { + $self = clone $this; + $self->castToArray = $castToArray; + + return $self; + } } diff --git a/src/State/Util/ParameterParserTrait.php b/src/State/Util/ParameterParserTrait.php index 05282313498..26bc2630e1d 100644 --- a/src/State/Util/ParameterParserTrait.php +++ b/src/State/Util/ParameterParserTrait.php @@ -92,9 +92,11 @@ private function extractParameterValues(Parameter $parameter, array $values): st } } - if ($isCollection) { - $value = \is_array($value) ? $value : [$value]; - } elseif ($parameter instanceof HeaderParameter && \is_array($value) && array_is_list($value) && 1 === \count($value)) { + if ($isCollection && true === $parameter->getCastToArray() && !\is_array($value)) { + $value = [$value]; + } + + if (!$isCollection && $parameter instanceof HeaderParameter && \is_array($value) && array_is_list($value) && 1 === \count($value)) { $value = $value[0]; } diff --git a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php index 9c29ed33e5c..e5757ef2dba 100644 --- a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php +++ b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php @@ -29,7 +29,9 @@ use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\TypeIdentifier; +use Symfony\Component\Validator\Constraints\All; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Constraints\Country; #[Get( uriTemplate: 'with_parameters/{id}{._format}', @@ -56,6 +58,20 @@ ], provider: [self::class, 'collectionProvider'] )] +#[GetCollection( + uriTemplate: 'with_parameters_country{._format}', + parameters: [ + 'country' => new QueryParameter(schema: ['type' => 'string'], constraints: [new Country()]), + ], + provider: [self::class, 'collectionProvider'] +)] +#[GetCollection( + uriTemplate: 'with_parameters_countries{._format}', + parameters: [ + 'country' => new QueryParameter(constraints: [new All([new Country()])], castToArray: true), + ], + provider: [self::class, 'collectionProvider'], +)] #[GetCollection( uriTemplate: 'validate_parameters{._format}', parameters: [ @@ -105,7 +121,6 @@ parameters: new Parameters([ new QueryParameter( key: 'q', - nativeType: new BuiltinType(TypeIdentifier::STRING), ), new HeaderParameter( key: 'q', diff --git a/tests/Functional/Parameters/ParameterTest.php b/tests/Functional/Parameters/ParameterTest.php index 5d08e514a40..42c217d0afd 100644 --- a/tests/Functional/Parameters/ParameterTest.php +++ b/tests/Functional/Parameters/ParameterTest.php @@ -102,6 +102,15 @@ public function testHeaderAndQuery(): void ]); } + public function testHeaderAndQueryWithArray(): void + { + $response = self::createClient()->request('GET', 'with_parameters_header_and_query?q[]=blabla', ['headers' => ['q' => '(complex stuff)']]); + $this->assertEquals($response->toArray(), [ + '(complex stuff)', + ['blabla'], + ]); + } + public function testHeaderParameterRequired(): void { self::createClient()->request('GET', 'header_required', ['headers' => ['req' => 'blabla']]); @@ -125,4 +134,30 @@ public static function provideHeaderValues(): iterable yield 'too low' => ['0', 422]; yield 'invalid integer' => ['string', 422]; } + + #[DataProvider('provideCountryValues')] + public function testIssue7157(string $queryParameter, int $expectedStatusCode): void + { + self::createClient()->request('GET', 'with_parameters_country?'.$queryParameter); + $this->assertResponseStatusCodeSame($expectedStatusCode); + } + + public static function provideCountryValues(): iterable + { + yield 'valid country' => ['country=FR', 200]; + yield 'array of countries' => ['country[]=FR', 422]; + } + + #[DataProvider('provideCountriesValues')] + public function testIssue7157WithCountries(string $queryParameter, int $expectedStatusCode): void + { + self::createClient()->request('GET', 'with_parameters_countries?'.$queryParameter); + $this->assertResponseStatusCodeSame($expectedStatusCode); + } + + public static function provideCountriesValues(): iterable + { + yield 'valid country' => ['country=FR', 200]; + yield 'array of countries' => ['country[]=FR', 200]; + } } From 329acf21e3a8618136a21b9121c5891f1fe6b9e8 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 20 May 2025 13:42:43 +0200 Subject: [PATCH 03/23] fix(metadata): infer parameter string type from schema (#7161) --- ...meterResourceMetadataCollectionFactory.php | 2 ++ .../TestBundle/ApiResource/WithParameter.php | 22 ++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 7675487762f..a5bab56c580 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -154,6 +154,8 @@ private function getDefaultParameters(Operation $operation, string $resourceClas // this forces the type to be only a list if ('array' === ($parameter->getSchema()['type'] ?? null)) { $parameter = $parameter->withNativeType(Type::list(Type::string())); + } elseif ('string' === ($parameter->getSchema()['type'] ?? null)) { + $parameter = $parameter->withNativeType(Type::string()); } else { $parameter = $parameter->withNativeType(Type::union(Type::string(), Type::list(Type::string()))); } diff --git a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php index e5757ef2dba..31544ae7ea0 100644 --- a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php +++ b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php @@ -28,6 +28,9 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\GenericType; +use Symfony\Component\TypeInfo\Type\UnionType; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Validator\Constraints\All; use Symfony\Component\Validator\Constraints as Assert; @@ -61,7 +64,21 @@ #[GetCollection( uriTemplate: 'with_parameters_country{._format}', parameters: [ - 'country' => new QueryParameter(schema: ['type' => 'string'], constraints: [new Country()]), + 'country' => new QueryParameter( + schema: ['type' => 'string'], + constraints: [new Country()], + nativeType: new UnionType( + new BuiltinType(TypeIdentifier::STRING), + new CollectionType( + new GenericType( // @phpstan-ignore-line + new BuiltinType(TypeIdentifier::ARRAY), // @phpstan-ignore-line + new BuiltinType(TypeIdentifier::INT), + new BuiltinType(TypeIdentifier::STRING), + ), + true, + ), + ) + ), ], provider: [self::class, 'collectionProvider'] )] @@ -104,8 +121,7 @@ nativeType: new BuiltinType(TypeIdentifier::STRING), ), 'pattern' => new QueryParameter( - schema: ['pattern' => '\d'], - nativeType: new BuiltinType(TypeIdentifier::STRING), + schema: ['pattern' => '\d', 'type' => 'string'], ), ], provider: [self::class, 'collectionProvider'] From 4d66f5ef313fe3857611e8345702a10019c79ec5 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 21 May 2025 11:36:50 +0200 Subject: [PATCH 04/23] fix(laravel): persist embeded relations with groups --- src/Laravel/ApiPlatformProvider.php | 30 +++---- .../EloquentPropertyMetadataFactory.php | 21 +++-- .../Eloquent/Metadata/ModelMetadata.php | 17 +++- .../Eloquent/State/PersistProcessor.php | 42 +++++++++- src/Laravel/Tests/EloquentTest.php | 81 +++++++++++++++++++ src/Laravel/workbench/app/Models/CartItem.php | 43 ++++++++++ .../workbench/app/Models/ShoppingCart.php | 41 ++++++++++ .../database/factories/CartItemFactory.php | 36 +++++++++ .../factories/ShoppingCartFactory.php | 34 ++++++++ .../2025_05_20_143303_create_cart.php | 48 +++++++++++ src/Serializer/AbstractItemNormalizer.php | 1 - 11 files changed, 366 insertions(+), 28 deletions(-) create mode 100644 src/Laravel/workbench/app/Models/CartItem.php create mode 100644 src/Laravel/workbench/app/Models/ShoppingCart.php create mode 100644 src/Laravel/workbench/database/factories/CartItemFactory.php create mode 100644 src/Laravel/workbench/database/factories/ShoppingCartFactory.php create mode 100644 src/Laravel/workbench/database/migrations/2025_05_20_143303_create_cart.php diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index a530db4225c..264d2f480f6 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -256,30 +256,26 @@ public function register(): void }); $this->app->singleton(PropertyMetadataFactoryInterface::class, function (Application $app) { - return new PropertyInfoPropertyMetadataFactory( - $app->make(PropertyInfoExtractorInterface::class), - new EloquentPropertyMetadataFactory( - $app->make(ModelMetadata::class), - ) - ); - }); - - $this->app->extend(PropertyMetadataFactoryInterface::class, function (PropertyInfoPropertyMetadataFactory $inner, Application $app) { /** @var ConfigRepository $config */ $config = $app['config']; return new CachePropertyMetadataFactory( new SchemaPropertyMetadataFactory( $app->make(ResourceClassResolverInterface::class), - new SerializerPropertyMetadataFactory( - $app->make(SerializerClassMetadataFactory::class), - new AttributePropertyMetadataFactory( - new EloquentAttributePropertyMetadataFactory( - $inner, - ) + new PropertyInfoPropertyMetadataFactory( + $app->make(PropertyInfoExtractorInterface::class), + new SerializerPropertyMetadataFactory( + $app->make(SerializerClassMetadataFactory::class), + new AttributePropertyMetadataFactory( + new EloquentAttributePropertyMetadataFactory( + new EloquentPropertyMetadataFactory( + $app->make(ModelMetadata::class), + ), + ) + ), + $app->make(ResourceClassResolverInterface::class) ), - $app->make(ResourceClassResolverInterface::class) - ), + ) ), true === $config->get('app.debug') ? 'array' : $config->get('api-platform.cache', 'file') ); diff --git a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php index f29a4556416..11bb76ce196 100644 --- a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php +++ b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php @@ -86,10 +86,21 @@ public function create(string $resourceClass, string $property, array $options = default => new Type(\in_array($builtinType, Type::$builtinTypes, true) ? $builtinType : Type::BUILTIN_TYPE_STRING, $p['nullable'] ?? true), }; - return $propertyMetadata - ->withBuiltinTypes([$type]) - ->withWritable($propertyMetadata->isWritable() ?? true === $p['fillable']) - ->withReadable($propertyMetadata->isReadable() ?? false === $p['hidden']); + $propertyMetadata = $propertyMetadata + ->withBuiltinTypes([$type]); + + // If these are set let the SerializerPropertyMetadataFactory do the work + if (!isset($options['denormalization_groups'])) { + $propertyMetadata = $propertyMetadata + ->withWritable($propertyMetadata->isWritable() ?? true === $p['fillable']); + } + + if (!isset($options['normalization_groups'])) { + $propertyMetadata = $propertyMetadata + ->withReadable($propertyMetadata->isReadable() ?? false === $p['hidden']); + } + + return $propertyMetadata; } foreach ($this->modelMetadata->getRelations($model) as $relation) { @@ -110,8 +121,6 @@ public function create(string $resourceClass, string $property, array $options = return $propertyMetadata ->withBuiltinTypes([$type]) - ->withWritable($propertyMetadata->isWritable() ?? true) - ->withReadable($propertyMetadata->isReadable() ?? true) ->withExtraProperties(['eloquent_relation' => $relation] + $propertyMetadata->getExtraProperties()); } diff --git a/src/Laravel/Eloquent/Metadata/ModelMetadata.php b/src/Laravel/Eloquent/Metadata/ModelMetadata.php index e6c5b90ae78..4259017b2d3 100644 --- a/src/Laravel/Eloquent/Metadata/ModelMetadata.php +++ b/src/Laravel/Eloquent/Metadata/ModelMetadata.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Laravel\Eloquent\Metadata; +use ApiPlatform\Metadata\Util\CamelCaseToSnakeCaseNameConverter; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Collection; @@ -25,6 +26,8 @@ */ final class ModelMetadata { + private CamelCaseToSnakeCaseNameConverter $relationNameConverter; + /** * @var array> */ @@ -54,6 +57,11 @@ final class ModelMetadata 'morphedByMany', ]; + public function __construct() + { + $this->relationNameConverter = new CamelCaseToSnakeCaseNameConverter(); + } + /** * Gets the column attributes for the given model. * @@ -172,8 +180,10 @@ public function getRelations(Model $model): Collection || $this->attributeIsHidden($method->getName(), $model) ) ->filter(function (\ReflectionMethod $method) { - if ($method->getReturnType() instanceof \ReflectionNamedType - && is_subclass_of($method->getReturnType()->getName(), Relation::class)) { + if ( + $method->getReturnType() instanceof \ReflectionNamedType + && is_subclass_of($method->getReturnType()->getName(), Relation::class) + ) { return true; } @@ -204,7 +214,8 @@ public function getRelations(Model $model): Collection } return [ - 'name' => $method->getName(), + 'name' => $this->relationNameConverter->normalize($method->getName()), + 'method_name' => $method->getName(), 'type' => $relation::class, 'related' => \get_class($relation->getRelated()), 'foreign_key' => method_exists($relation, 'getForeignKeyName') ? $relation->getForeignKeyName() : null, diff --git a/src/Laravel/Eloquent/State/PersistProcessor.php b/src/Laravel/Eloquent/State/PersistProcessor.php index 84a01ce661d..2cd0498a9a1 100644 --- a/src/Laravel/Eloquent/State/PersistProcessor.php +++ b/src/Laravel/Eloquent/State/PersistProcessor.php @@ -14,16 +14,23 @@ namespace ApiPlatform\Laravel\Eloquent\State; use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * @implements ProcessorInterface<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> */ final class PersistProcessor implements ProcessorInterface { + /** + * @var array + */ + private array $relations; + public function __construct( private readonly ModelMetadata $modelMetadata, ) { @@ -31,14 +38,35 @@ public function __construct( public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { + $toMany = []; + foreach ($this->modelMetadata->getRelations($data) as $relation) { if (!isset($data->{$relation['name']})) { continue; } if (BelongsTo::class === $relation['type']) { - $data->{$relation['name']}()->associate($data->{$relation['name']}); + $rel = $data->{$relation['name']}; + + if (!$rel->exists) { + $rel->save(); + } + + $data->{$relation['method_name']}()->associate($data->{$relation['name']}); + unset($data->{$relation['name']}); + $this->relations[$relation['method_name']] = $relation['name']; + } + + if (HasMany::class === $relation['type']) { + $rel = $data->{$relation['name']}; + + if (!\is_array($rel)) { + throw new RuntimeException('To-Many relationship is not a collection.'); + } + + $toMany[$relation['method_name']] = $rel; unset($data->{$relation['name']}); + $this->relations[$relation['method_name']] = $relation['name']; } } @@ -54,6 +82,18 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $data->saveOrFail(); $data->refresh(); + foreach ($data->getRelations() as $methodName => $obj) { + if (isset($this->relations[$methodName])) { + $data->{$this->relations[$methodName]} = $obj; + } + } + + foreach ($toMany as $methodName => $relations) { + $data->{$methodName}()->saveMany($relations); + $data->{$this->relations[$methodName]} = $relations; + unset($toMany[$methodName]); + } + return $data; } } diff --git a/src/Laravel/Tests/EloquentTest.php b/src/Laravel/Tests/EloquentTest.php index 85279d1ab3f..06fca290a91 100644 --- a/src/Laravel/Tests/EloquentTest.php +++ b/src/Laravel/Tests/EloquentTest.php @@ -16,6 +16,7 @@ use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; use ApiPlatform\Laravel\workbench\app\Enums\BookStatus; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Str; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase; use Workbench\Database\Factories\AuthorFactory; @@ -442,4 +443,84 @@ public function testBelongsTo(): void $this->assertEquals($json['@id'], '/api/grand_sons/1/grand_father'); $this->assertEquals($json['sons'][0], '/api/grand_sons/1'); } + + public function testRelationIsHandledOnCreateWithNestedData(): 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(201); + + $response + ->assertJson([ + '@context' => '/api/contexts/CartItem', + '@id' => '/api/cart_items/1', + '@type' => 'CartItem', + 'id' => 1, + 'productSku' => 'SKU_TEST_001', + 'quantity' => 2, + 'priceAtAddition' => 19.99, + 'shoppingCart' => [ + '@id' => '/api/shopping_carts/1', + '@type' => 'ShoppingCart', + 'userIdentifier' => $cartData['shoppingCart']['userIdentifier'], + 'status' => 'active', + ], + ]); + } + + public function testRelationIsHandledOnCreateWithNestedDataToMany(): void + { + $cartData = [ + 'userIdentifier' => 'user-'.Str::uuid()->toString(), + 'status' => 'active', + 'cartItems' => [ + [ + 'productSku' => 'SKU_TEST_001', + 'quantity' => 2, + 'priceAtAddition' => '19.99', + ], + [ + 'productSku' => 'SKU_TEST_002', + 'quantity' => 1, + 'priceAtAddition' => '25.50', + ], + ], + ]; + + $response = $this->postJson('/api/shopping_carts', $cartData, ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); + $response->assertStatus(201); + $response->assertJson([ + '@context' => '/api/contexts/ShoppingCart', + '@id' => '/api/shopping_carts/1', + '@type' => 'ShoppingCart', + 'id' => 1, + 'userIdentifier' => $cartData['userIdentifier'], + 'status' => 'active', + 'cartItems' => [ + [ + '@id' => '/api/cart_items/1', + '@type' => 'CartItem', + 'productSku' => 'SKU_TEST_001', + 'quantity' => 2, + 'priceAtAddition' => '19.99', + ], + [ + '@id' => '/api/cart_items/2', + '@type' => 'CartItem', + 'productSku' => 'SKU_TEST_002', + 'quantity' => 1, + 'priceAtAddition' => '25.50', + ], + ], + ]); + } } diff --git a/src/Laravel/workbench/app/Models/CartItem.php b/src/Laravel/workbench/app/Models/CartItem.php new file mode 100644 index 00000000000..55e145bb552 --- /dev/null +++ b/src/Laravel/workbench/app/Models/CartItem.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\App\Models; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +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']])] +#[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')] +#[ApiProperty(serialize: new Groups(['shopping_cart.write']), property: 'quantity')] +class CartItem extends Model +{ + use HasFactory; + + protected $fillable = [ + 'product_sku', + 'quantity', + 'price_at_addition', + ]; + + public function shoppingCart(): BelongsTo + { + return $this->belongsTo(ShoppingCart::class); + } +} diff --git a/src/Laravel/workbench/app/Models/ShoppingCart.php b/src/Laravel/workbench/app/Models/ShoppingCart.php new file mode 100644 index 00000000000..0fb3a580663 --- /dev/null +++ b/src/Laravel/workbench/app/Models/ShoppingCart.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\App\Models; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource(denormalizationContext: ['groups' => ['shopping_cart.write']], normalizationContext: ['groups' => ['shopping_cart.write']])] +#[Groups(['shopping_cart.write'])] +// We do not want to set the group on `cartItems` because it will lead to a circular reference +#[ApiProperty(serialize: new Groups(['cart_item.write']), property: 'user_identifier')] +#[ApiProperty(serialize: new Groups(['cart_item.write']), property: 'status')] +class ShoppingCart extends Model +{ + use HasFactory; + + protected $fillable = [ + 'user_identifier', + 'status', + ]; + + public function cartItems(): HasMany + { + return $this->hasMany(CartItem::class); + } +} diff --git a/src/Laravel/workbench/database/factories/CartItemFactory.php b/src/Laravel/workbench/database/factories/CartItemFactory.php new file mode 100644 index 00000000000..8958a562542 --- /dev/null +++ b/src/Laravel/workbench/database/factories/CartItemFactory.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\Database\Factories; + +use Illuminate\Database\Eloquent\Factories\Factory; +use Workbench\App\Models\Cart; +use Workbench\App\Models\CartItem; + +/** + * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\CartItem> + */ +class CartItemFactory extends Factory +{ + protected $model = CartItem::class; + + public function definition(): array + { + return [ + 'product_sku' => 'SKU-'.$this->faker->unique()->numberBetween(1000, 9999), + 'quantity' => $this->faker->numberBetween(1, 5), + 'price_at_addition' => $this->faker->randomFloat(2, 5, 200), + 'shopping_cart_id' => Cart::factory(), + ]; + } +} diff --git a/src/Laravel/workbench/database/factories/ShoppingCartFactory.php b/src/Laravel/workbench/database/factories/ShoppingCartFactory.php new file mode 100644 index 00000000000..2a7691e5f60 --- /dev/null +++ b/src/Laravel/workbench/database/factories/ShoppingCartFactory.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\Database\Factories; + +use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Str; +use Workbench\App\Models\ShoppingCart; // For generating a random identifier + +/** + * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ShoppingCart> + */ +class ShoppingCartFactory extends Factory +{ + protected $model = ShoppingCart::class; + + public function definition(): array + { + return [ + 'user_identifier' => Str::uuid()->toString(), + 'status' => $this->faker->randomElement(['active', 'pending_checkout', 'completed']), + ]; + } +} diff --git a/src/Laravel/workbench/database/migrations/2025_05_20_143303_create_cart.php b/src/Laravel/workbench/database/migrations/2025_05_20_143303_create_cart.php new file mode 100644 index 00000000000..6970b69dd2d --- /dev/null +++ b/src/Laravel/workbench/database/migrations/2025_05_20_143303_create_cart.php @@ -0,0 +1,48 @@ + + * + * 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('shopping_carts', function (Blueprint $table): void { + $table->id(); + $table->string('user_identifier')->index(); + $table->string('status')->default('active'); + $table->timestamps(); + }); + + Schema::create('cart_items', function (Blueprint $table): void { + $table->id(); + $table->string('product_sku'); + $table->integer('quantity')->unsigned(); + $table->decimal('price_at_addition', 8, 2); + + $table->unsignedInteger('shopping_cart_id')->nullable(); + $table->foreign('shopping_cart_id')->references('id')->on('shopping_carts'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cart_items'); + Schema::dropIfExists('shopping_carts'); + } +}; diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 1b68581550c..7f7d83c0e0d 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -42,7 +42,6 @@ use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Serializer\Serializer; /** * Base item normalizer. From 767fa926b10bef771e896300b8e796287392d8c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alfaiate?= Date: Tue, 20 May 2025 11:08:47 +0700 Subject: [PATCH 05/23] feat(laravel): add name_converter option --- src/Laravel/ApiPlatformProvider.php | 3 ++- src/Laravel/config/api-platform.php | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 264d2f480f6..53bb5197666 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -313,9 +313,10 @@ public function register(): void $this->app->bind(NameConverterInterface::class, function (Application $app) { $config = $app['config']; + $nameConverter = $config->get('api-platform.name_converter', SnakeCaseToCamelCaseNameConverter::class); $defaultContext = $config->get('api-platform.serializer', []); - return new HydraPrefixNameConverter(new MetadataAwareNameConverter($app->make(ClassMetadataFactoryInterface::class), $app->make(SnakeCaseToCamelCaseNameConverter::class)), $defaultContext); + return new HydraPrefixNameConverter(new MetadataAwareNameConverter($app->make(ClassMetadataFactoryInterface::class), $app->make($nameConverter)), $defaultContext); }); $this->app->singleton(OperationMetadataFactory::class, function (Application $app) { diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 5c5c18c968a..84486479490 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -11,6 +11,7 @@ declare(strict_types=1); +use ApiPlatform\Laravel\Eloquent\Serializer\SnakeCaseToCamelCaseNameConverter; use ApiPlatform\Metadata\UrlGeneratorInterface; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\AuthenticationException; @@ -86,6 +87,8 @@ // 'middleware' => null ], + 'name_converter' => SnakeCaseToCamelCaseNameConverter::class, + 'exception_to_status' => [ AuthenticationException::class => 401, AuthorizationException::class => 403, From 61301d9065f660b3b41b81976d6280b772d1d8c3 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 21 May 2025 14:10:46 +0200 Subject: [PATCH 06/23] test(laravel): test to change the name converter --- src/Laravel/ApiPlatformProvider.php | 11 ++- .../Eloquent/Metadata/ModelMetadata.php | 8 +-- src/Laravel/Tests/SnakeCaseApiTest.php | 72 +++++++++++++++++++ 3 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 src/Laravel/Tests/SnakeCaseApiTest.php diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 53bb5197666..b52fef2d4ee 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -211,7 +211,10 @@ public function register(): void ); }); - $this->app->singleton(ModelMetadata::class); + $this->app->singleton(ModelMetadata::class, function () { + return new ModelMetadata(); + }); + $this->app->bind(LoaderInterface::class, AttributeLoader::class); $this->app->bind(ClassMetadataFactoryInterface::class, ClassMetadataFactory::class); $this->app->singleton(ClassMetadataFactory::class, function (Application $app) { @@ -314,9 +317,13 @@ public function register(): void $this->app->bind(NameConverterInterface::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); + } + $defaultContext = $config->get('api-platform.serializer', []); - return new HydraPrefixNameConverter(new MetadataAwareNameConverter($app->make(ClassMetadataFactoryInterface::class), $app->make($nameConverter)), $defaultContext); + return new HydraPrefixNameConverter(new MetadataAwareNameConverter($app->make(ClassMetadataFactoryInterface::class), $nameConverter), $defaultContext); }); $this->app->singleton(OperationMetadataFactory::class, function (Application $app) { diff --git a/src/Laravel/Eloquent/Metadata/ModelMetadata.php b/src/Laravel/Eloquent/Metadata/ModelMetadata.php index 4259017b2d3..bd89142d0af 100644 --- a/src/Laravel/Eloquent/Metadata/ModelMetadata.php +++ b/src/Laravel/Eloquent/Metadata/ModelMetadata.php @@ -13,11 +13,12 @@ namespace ApiPlatform\Laravel\Eloquent\Metadata; -use ApiPlatform\Metadata\Util\CamelCaseToSnakeCaseNameConverter; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** * Inspired from Illuminate\Database\Console\ShowModelCommand. @@ -26,8 +27,6 @@ */ final class ModelMetadata { - private CamelCaseToSnakeCaseNameConverter $relationNameConverter; - /** * @var array> */ @@ -57,9 +56,8 @@ final class ModelMetadata 'morphedByMany', ]; - public function __construct() + public function __construct(private NameConverterInterface $relationNameConverter = new CamelCaseToSnakeCaseNameConverter()) { - $this->relationNameConverter = new CamelCaseToSnakeCaseNameConverter(); } /** diff --git a/src/Laravel/Tests/SnakeCaseApiTest.php b/src/Laravel/Tests/SnakeCaseApiTest.php new file mode 100644 index 00000000000..f9d0f9b6fb1 --- /dev/null +++ b/src/Laravel/Tests/SnakeCaseApiTest.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; +use Illuminate\Contracts\Config\Repository; +use Illuminate\Foundation\Application; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Str; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +class SnakeCaseApiTest 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.name_converter', null); + $config->set('api-platform.formats', ['jsonld' => ['application/ld+json']]); + $config->set('api-platform.docs_formats', ['jsonld' => ['application/ld+json']]); + }); + } + + public function testRelationIsHandledOnCreateWithNestedDataSnakeCase(): void + { + $cartData = [ + 'product_sku' => 'SKU_TEST_001', + 'quantity' => 2, + 'price_at_addition' => '19.99', + 'shopping_cart' => [ + 'user_identifier' => 'user-'.Str::uuid()->toString(), + 'status' => 'active', + ], + ]; + + $response = $this->postJson('/api/cart_items', $cartData, ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); + $response->assertStatus(201); + + $response + ->assertJson([ + '@context' => '/api/contexts/CartItem', + '@id' => '/api/cart_items/1', + '@type' => 'CartItem', + 'id' => 1, + 'product_sku' => 'SKU_TEST_001', + 'quantity' => 2, + 'price_at_addition' => 19.99, + 'shopping_cart' => [ + '@id' => '/api/shopping_carts/1', + '@type' => 'ShoppingCart', + 'user_identifier' => $cartData['shopping_cart']['user_identifier'], + 'status' => 'active', + ], + ]); + } +} From fe73002bf5ae64adb1eb9e310dc62ff158de094d Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 21 May 2025 14:12:03 +0200 Subject: [PATCH 07/23] fix(laravel): duplicated property names --- .../ClassLevelAttributePropertyNameCollectionFactory.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Metadata/Property/Factory/ClassLevelAttributePropertyNameCollectionFactory.php b/src/Metadata/Property/Factory/ClassLevelAttributePropertyNameCollectionFactory.php index 63283013f16..06df512551f 100644 --- a/src/Metadata/Property/Factory/ClassLevelAttributePropertyNameCollectionFactory.php +++ b/src/Metadata/Property/Factory/ClassLevelAttributePropertyNameCollectionFactory.php @@ -33,17 +33,17 @@ public function create(string $resourceClass, array $options = []): PropertyName return $parentPropertyNameCollection ?? new PropertyNameCollection(); } - $properties = $parentPropertyNameCollection ? iterator_to_array($parentPropertyNameCollection) : []; + $properties = $parentPropertyNameCollection ? array_flip(iterator_to_array($parentPropertyNameCollection)) : []; $refl = new \ReflectionClass($resourceClass); $attributes = $refl->getAttributes(ApiProperty::class); foreach ($attributes as $attribute) { $instance = $attribute->newInstance(); if ($property = $instance->getProperty()) { - $properties[] = $property; + $properties[$property] = true; } } - return new PropertyNameCollection($properties); + return new PropertyNameCollection(array_keys($properties)); } } From 4e7e8615f3cabda7dda3761ed3c035fb4b93a8a3 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 21 May 2025 15:41:01 +0200 Subject: [PATCH 08/23] chore(laravel): comments on name converter --- src/Laravel/config/api-platform.php | 1 + src/Laravel/workbench/app/Models/CartItem.php | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 84486479490..8fd2331ae43 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -87,6 +87,7 @@ // 'middleware' => null ], + // set to null if you want to keep snake_case 'name_converter' => SnakeCaseToCamelCaseNameConverter::class, 'exception_to_status' => [ diff --git a/src/Laravel/workbench/app/Models/CartItem.php b/src/Laravel/workbench/app/Models/CartItem.php index 55e145bb552..6026b7ddca2 100644 --- a/src/Laravel/workbench/app/Models/CartItem.php +++ b/src/Laravel/workbench/app/Models/CartItem.php @@ -20,7 +20,6 @@ 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']])] #[Groups('cart_item.write')] #[ApiProperty(serialize: new Groups(['shopping_cart.write']), property: 'product_sku')] From 730d17a306df4b92082484a19e82c5e150537331 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 09:38:54 +0200 Subject: [PATCH 09/23] fix(laravel): validate the model instead of body --- src/Laravel/ApiPlatformDeferredProvider.php | 4 +- src/Laravel/ApiPlatformProvider.php | 8 ++-- src/Laravel/State/ValidateProvider.php | 44 ++++++++++++++++--- src/Laravel/Tests/SnakeCaseApiTest.php | 16 +++++++ .../app/ApiResource/RuleValidation.php | 2 +- src/Laravel/workbench/app/Models/CartItem.php | 10 ++++- 6 files changed, 69 insertions(+), 15 deletions(-) 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..29ef3fe782f 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -343,12 +343,12 @@ 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) { + return new ValidateProvider($app->make(DeserializeProvider::class), $app, $app->make(ObjectNormalizer::class)); }); if (class_exists(JsonApiProvider::class)) { diff --git a/src/Laravel/State/ValidateProvider.php b/src/Laravel/State/ValidateProvider.php index 5e065ffd1b4..cbef8a356d0 100644 --- a/src/Laravel/State/ValidateProvider.php +++ b/src/Laravel/State/ValidateProvider.php @@ -14,12 +14,15 @@ 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\Normalizer\NormalizerInterface; /** * @implements ProviderInterface @@ -34,12 +37,13 @@ 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, ) { } 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 +78,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 +87,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/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/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')] From b0390080e90be9ac494c8b4d968e59a4962f32ca Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 09:39:54 +0200 Subject: [PATCH 10/23] fix(laravel): name convert validation property path --- src/Laravel/ApiPlatformProvider.php | 10 ++- .../SnakeCaseToCamelCaseNameConverter.php | 71 ------------------- src/Laravel/State/ValidateProvider.php | 3 + src/Laravel/State/ValidationErrorTrait.php | 5 +- src/Laravel/config/api-platform.php | 2 +- 5 files changed, 16 insertions(+), 75 deletions(-) delete mode 100644 src/Laravel/Eloquent/Serializer/SnakeCaseToCamelCaseNameConverter.php diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 29ef3fe782f..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; @@ -348,7 +348,13 @@ public function register(): void }); $this->app->singleton(ValidateProvider::class, function (Application $app) { - return new ValidateProvider($app->make(DeserializeProvider::class), $app, $app->make(ObjectNormalizer::class)); + $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 cbef8a356d0..4e2272e4cae 100644 --- a/src/Laravel/State/ValidateProvider.php +++ b/src/Laravel/State/ValidateProvider.php @@ -22,6 +22,7 @@ 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; /** @@ -39,7 +40,9 @@ public function __construct( 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 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/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', From bf166bb1739ca70d45f8e4c7b8e3784a94c21bbd Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 09:40:29 +0200 Subject: [PATCH 11/23] test(laravel): validation tests #6932 --- src/Laravel/Tests/ValidationTest.php | 60 +++++++++++++++++++ .../workbench/app/Models/Issue6932.php | 33 ++++++++++ .../2025_05_21_160249_create_issue6932.php | 35 +++++++++++ 3 files changed, 128 insertions(+) create mode 100644 src/Laravel/Tests/ValidationTest.php create mode 100644 src/Laravel/workbench/app/Models/Issue6932.php create mode 100644 src/Laravel/workbench/database/migrations/2025_05_21_160249_create_issue6932.php 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/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'); + } +}; From fd010ea1be86073f5d8905d5640846390ade7ce6 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 10:27:56 +0200 Subject: [PATCH 12/23] fix(openapi): nullable externalDocs return type fixes #7163 --- src/OpenApi/Model/Tag.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenApi/Model/Tag.php b/src/OpenApi/Model/Tag.php index bb7db4eaf02..c0793522a15 100644 --- a/src/OpenApi/Model/Tag.php +++ b/src/OpenApi/Model/Tag.php @@ -47,7 +47,7 @@ public function withDescription(string $description): self return $clone; } - public function getExternalDocs(): string + public function getExternalDocs(): ?string { return $this->externalDocs; } From 470c2e8bdf8d7a7502c02d2b681ced2001e2c1cc Mon Sep 17 00:00:00 2001 From: jknezovic <101651825+JakovKnezovicc@users.noreply.github.com> Date: Thu, 22 May 2025 10:55:22 +0200 Subject: [PATCH 13/23] fix(httpcache): iri cache tag for collection operation with path parameter --- .../EventListener/PurgeHttpCacheListener.php | 3 +- src/Symfony/Routing/IriConverter.php | 2 +- .../PurgeHttpCacheListenerTest.php | 85 +++++++++++++++---- 3 files changed, 69 insertions(+), 21 deletions(-) diff --git a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php index 8fdd0b3c964..7dafbdf6723 100644 --- a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php +++ b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php @@ -111,8 +111,7 @@ public function postFlush(): void private function gatherResourceAndItemTags(object $entity, bool $purgeItem): void { try { - $resourceClass = $this->resourceClassResolver->getResourceClass($entity); - $iri = $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, new GetCollection()); + $iri = $this->iriConverter->getIriFromResource($entity, UrlGeneratorInterface::ABS_PATH, new GetCollection()); $this->tags[$iri] = $iri; if ($purgeItem) { diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index b1bb70a3d09..936a0fc3135 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -121,7 +121,7 @@ public function getIriFromResource(object|string $resource, int $referenceType = $operation = $this->operationMetadataFactory->create($context['item_uri_template']); } - $localOperationCacheKey = ($operation?->getName() ?? '').$resourceClass.(\is_string($resource) ? '_c' : '_i'); + $localOperationCacheKey = ($operation?->getName() ?? '').$resourceClass.((\is_string($resource) || $operation instanceof CollectionOperationInterface) ? '_c' : '_i'); if ($operation && isset($this->localOperationCache[$localOperationCacheKey])) { return $this->generateSymfonyRoute($resource, $referenceType, $this->localOperationCache[$localOperationCacheKey], $context, $this->localIdentifiersExtractorOperationCache[$localOperationCacheKey] ?? null); } diff --git a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php index 2f15ca485a3..68d879b8257 100644 --- a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php +++ b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php @@ -65,19 +65,16 @@ public function testOnFlush(): void $purgerProphecy->purge(['/dummies', '/dummies/1', '/dummies/2', '/dummies/3', '/dummies/4'])->shouldBeCalled(); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, new GetCollection())->willReturn('/dummies')->shouldBeCalled(); - $iriConverterProphecy->getIriFromResource(DummyNoGetOperation::class, UrlGeneratorInterface::ABS_PATH, new GetCollection())->willThrow(new InvalidArgumentException())->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->willReturn('/dummies')->shouldBeCalled(); $iriConverterProphecy->getIriFromResource($toUpdate1)->willReturn('/dummies/1')->shouldBeCalled(); $iriConverterProphecy->getIriFromResource($toUpdate2)->willReturn('/dummies/2')->shouldBeCalled(); $iriConverterProphecy->getIriFromResource($toDelete1)->willReturn('/dummies/3')->shouldBeCalled(); $iriConverterProphecy->getIriFromResource($toDelete2)->willReturn('/dummies/4')->shouldBeCalled(); - $iriConverterProphecy->getIriFromResource($toDeleteNoPurge)->shouldNotBeCalled(); + $iriConverterProphecy->getIriFromResource(Argument::type(DummyNoGetOperation::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->willThrow(new InvalidArgumentException())->shouldBeCalled(); $iriConverterProphecy->getIriFromResource(Argument::any())->willThrow(new ItemNotFoundException()); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true)->shouldBeCalled(); - $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class)->shouldBeCalled(); - $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyNoGetOperation::class))->willReturn(DummyNoGetOperation::class)->shouldBeCalled(); $uowProphecy = $this->prophesize(UnitOfWork::class); $uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert1, $toInsert2])->shouldBeCalled(); @@ -105,6 +102,9 @@ public function testOnFlush(): void $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal()); $listener->onFlush($eventArgs); $listener->postFlush(); + + $iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->shouldHaveBeenCalled(); + $iriConverterProphecy->getIriFromResource(Argument::type(DummyNoGetOperation::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->shouldHaveBeenCalled(); } public function testPreUpdate(): void @@ -122,14 +122,13 @@ public function testPreUpdate(): void $purgerProphecy->purge(['/dummies', '/dummies/1', '/related_dummies/old', '/related_dummies/new'])->shouldBeCalled(); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, new GetCollection())->willReturn('/dummies')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->willReturn('/dummies')->shouldBeCalled(); $iriConverterProphecy->getIriFromResource($dummy)->willReturn('/dummies/1')->shouldBeCalled(); $iriConverterProphecy->getIriFromResource($oldRelatedDummy)->willReturn('/related_dummies/old')->shouldBeCalled(); $iriConverterProphecy->getIriFromResource($newRelatedDummy)->willReturn('/related_dummies/new')->shouldBeCalled(); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true)->shouldBeCalled(); - $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class)->shouldBeCalled(); $emProphecy = $this->prophesize(EntityManagerInterface::class); @@ -154,11 +153,11 @@ public function testNothingToPurge(): void $purgerProphecy->purge([])->shouldNotBeCalled(); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getIriFromResource(DummyNoGetOperation::class, UrlGeneratorInterface::ABS_PATH, new GetCollection())->willThrow(new InvalidArgumentException())->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource(Argument::type(DummyNoGetOperation::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->willThrow(new InvalidArgumentException())->shouldBeCalled(); $iriConverterProphecy->getIriFromResource($dummyNoGetOperation)->shouldNotBeCalled(); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyNoGetOperation::class))->willReturn(DummyNoGetOperation::class)->shouldBeCalled(); + $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyNoGetOperation::class))->willReturn(DummyNoGetOperation::class)->shouldNotBeCalled(); $emProphecy = $this->prophesize(EntityManagerInterface::class); @@ -176,18 +175,20 @@ public function testNothingToPurge(): void public function testNotAResourceClass(): void { $containNonResource = new ContainNonResource(); - $nonResource = new NotAResource('foo', 'bar'); + $nonResource1 = new NotAResource('foo', 'bar'); + $nonResource2 = new NotAResource('baz', 'qux'); + $collectionOfNotAResource = [$nonResource1, $nonResource2]; $purgerProphecy = $this->prophesize(PurgerInterface::class); - $purgerProphecy->purge([])->shouldNotBeCalled(); + $purgerProphecy->purge(['/dummies'])->shouldBeCalled(); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getIriFromResource(ContainNonResource::class, UrlGeneratorInterface::ABS_PATH, Argument::any())->willReturn('/dummies/1'); - $iriConverterProphecy->getIriFromResource($nonResource)->shouldNotBeCalled(); + $iriConverterProphecy->getIriFromResource(Argument::type(ContainNonResource::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->willReturn('/dummies')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($nonResource1)->willThrow(new InvalidArgumentException())->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($nonResource2)->willThrow(new InvalidArgumentException())->shouldBeCalled(); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(Argument::type(ContainNonResource::class))->willReturn(ContainNonResource::class)->shouldBeCalled(); - $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false)->shouldBeCalled(); + $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true)->shouldBeCalled(); $uowProphecy = $this->prophesize(UnitOfWork::class); $uowProphecy->getScheduledEntityInsertions()->willReturn([$containNonResource])->shouldBeCalled(); @@ -208,11 +209,59 @@ public function testNotAResourceClass(): void $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); $propertyAccessorProphecy->isReadable(Argument::type(ContainNonResource::class), 'notAResource')->willReturn(true); - $propertyAccessorProphecy->isReadable(Argument::type(ContainNonResource::class), 'collectionOfNotAResource')->shouldNotBeCalled(); - $propertyAccessorProphecy->getValue(Argument::type(ContainNonResource::class), 'notAResource')->shouldBeCalled()->willReturn($nonResource); - $propertyAccessorProphecy->getValue(Argument::type(ContainNonResource::class), 'collectionOfNotAResource')->shouldNotBeCalled(); + $propertyAccessorProphecy->isReadable(Argument::type(ContainNonResource::class), 'collectionOfNotAResource')->willReturn(true); + $propertyAccessorProphecy->getValue(Argument::type(ContainNonResource::class), 'notAResource')->shouldBeCalled()->willReturn($nonResource1); + $propertyAccessorProphecy->getValue(Argument::type(ContainNonResource::class), 'collectionOfNotAResource')->shouldBeCalled()->willReturn($collectionOfNotAResource); $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal()); $listener->onFlush($eventArgs); + $listener->postFlush(); + } + + public function testAddTagsForCollection(): void + { + $dummy1 = new Dummy(); + $dummy1->setId(1); + $dummy2 = new Dummy(); + $dummy2->setId(2); + $collection = [$dummy1, $dummy2]; + + $purgerProphecy = $this->prophesize(PurgerInterface::class); + $purgerProphecy->purge(['/dummies', '/dummies/1', '/dummies/2'])->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->willReturn('/dummies')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($dummy1)->willReturn('/dummies/1')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($dummy2)->willReturn('/dummies/2')->shouldBeCalled(); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true)->shouldBeCalled(); + + $dummyWithCollection = new Dummy(); + $dummyWithCollection->setId(3); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([$dummyWithCollection])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + + $dummyClassMetadata = new ClassMetadata(Dummy::class); + // @phpstan-ignore-next-line + $dummyClassMetadata->associationMappings = [ + 'relatedDummies' => ['targetEntity' => Dummy::class], + ]; + $emProphecy->getClassMetadata(Dummy::class)->willReturn($dummyClassMetadata)->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->isReadable(Argument::type(Dummy::class), 'relatedDummies')->willReturn(true); + $propertyAccessorProphecy->getValue(Argument::type(Dummy::class), 'relatedDummies')->willReturn($collection)->shouldBeCalled(); + + $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal()); + $listener->onFlush($eventArgs); + $listener->postFlush(); } } From 9c0dbb65319beb89193e653e14c99352ac529a55 Mon Sep 17 00:00:00 2001 From: Aleksey Polyvanyi Date: Thu, 22 May 2025 10:56:51 +0200 Subject: [PATCH 14/23] fix(state): do not expose FQCN in DeserializeProvider on PartialDenormalizationException (#7158) Co-authored-by: soyuka --- src/State/Provider/DeserializeProvider.php | 23 ++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/State/Provider/DeserializeProvider.php b/src/State/Provider/DeserializeProvider.php index be399f4f1bf..e7ad02c78b2 100644 --- a/src/State/Provider/DeserializeProvider.php +++ b/src/State/Provider/DeserializeProvider.php @@ -100,12 +100,13 @@ public function provide(Operation $operation, array $uriVariables = [], array $c if (!$exception instanceof NotNormalizableValueException) { continue; } - $message = (new Type($exception->getExpectedTypes() ?? []))->message; + $expectedTypes = $this->normalizeExpectedTypes($exception->getExpectedTypes()); + $message = (new Type($expectedTypes))->message; $parameters = []; if ($exception->canUseMessageForUser()) { $parameters['hint'] = $exception->getMessage(); } - $violations->add(new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $exception->getExpectedTypes() ?? [])], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR)); + $violations->add(new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $expectedTypes)], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR)); } if (0 !== \count($violations)) { throw new ValidationException($violations); @@ -114,4 +115,22 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $data; } + + private function normalizeExpectedTypes(?array $expectedTypes = null): array + { + $normalizedTypes = []; + + foreach ($expectedTypes ?? [] as $expectedType) { + $normalizedType = $expectedType; + + if (class_exists($expectedType) || interface_exists($expectedType)) { + $classReflection = new \ReflectionClass($expectedType); + $normalizedType = $classReflection->getShortName(); + } + + $normalizedTypes[] = $normalizedType; + } + + return $normalizedTypes; + } } From 937443d2d2bbd002f34d95f41e1e88a0e8f13724 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 10:56:20 +0200 Subject: [PATCH 15/23] chore: httpcache in commitlint configuration --- .commitlintrc | 1 + 1 file changed, 1 insertion(+) diff --git a/.commitlintrc b/.commitlintrc index 886c4606731..c6faa81fd6e 100644 --- a/.commitlintrc +++ b/.commitlintrc @@ -13,6 +13,7 @@ "elasticsearch", "mongodb", "jsonld", + "httpcache", "hydra", "jsonapi", "graphql", From f789860009888582791611a31d6084c620050615 Mon Sep 17 00:00:00 2001 From: Aleksey Polyvanyi Date: Thu, 22 May 2025 12:03:44 +0200 Subject: [PATCH 16/23] fix(serializer): exception message to not expose resource FQCN (#7156) --- src/Serializer/AbstractItemNormalizer.php | 6 +-- .../Tests/AbstractItemNormalizerTest.php | 41 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 7f7d83c0e0d..a2683fdf3a6 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -227,13 +227,13 @@ public function denormalize(mixed $data, string $class, ?string $format = null, throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e); } - throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" resource "string" (IRI), "%s" given.', $resourceClass, \gettype($data)), $data, [$resourceClass], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); + throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [$resourceClass], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); } catch (InvalidArgumentException $e) { if (!isset($context['not_normalizable_value_exceptions'])) { throw new UnexpectedValueException(\sprintf('Invalid IRI "%s".', $data), $e->getCode(), $e); } - throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" resource "string" (IRI), "%s" given.', $resourceClass, \gettype($data)), $data, [$resourceClass], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Invalid IRI "%s".', $data), $data, [$resourceClass], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); } } @@ -591,7 +591,7 @@ protected function denormalizeRelation(string $attributeName, ApiProperty $prope throw new UnexpectedValueException(\sprintf('Invalid IRI "%s".', $value), $e->getCode(), $e); } - throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $value, [$className], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Invalid IRI "%s".', $value), $value, [$className], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); } } diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 8f627518129..bef59532d8e 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\IriConverterInterface; @@ -910,11 +911,13 @@ public function testDeserializationPathForNotDenormalizableRelations(): void $this->assertCount(1, $errors); // @phpstan-ignore-line method.impossibleType (false positive) $this->assertInstanceOf(NotNormalizableValueException::class, $errors[0]); $this->assertSame('relatedDummies[0]', $errors[0]->getPath()); + $this->assertSame('Invalid IRI "wrong".', $errors[0]->getMessage()); } public function testDeserializationPathForNotDenormalizableResource(): void { $this->expectException(NotNormalizableValueException::class); + $this->expectExceptionMessage('Invalid IRI "wrong IRI".'); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); @@ -949,6 +952,44 @@ public function testDeserializationPathForNotDenormalizableResource(): void $normalizer->denormalize('wrong IRI', Dummy::class, null, ['not_normalizable_value_exceptions' => []]); } + public function testDeserializationPathForNotFoundResource(): void + { + $this->expectException(NotNormalizableValueException::class); + $this->expectExceptionMessage('Some item not found exception.'); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getResourceFromIri(Argument::cetera())->willThrow(new ItemNotFoundException('Some item not found exception.')); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + null, + null, + [], + null, + null, + ]); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $normalizer->denormalize('/some-iri', Dummy::class, null, ['not_normalizable_value_exceptions' => []]); + } + public function testInnerDocumentNotAllowed(): void { $this->expectException(UnexpectedValueException::class); From 39123942a0e1ff9dfb1e46f4a410160e3cd3fbd7 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 14:11:29 +0200 Subject: [PATCH 17/23] fix(laravel): register error handler without graphql see https://github.com/api-platform/api-platform/discussions/2885 --- src/Laravel/ApiPlatformProvider.php | 40 ++++++++++++++--------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 7cc8b6aa6ab..c9931426fdd 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -927,6 +927,26 @@ public function register(): void return new Inflector(); }); + $this->app->singleton( + ExceptionHandlerInterface::class, + function (Application $app) { + /** @var ConfigRepository */ + $config = $app['config']; + + return new ErrorHandler( + $app, + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ApiPlatformController::class), + $app->make(IdentifiersExtractorInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(Negotiator::class), + $config->get('api-platform.exception_to_status'), + $config->get('app.debug'), + $config->get('api-platform.error_formats') + ); + } + ); + if ($this->app->runningInConsole()) { $this->commands([ Console\InstallCommand::class, @@ -1148,26 +1168,6 @@ private function registerGraphQl(): void formats: $config->get('api-platform.formats') ); }); - - $this->app->singleton( - ExceptionHandlerInterface::class, - function (Application $app) { - /** @var ConfigRepository */ - $config = $app['config']; - - return new ErrorHandler( - $app, - $app->make(ResourceMetadataCollectionFactoryInterface::class), - $app->make(ApiPlatformController::class), - $app->make(IdentifiersExtractorInterface::class), - $app->make(ResourceClassResolverInterface::class), - $app->make(Negotiator::class), - $config->get('api-platform.exception_to_status'), - $config->get('app.debug'), - $config->get('api-platform.error_formats') - ); - } - ); } /** From 383ac88d625a16ee696997da3d490c4268f4c389 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 14:14:29 +0200 Subject: [PATCH 18/23] docs: changelog 4.1.10 --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f606ee6bfaf..2a46bc18190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## v4.1.10 + +### Bug fixes + +* [329acf21e](https://github.com/api-platform/core/commit/329acf21e3a8618136a21b9121c5891f1fe6b9e8) fix(metadata): infer parameter string type from schema (#7161) +* [5459ba375](https://github.com/api-platform/core/commit/5459ba375b0e7ffd1c783a6e18a6452769eaff46) fix(metadata): parameter cast to array flag (#7160) +* [fe73002bf](https://github.com/api-platform/core/commit/fe73002bf5ae64adb1eb9e310dc62ff158de094d) fix(laravel): duplicated property names +* [b0390080e](https://github.com/api-platform/core/commit/b0390080e90be9ac494c8b4d968e59a4962f32ca) fix(laravel): name convert validation property path +* [730d17a30](https://github.com/api-platform/core/commit/730d17a306df4b92082484a19e82c5e150537331) fix(laravel): validate the model instead of body +* [4d66f5ef3](https://github.com/api-platform/core/commit/4d66f5ef313fe3857611e8345702a10019c79ec5) fix(laravel): persist embeded relations with groups +* [39123942a](https://github.com/api-platform/core/commit/39123942a0e1ff9dfb1e46f4a410160e3cd3fbd7) fix(laravel): register error handler without graphql +* [fd010ea1b](https://github.com/api-platform/core/commit/fd010ea1be86073f5d8905d5640846390ade7ce6) fix(openapi): nullable externalDocs return type +* [470c2e8bd](https://github.com/api-platform/core/commit/470c2e8bdf8d7a7502c02d2b681ced2001e2c1cc) fix(httpcache): iri cache tag for collection operation with path parameter +* [9c0dbb653](https://github.com/api-platform/core/commit/9c0dbb65319beb89193e653e14c99352ac529a55) fix(state): do not expose FQCN in DeserializeProvider on PartialDenormalizationException (#7158) +* [f78986000](https://github.com/api-platform/core/commit/f789860009888582791611a31d6084c620050615) fix(serializer): exception message to not expose resource FQCN (#7156) + +### Features + +* [767fa926b](https://github.com/api-platform/core/commit/767fa926b10bef771e896300b8e796287392d8c0) feat(laravel): add name_converter option + ## v4.1.9 ### Bug fixes From e7b3df11e4591f35400c691f6cf016304dce982e Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 22 May 2025 14:59:48 +0200 Subject: [PATCH 19/23] fix(laravel): register error handler without graphql (#7168) --- src/Laravel/ApiPlatformDeferredProvider.php | 26 +++++++++++++++++++++ src/Laravel/ApiPlatformProvider.php | 23 ------------------ 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/Laravel/ApiPlatformDeferredProvider.php b/src/Laravel/ApiPlatformDeferredProvider.php index 5fdaf23441c..925aba6e97a 100644 --- a/src/Laravel/ApiPlatformDeferredProvider.php +++ b/src/Laravel/ApiPlatformDeferredProvider.php @@ -22,6 +22,7 @@ use ApiPlatform\GraphQl\Type\TypesContainerInterface; use ApiPlatform\JsonApi\Filter\SparseFieldset; use ApiPlatform\JsonApi\Filter\SparseFieldsetParameterProvider; +use ApiPlatform\Laravel\Controller\ApiPlatformController; use ApiPlatform\Laravel\Eloquent\Extension\FilterQueryExtension; use ApiPlatform\Laravel\Eloquent\Extension\QueryExtensionInterface; use ApiPlatform\Laravel\Eloquent\Filter\BooleanFilter; @@ -42,11 +43,13 @@ use ApiPlatform\Laravel\Eloquent\State\LinksHandlerInterface; use ApiPlatform\Laravel\Eloquent\State\PersistProcessor; use ApiPlatform\Laravel\Eloquent\State\RemoveProcessor; +use ApiPlatform\Laravel\Exception\ErrorHandler; use ApiPlatform\Laravel\Metadata\CacheResourceCollectionMetadataFactory; use ApiPlatform\Laravel\Metadata\ParameterValidationResourceMetadataCollectionFactory; use ApiPlatform\Laravel\State\ParameterValidatorProvider; use ApiPlatform\Laravel\State\SwaggerUiProcessor; use ApiPlatform\Laravel\State\ValidateProvider; +use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\InflectorInterface; use ApiPlatform\Metadata\Operation\PathSegmentNameGeneratorInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -80,9 +83,11 @@ use ApiPlatform\State\Provider\ParameterProvider; use ApiPlatform\State\Provider\SecurityParameterProvider; use ApiPlatform\State\ProviderInterface; +use Illuminate\Contracts\Debug\ExceptionHandler as ExceptionHandlerInterface; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Support\DeferrableProvider; use Illuminate\Support\ServiceProvider; +use Negotiation\Negotiator; use Psr\Log\LoggerInterface; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -289,6 +294,26 @@ private function registerGraphQl(): void $app->make(InflectorInterface::class) ); }); + + $this->app->singleton( + ExceptionHandlerInterface::class, + function (Application $app) { + /** @var ConfigRepository */ + $config = $app['config']; + + return new ErrorHandler( + $app, + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ApiPlatformController::class), + $app->make(IdentifiersExtractorInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(Negotiator::class), + $config->get('api-platform.exception_to_status'), + $config->get('app.debug'), + $config->get('api-platform.error_formats') + ); + } + ); } /** @@ -329,6 +354,7 @@ public function provides(): array ResourceMetadataCollectionFactoryInterface::class, 'api_platform.graphql.state_provider.parameter', FieldsBuilderEnumInterface::class, + ExceptionHandlerInterface::class, ]; } } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index c9931426fdd..e3c7e6691a4 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -76,7 +76,6 @@ use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\Laravel\ApiResource\Error; use ApiPlatform\Laravel\ApiResource\ValidationError; -use ApiPlatform\Laravel\Controller\ApiPlatformController; use ApiPlatform\Laravel\Controller\DocumentationController; use ApiPlatform\Laravel\Controller\EntrypointController; use ApiPlatform\Laravel\Eloquent\Filter\JsonApi\SortFilterParameterProvider; @@ -88,7 +87,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\Exception\ErrorHandler; use ApiPlatform\Laravel\GraphQl\Controller\EntrypointController as GraphQlEntrypointController; use ApiPlatform\Laravel\GraphQl\Controller\GraphiQlController; use ApiPlatform\Laravel\JsonApi\State\JsonApiProvider; @@ -156,7 +154,6 @@ use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; use Illuminate\Config\Repository as ConfigRepository; -use Illuminate\Contracts\Debug\ExceptionHandler as ExceptionHandlerInterface; use Illuminate\Contracts\Foundation\Application; use Illuminate\Routing\Router; use Illuminate\Support\ServiceProvider; @@ -927,26 +924,6 @@ public function register(): void return new Inflector(); }); - $this->app->singleton( - ExceptionHandlerInterface::class, - function (Application $app) { - /** @var ConfigRepository */ - $config = $app['config']; - - return new ErrorHandler( - $app, - $app->make(ResourceMetadataCollectionFactoryInterface::class), - $app->make(ApiPlatformController::class), - $app->make(IdentifiersExtractorInterface::class), - $app->make(ResourceClassResolverInterface::class), - $app->make(Negotiator::class), - $config->get('api-platform.exception_to_status'), - $config->get('app.debug'), - $config->get('api-platform.error_formats') - ); - } - ); - if ($this->app->runningInConsole()) { $this->commands([ Console\InstallCommand::class, From 30f8bcc47a45ec500b49197ab9cd12584856b2bb Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 15:13:30 +0200 Subject: [PATCH 20/23] chore: bump patch dependencies --- composer.json | 2 +- src/Doctrine/Common/composer.json | 4 ++-- src/Doctrine/Odm/composer.json | 6 +++--- src/Doctrine/Orm/composer.json | 6 +++--- src/Documentation/composer.json | 2 +- src/Elasticsearch/composer.json | 6 +++--- src/GraphQl/composer.json | 6 +++--- src/Hal/composer.json | 6 +++--- src/HttpCache/composer.json | 4 ++-- src/Hydra/composer.json | 12 ++++++------ src/JsonApi/composer.json | 10 +++++----- src/JsonLd/composer.json | 6 +++--- src/JsonSchema/composer.json | 2 +- src/Laravel/composer.json | 20 ++++++++++---------- src/OpenApi/composer.json | 6 +++--- src/RamseyUuid/composer.json | 2 +- src/Serializer/composer.json | 4 ++-- src/State/composer.json | 2 +- src/Symfony/composer.json | 20 ++++++++++---------- src/Validator/composer.json | 2 +- 20 files changed, 64 insertions(+), 64 deletions(-) diff --git a/composer.json b/composer.json index b85f01b0023..948d2a18958 100644 --- a/composer.json +++ b/composer.json @@ -156,7 +156,7 @@ "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", "soyuka/contexts": "^3.3.10", - "soyuka/pmu": "^0.1.0", + "soyuka/pmu": "^0.2.0", "soyuka/stubs-mongodb": "^1.0", "symfony/asset": "^6.4 || ^7.0", "symfony/browser-kit": "^6.4 || ^7.0", diff --git a/src/Doctrine/Common/composer.json b/src/Doctrine/Common/composer.json index cfa972c09ff..a4ba70d9036 100644 --- a/src/Doctrine/Common/composer.json +++ b/src/Doctrine/Common/composer.json @@ -24,8 +24,8 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.1", - "api-platform/state": "^4.1.8", + "api-platform/metadata": "^4.1.11", + "api-platform/state": "^4.1.11", "doctrine/collections": "^2.1", "doctrine/common": "^3.2.2", "doctrine/persistence": "^3.2" diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json index dc1c2553e9b..d19f98883e6 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -25,9 +25,9 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.1", - "api-platform/metadata": "^4.1", - "api-platform/state": "^4.1.8", + "api-platform/doctrine-common": "^4.1.11", + "api-platform/metadata": "^4.1.11", + "api-platform/state": "^4.1.11", "doctrine/mongodb-odm": "^2.10", "symfony/property-info": "^6.4 || ^7.1" }, diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index 6972438cfc1..7ab550643e3 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -24,9 +24,9 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.1", - "api-platform/metadata": "^4.1", - "api-platform/state": "^4.1.8", + "api-platform/doctrine-common": "^4.1.11", + "api-platform/metadata": "^4.1.11", + "api-platform/state": "^4.1.11", "doctrine/orm": "^2.17 || ^3.0", "symfony/property-info": "^6.4 || ^7.1" }, diff --git a/src/Documentation/composer.json b/src/Documentation/composer.json index 26b76eab900..d70d2f0d78f 100644 --- a/src/Documentation/composer.json +++ b/src/Documentation/composer.json @@ -21,7 +21,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.1" + "api-platform/metadata": "^4.1.11" }, "extra": { "branch-alias": { diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json index 62efc8c98ba..0a438e12f26 100644 --- a/src/Elasticsearch/composer.json +++ b/src/Elasticsearch/composer.json @@ -24,9 +24,9 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.1", - "api-platform/serializer": "^4.1", - "api-platform/state": "^4.1", + "api-platform/metadata": "^4.1.11", + "api-platform/serializer": "^4.1.11", + "api-platform/state": "^4.1.11", "elasticsearch/elasticsearch": "^7.17 || ^8.4", "symfony/cache": "^6.4 || ^7.0", "symfony/console": "^6.4 || ^7.0", diff --git a/src/GraphQl/composer.json b/src/GraphQl/composer.json index 1641e50219a..f160480d968 100644 --- a/src/GraphQl/composer.json +++ b/src/GraphQl/composer.json @@ -21,9 +21,9 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.1", - "api-platform/state": "^4.1", - "api-platform/serializer": "^4.1", + "api-platform/metadata": "^4.1.11", + "api-platform/state": "^4.1.11", + "api-platform/serializer": "^4.1.11", "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", "webonyx/graphql-php": "^15.0", diff --git a/src/Hal/composer.json b/src/Hal/composer.json index 034ede545c3..6f1b89be80c 100644 --- a/src/Hal/composer.json +++ b/src/Hal/composer.json @@ -22,9 +22,9 @@ ], "require": { "php": ">=8.2", - "api-platform/state": "^4.1", - "api-platform/metadata": "^4.1", - "api-platform/serializer": "^4.1" + "api-platform/state": "^4.1.11", + "api-platform/metadata": "^4.1.11", + "api-platform/serializer": "^4.1.11" }, "autoload": { "psr-4": { diff --git a/src/HttpCache/composer.json b/src/HttpCache/composer.json index 05c8a921d8a..2c35092efe5 100644 --- a/src/HttpCache/composer.json +++ b/src/HttpCache/composer.json @@ -23,8 +23,8 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.1", - "api-platform/state": "^4.1", + "api-platform/metadata": "^4.1.11", + "api-platform/state": "^4.1.11", "symfony/http-foundation": "^6.4 || ^7.0" }, "require-dev": { diff --git a/src/Hydra/composer.json b/src/Hydra/composer.json index b36de13b4f3..1346d7ac387 100644 --- a/src/Hydra/composer.json +++ b/src/Hydra/composer.json @@ -25,12 +25,12 @@ ], "require": { "php": ">=8.2", - "api-platform/state": "^4.1", - "api-platform/documentation": "^4.1", - "api-platform/metadata": "^4.1", - "api-platform/jsonld": "^4.1", - "api-platform/json-schema": "^4.1", - "api-platform/serializer": "^4.1", + "api-platform/state": "^4.1.11", + "api-platform/documentation": "^4.1.11", + "api-platform/metadata": "^4.1.11", + "api-platform/jsonld": "^4.1.11", + "api-platform/json-schema": "^4.1.11", + "api-platform/serializer": "^4.1.11", "symfony/web-link": "^6.4 || ^7.1" }, "require-dev": { diff --git a/src/JsonApi/composer.json b/src/JsonApi/composer.json index 0b3aa7d10da..402375834c4 100644 --- a/src/JsonApi/composer.json +++ b/src/JsonApi/composer.json @@ -22,11 +22,11 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.1", - "api-platform/json-schema": "^4.1", - "api-platform/metadata": "^4.1", - "api-platform/serializer": "^4.1", - "api-platform/state": "^4.1", + "api-platform/documentation": "^4.1.11", + "api-platform/json-schema": "^4.1.11", + "api-platform/metadata": "^4.1.11", + "api-platform/serializer": "^4.1.11", + "api-platform/state": "^4.1.11", "symfony/error-handler": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4 || ^7.0" }, diff --git a/src/JsonLd/composer.json b/src/JsonLd/composer.json index e95c4ccdd53..afc7f063612 100644 --- a/src/JsonLd/composer.json +++ b/src/JsonLd/composer.json @@ -24,9 +24,9 @@ ], "require": { "php": ">=8.2", - "api-platform/state": "^4.1", - "api-platform/metadata": "^4.1", - "api-platform/serializer": "^4.1" + "api-platform/state": "^4.1.11", + "api-platform/metadata": "^4.1.11", + "api-platform/serializer": "^4.1.11" }, "autoload": { "psr-4": { diff --git a/src/JsonSchema/composer.json b/src/JsonSchema/composer.json index ebdc30a4c5f..72ce292f4d9 100644 --- a/src/JsonSchema/composer.json +++ b/src/JsonSchema/composer.json @@ -25,7 +25,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.1", + "api-platform/metadata": "^4.1.11", "symfony/console": "^6.4 || ^7.0", "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index a0c2c5b01ee..33c3cd45308 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -28,16 +28,16 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.1", - "api-platform/hydra": "^4.1", - "api-platform/json-hal": "^4.1", - "api-platform/json-schema": "^4.1", - "api-platform/jsonld": "^4.1", - "api-platform/json-api": "^4.1", - "api-platform/metadata": "^4.1", - "api-platform/openapi": "^4.1", - "api-platform/serializer": "^4.1", - "api-platform/state": "^4.1", + "api-platform/documentation": "^4.1.11", + "api-platform/hydra": "^4.1.11", + "api-platform/json-hal": "^4.1.11", + "api-platform/json-schema": "^4.1.11", + "api-platform/jsonld": "^4.1.11", + "api-platform/json-api": "^4.1.11", + "api-platform/metadata": "^4.1.11", + "api-platform/openapi": "^4.1.11", + "api-platform/serializer": "^4.1.11", + "api-platform/state": "^4.1.11", "illuminate/config": "^11.0 || ^12.0", "laravel/framework": "^11.0 || ^12.0", "illuminate/contracts": "^11.0 || ^12.0", diff --git a/src/OpenApi/composer.json b/src/OpenApi/composer.json index 86cd21e97c6..b69f290c553 100644 --- a/src/OpenApi/composer.json +++ b/src/OpenApi/composer.json @@ -28,9 +28,9 @@ ], "require": { "php": ">=8.2", - "api-platform/json-schema": "^4.1", - "api-platform/metadata": "^4.1", - "api-platform/state": "^4.1", + "api-platform/json-schema": "^4.1.11", + "api-platform/metadata": "^4.1.11", + "api-platform/state": "^4.1.11", "symfony/console": "^6.4 || ^7.0", "symfony/filesystem": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", diff --git a/src/RamseyUuid/composer.json b/src/RamseyUuid/composer.json index 2aef2b6b1ef..8c0f69ec7ae 100644 --- a/src/RamseyUuid/composer.json +++ b/src/RamseyUuid/composer.json @@ -23,7 +23,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.1", + "api-platform/metadata": "^4.1.11", "symfony/serializer": "^6.4 || ^7.0" }, "require-dev": { diff --git a/src/Serializer/composer.json b/src/Serializer/composer.json index d711560e337..ea8dcdf35cc 100644 --- a/src/Serializer/composer.json +++ b/src/Serializer/composer.json @@ -23,8 +23,8 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.1", - "api-platform/state": "^4.1", + "api-platform/metadata": "^4.1.11", + "api-platform/state": "^4.1.11", "symfony/property-access": "^6.4 || ^7.0", "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", diff --git a/src/State/composer.json b/src/State/composer.json index bdbc1aa9d6d..692759d223f 100644 --- a/src/State/composer.json +++ b/src/State/composer.json @@ -28,7 +28,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.1", + "api-platform/metadata": "^4.1.11", "psr/container": "^1.0 || ^2.0", "symfony/http-kernel": "^6.4 || ^7.0" }, diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index a41df513db6..3125eb25ad5 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -29,16 +29,16 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.1", - "api-platform/http-cache": "^4.1", - "api-platform/json-schema": "^4.1", - "api-platform/jsonld": "^4.1", - "api-platform/hydra": "^4.1", - "api-platform/metadata": "^4.1", - "api-platform/serializer": "^4.1", - "api-platform/state": "^4.1", - "api-platform/validator": "^4.1", - "api-platform/openapi": "^4.1", + "api-platform/documentation": "^4.1.11", + "api-platform/http-cache": "^4.1.11", + "api-platform/json-schema": "^4.1.11", + "api-platform/jsonld": "^4.1.11", + "api-platform/hydra": "^4.1.11", + "api-platform/metadata": "^4.1.11", + "api-platform/serializer": "^4.1.11", + "api-platform/state": "^4.1.11", + "api-platform/validator": "^4.1.11", + "api-platform/openapi": "^4.1.11", "symfony/property-info": "^6.4 || ^7.1", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", diff --git a/src/Validator/composer.json b/src/Validator/composer.json index d473960fbe1..aa72da18130 100644 --- a/src/Validator/composer.json +++ b/src/Validator/composer.json @@ -23,7 +23,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.1", + "api-platform/metadata": "^4.1.11", "symfony/type-info": "^7.2", "symfony/web-link": "^6.4 || ^7.1" }, From c5e959d7bc774d465726bcea94663a7edfa50dc6 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 15:14:52 +0200 Subject: [PATCH 21/23] docs: changelog 4.1.11 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a46bc18190..47ca42e7271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v4.1.11 + +### Bug fixes + +* [e7b3df11e](https://github.com/api-platform/core/commit/e7b3df11e4591f35400c691f6cf016304dce982e) fix(laravel): register error handler without graphql (#7168) + ## v4.1.10 ### Bug fixes From b14a463a9da8a285eba1b0adc63ca4121efb0dce Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 15:25:07 +0200 Subject: [PATCH 22/23] fix(laravel): register error handler without graphql --- src/Laravel/ApiPlatformDeferredProvider.php | 40 ++++++++++----------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Laravel/ApiPlatformDeferredProvider.php b/src/Laravel/ApiPlatformDeferredProvider.php index 925aba6e97a..18e549f06f5 100644 --- a/src/Laravel/ApiPlatformDeferredProvider.php +++ b/src/Laravel/ApiPlatformDeferredProvider.php @@ -252,6 +252,26 @@ public function register(): void ); }); + $this->app->singleton( + ExceptionHandlerInterface::class, + function (Application $app) { + /** @var ConfigRepository */ + $config = $app['config']; + + return new ErrorHandler( + $app, + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ApiPlatformController::class), + $app->make(IdentifiersExtractorInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(Negotiator::class), + $config->get('api-platform.exception_to_status'), + $config->get('app.debug'), + $config->get('api-platform.error_formats') + ); + } + ); + if (interface_exists(FieldsBuilderEnumInterface::class)) { $this->registerGraphQl(); } @@ -294,26 +314,6 @@ private function registerGraphQl(): void $app->make(InflectorInterface::class) ); }); - - $this->app->singleton( - ExceptionHandlerInterface::class, - function (Application $app) { - /** @var ConfigRepository */ - $config = $app['config']; - - return new ErrorHandler( - $app, - $app->make(ResourceMetadataCollectionFactoryInterface::class), - $app->make(ApiPlatformController::class), - $app->make(IdentifiersExtractorInterface::class), - $app->make(ResourceClassResolverInterface::class), - $app->make(Negotiator::class), - $config->get('api-platform.exception_to_status'), - $config->get('app.debug'), - $config->get('api-platform.error_formats') - ); - } - ); } /** From abb1fac0c6201764589a8f22cb3e1ffd7e02aa16 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 15:26:31 +0200 Subject: [PATCH 23/23] docs: changelog 4.1.12 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47ca42e7271..07997fd9a48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ # Changelog -## v4.1.11 +## v4.1.11 - v4.1.12 ### Bug fixes -* [e7b3df11e](https://github.com/api-platform/core/commit/e7b3df11e4591f35400c691f6cf016304dce982e) fix(laravel): register error handler without graphql (#7168) +* [b14a463a9](https://github.com/api-platform/core/commit/b14a463a9da8a285eba1b0adc63ca4121efb0dce) fix(laravel): register error handler without graphql ## v4.1.10