From 884b0331f1281b8ca5386ddf4e562ba16c3f6d59 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 4 Oct 2024 09:31:23 +0200 Subject: [PATCH 01/16] fix: errors hydra prefix removed --- features/mongodb/filters.feature | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/features/mongodb/filters.feature b/features/mongodb/filters.feature index aad34147c69..8761ee02822 100644 --- a/features/mongodb/filters.feature +++ b/features/mongodb/filters.feature @@ -12,9 +12,9 @@ Feature: Filters on collections And the response should be in JSON And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "@context" should be equal to "/contexts/Error" - And the JSON node "@type" should be equal to "hydra:Error" - And the JSON node "hydra:title" should be equal to "An error occurred" - And the JSON node "hydra:description" should be equal to "Cannot use reference 'badFourthLevel' in class 'ThirdLevel' for lookup or graphLookup: dbRef references are not supported." + And the JSON node "@type" should be equal to "Error" + And the JSON node "title" should be equal to "An error occurred" + And the JSON node "description" should be equal to "Cannot use reference 'badFourthLevel' in class 'ThirdLevel' for lookup or graphLookup: dbRef references are not supported." And the JSON node "trace" should exist Scenario: Error when getting collection with nested properties if references are not correctly stored (not owning side) @@ -23,7 +23,7 @@ Feature: Filters on collections And the response should be in JSON And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "@context" should be equal to "/contexts/Error" - And the JSON node "@type" should be equal to "hydra:Error" - And the JSON node "hydra:title" should be equal to "An error occurred" - And the JSON node "hydra:description" should be equal to "Cannot use reference 'badThirdLevel' in class 'FourthLevel' for lookup or graphLookup: dbRef references are not supported." + And the JSON node "@type" should be equal to "Error" + And the JSON node "title" should be equal to "An error occurred" + And the JSON node "description" should be equal to "Cannot use reference 'badThirdLevel' in class 'FourthLevel' for lookup or graphLookup: dbRef references are not supported." And the JSON node "trace" should exist From 1e561122d234fa98a13fd9058222ea10e414e28b Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 2 Oct 2024 15:08:57 +0200 Subject: [PATCH 02/16] chore: remove useless require-dev --- .github/workflows/ci.yml | 1 + composer.json | 20 +------------------- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08499f48efa..6b4df6def02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,7 @@ jobs: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} restore-keys: ${{ runner.os }}-composer- + - run: composer validate - name: Update project dependencies run: | composer global require soyuka/pmu diff --git a/composer.json b/composer.json index cb1413fdf9d..ff7ae164554 100644 --- a/composer.json +++ b/composer.json @@ -115,24 +115,6 @@ "willdurand/negotiation": "^3.1" }, "require-dev": { - "api-platform/doctrine-common": "^3.4 || ^4.0", - "api-platform/doctrine-odm": "^3.4 || ^4.0", - "api-platform/doctrine-orm": "^3.4 || ^4.0", - "api-platform/documentation": "^3.4 || ^4.0", - "api-platform/elasticsearch": "^3.4 || ^4.0", - "api-platform/graphql": "^3.4 || ^4.0", - "api-platform/http-cache": "^3.4 || ^4.0", - "api-platform/hydra": "^3.4 || ^4.0", - "api-platform/json-api": "^3.3 || ^4.0", - "api-platform/json-schema": "^3.4 || ^4.0", - "api-platform/jsonld": "^3.4 || ^4.0", - "api-platform/metadata": "^3.4 || ^4.0", - "api-platform/openapi": "^3.4 || ^4.0", - "api-platform/ramsey-uuid": "^3.4 || ^4.0", - "api-platform/json-hal": "^3.4 || ^4.0", - "api-platform/serializer": "^3.4 || ^4.0", - "api-platform/state": "^3.4 || ^4.0", - "api-platform/validator": "^3.4 || ^4.0", "behat/behat": "^3.11", "behat/mink": "^1.9", "doctrine/cache": "^1.11 || ^2.1", @@ -170,7 +152,7 @@ "ramsey/uuid": "^4.0", "ramsey/uuid-doctrine": "^2.0", "soyuka/contexts": "^3.3.10", - "soyuka/pmu": "^0.0.12", + "soyuka/pmu": "^0.0.15", "soyuka/stubs-mongodb": "^1.0", "symfony/asset": "^6.4 || ^7.0", "symfony/browser-kit": "^6.4 || ^7.0", From b93ee467c69253e0cfe60e75b48a5c7aa683474a Mon Sep 17 00:00:00 2001 From: Grzegorz Sadowski Date: Fri, 4 Oct 2024 11:07:48 +0200 Subject: [PATCH 03/16] fix: overwriting XML ApiResource definition by YAML ApiResource definition (#6660) --- .../Factory/ExtractorResourceMetadataCollectionFactory.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Metadata/Resource/Factory/ExtractorResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ExtractorResourceMetadataCollectionFactory.php index 75aaace146a..391eca8c491 100644 --- a/src/Metadata/Resource/Factory/ExtractorResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ExtractorResourceMetadataCollectionFactory.php @@ -53,14 +53,14 @@ public function create(string $resourceClass): ResourceMetadataCollection return $resourceMetadataCollection; } - foreach ($this->buildResources($resources, $resourceClass) as $i => $resource) { + foreach ($this->buildResources($resources, $resourceClass) as $resource) { foreach ($this->defaults['attributes'] ?? [] as $key => $value) { if (method_exists($resource, 'get'.ucfirst($key)) && !$resource->{'get'.ucfirst($key)}()) { $resource = $resource->{'with'.ucfirst($key)}($value); } } - $resourceMetadataCollection[$i] = $resource; + $resourceMetadataCollection[] = $resource; } return $resourceMetadataCollection; From 15d61c4b75fea2b365e0852a923fed8efbae6ab8 Mon Sep 17 00:00:00 2001 From: Grzegorz Sadowski Date: Fri, 4 Oct 2024 11:08:29 +0200 Subject: [PATCH 04/16] fix: using parameters in fromClass and toClass uriVariables' options (#6663) --- src/Metadata/Extractor/XmlResourceExtractor.php | 4 ++-- src/Metadata/Extractor/YamlResourceExtractor.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index 302a0194a03..315f7dd0082 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -271,10 +271,10 @@ private function buildUriVariables(\SimpleXMLElement $resource): ?array if ($toProperty = $this->phpize($data, 'toProperty', 'string')) { $uriVariables[$parameterName]['to_property'] = $toProperty; } - if ($fromClass = $this->phpize($data, 'fromClass', 'string')) { + if ($fromClass = $this->resolve($this->phpize($data, 'fromClass', 'string'))) { $uriVariables[$parameterName]['from_class'] = $fromClass; } - if ($toClass = $this->phpize($data, 'toClass', 'string')) { + if ($toClass = $this->resolve($this->phpize($data, 'toClass', 'string'))) { $uriVariables[$parameterName]['to_class'] = $toClass; } if (isset($data->identifiers->values)) { diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index 6ba6ea3f688..8bbb03bed69 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -193,13 +193,13 @@ private function buildUriVariables(array $resource): ?array unset($data[0], $data[1]); } if (isset($data['fromClass'])) { - $uriVariables[$parameterName]['from_class'] = $data['fromClass']; + $uriVariables[$parameterName]['from_class'] = $this->resolve($data['fromClass']); } if (isset($data['fromProperty'])) { $uriVariables[$parameterName]['from_property'] = $data['fromProperty']; } if (isset($data['toClass'])) { - $uriVariables[$parameterName]['to_class'] = $data['toClass']; + $uriVariables[$parameterName]['to_class'] = $this->resolve($data['toClass']); } if (isset($data['toProperty'])) { $uriVariables[$parameterName]['to_property'] = $data['toProperty']; From afe7d47d7b7ba6c8591bfb60137a65d1fa1fe38f Mon Sep 17 00:00:00 2001 From: Grzegorz Sadowski Date: Fri, 4 Oct 2024 11:09:28 +0200 Subject: [PATCH 05/16] fix: passing class as parameter in XML ApiResource's definition (#6659) --- src/Metadata/Extractor/XmlResourceExtractor.php | 1 - src/Metadata/Tests/Extractor/XmlExtractorTest.php | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index 315f7dd0082..143c3b77250 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -61,7 +61,6 @@ protected function extractPath(string $path): void foreach ($xml->resource as $resource) { $base = $this->buildExtendedBase($resource); $this->resources[$this->resolve((string) $resource['class'])][] = array_merge($base, [ - 'class' => $this->phpize($resource, 'class', 'string'), 'operations' => $this->buildOperations($resource, $base), 'graphQlOperations' => $this->buildGraphQlOperations($resource, $base), ]); diff --git a/src/Metadata/Tests/Extractor/XmlExtractorTest.php b/src/Metadata/Tests/Extractor/XmlExtractorTest.php index be785206c8e..b83f0ce1018 100644 --- a/src/Metadata/Tests/Extractor/XmlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/XmlExtractorTest.php @@ -95,7 +95,6 @@ public function testValidXML(): void 'extraProperties' => null, 'operations' => null, 'graphQlOperations' => null, - 'class' => Comment::class, 'processor' => null, 'provider' => null, 'read' => null, @@ -394,7 +393,6 @@ public function testValidXML(): void ], ], 'graphQlOperations' => null, - 'class' => Comment::class, 'processor' => null, 'provider' => null, 'read' => null, From f839c938154e1e3cc29b585957c7b58c162039c4 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 4 Oct 2024 11:42:08 +0200 Subject: [PATCH 06/16] doc: missing install command --- src/Laravel/CONTRIBUTING.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Laravel/CONTRIBUTING.md b/src/Laravel/CONTRIBUTING.md index b8f2528f14e..7e6574ad2e4 100644 --- a/src/Laravel/CONTRIBUTING.md +++ b/src/Laravel/CONTRIBUTING.md @@ -1,11 +1,14 @@ # Contributing to the Laravel Integration of API Platform +Pull requests should be made at https://github.com/api-plaform/core + ## Tests cd src/Laravel composer global require soyuka/pmu composer global link ../../ vendor/bin/testbench workbench:build + vendor/bin/testbench api-platform:install vendor/bin/testbench package:test # or vendor/bin/phpunit From 6d4e24883767f1c58dff5e52f57b0422110fa38f Mon Sep 17 00:00:00 2001 From: Tobias Oitzinger <42447585+toitzi@users.noreply.github.com> Date: Fri, 4 Oct 2024 14:43:41 +0200 Subject: [PATCH 07/16] fix(laravel): hiding/showing relationships (#6679) * fix(laravel): fix hiding/showing relationships Check if is in visible or hidden (if exists) just like with regular proeprties Closes: #6678 Signed-off-by: Tobias Oitzinger * test: use a WorkbenchTestCase --------- Signed-off-by: Tobias Oitzinger Co-authored-by: soyuka --- .../Eloquent/Metadata/ModelMetadata.php | 1 + .../Eloquent/Metadata/ModelMetadataTest.php | 83 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/Laravel/Tests/Eloquent/Metadata/ModelMetadataTest.php diff --git a/src/Laravel/Eloquent/Metadata/ModelMetadata.php b/src/Laravel/Eloquent/Metadata/ModelMetadata.php index 12a1ed9ba34..3d851681c03 100644 --- a/src/Laravel/Eloquent/Metadata/ModelMetadata.php +++ b/src/Laravel/Eloquent/Metadata/ModelMetadata.php @@ -156,6 +156,7 @@ public function getRelations(Model $model): Collection || $method->isAbstract() || Model::class === $method->getDeclaringClass()->getName() || $method->getNumberOfParameters() > 0 + || $this->attributeIsHidden($method->getName(), $model) ) ->filter(function (\ReflectionMethod $method) { if ($method->getReturnType() instanceof \ReflectionNamedType diff --git a/src/Laravel/Tests/Eloquent/Metadata/ModelMetadataTest.php b/src/Laravel/Tests/Eloquent/Metadata/ModelMetadataTest.php new file mode 100644 index 00000000000..e306c731b8c --- /dev/null +++ b/src/Laravel/Tests/Eloquent/Metadata/ModelMetadataTest.php @@ -0,0 +1,83 @@ + + * + * 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\Eloquent\Metadata; + +use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Workbench\App\Models\Book; + +/** + * @author Tobias Oitzinger + */ +final class ModelMetadataTest extends TestCase +{ + use RefreshDatabase; + use WithWorkbench; + + public function testHiddenAttributesAreCorrectlyIdentified(): void + { + $model = new class extends Model { + protected $hidden = ['secret']; + + /** + * @return HasMany + */ + public function secret(): HasMany + { + return $this->hasMany(Book::class); + } + }; + + $metadata = new ModelMetadata(); + $this->assertCount(0, $metadata->getRelations($model)); + } + + public function testVisibleAttributesAreCorrectlyIdentified(): void + { + $model = new class extends Model { + protected $visible = ['secret']; + + /** + * @return HasMany + */ + public function secret(): HasMany + { + return $this->hasMany(Book::class); + } + }; + + $metadata = new ModelMetadata(); + $this->assertCount(1, $metadata->getRelations($model)); + } + + public function testAllAttributesVisibleByDefault(): void + { + $model = new class extends Model { + /** + * @return HasMany + */ + public function secret(): HasMany + { + return $this->hasMany(Book::class); + } + }; + + $metadata = new ModelMetadata(); + $this->assertCount(1, $metadata->getRelations($model)); + } +} From b0d5a2adedb583074aa93d4f641bdda419d31ffa Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 3 Oct 2024 10:13:14 +0200 Subject: [PATCH 08/16] fix(laravel): register global middleware to secure non-rest routes --- src/Laravel/ApiPlatformProvider.php | 21 ++++++++++++++++----- src/Laravel/config/api-platform.php | 6 ++---- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 7168b574968..9c3406e7d49 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -1261,6 +1261,7 @@ public function boot(ResourceNameCollectionFactoryInterface $resourceNameCollect return; } + $globalMiddlewares = $config->get('api-platform.routes.middleware'); $routeCollection = new RouteCollection(); foreach ($resourceNameCollectionFactory->create() as $resourceClass) { foreach ($resourceMetadataFactory->create($resourceClass) as $resourceMetadata) { @@ -1273,7 +1274,7 @@ public function boot(ResourceNameCollectionFactoryInterface $resourceNameCollect ->setDefaults(['_api_operation_name' => $operation->getName(), '_api_resource_class' => $operation->getClass()]); $route->middleware(ApiPlatformMiddleware::class.':'.$operation->getName()); - $route->middleware($config->get('api-platform.routes.middleware')); + $route->middleware($globalMiddlewares); $route->middleware($operation->getMiddleware()); $routeCollection->add($route); @@ -1283,20 +1284,26 @@ public function boot(ResourceNameCollectionFactoryInterface $resourceNameCollect $prefix = $config->get('api-platform.defaults.route_prefix') ?? ''; $route = new Route(['GET'], $prefix.'/contexts/{shortName?}{_format?}', [ContextAction::class, '__invoke']); - $route->name('api_jsonld_context')->middleware(ApiPlatformMiddleware::class); + $route->name('api_jsonld_context'); + $route->middleware(ApiPlatformMiddleware::class); + $route->middleware($globalMiddlewares); $routeCollection->add($route); $route = new Route(['GET'], $prefix.'/docs{_format?}', function (Request $request, Application $app) { $documentationAction = $app->make(DocumentationController::class); return $documentationAction->__invoke($request); }); - $route->name('api_doc')->middleware(ApiPlatformMiddleware::class); + $route->name('api_doc'); + $route->middleware(ApiPlatformMiddleware::class); + $route->middleware($globalMiddlewares); $routeCollection->add($route); $route = new Route(['GET'], $prefix.'/.well-known/genid/{id}', function (): void { throw new NotExposedHttpException('This route is not exposed on purpose. It generates an IRI for a collection resource without identifier nor item operation.'); }); - $route->name('api_genid')->middleware(ApiPlatformMiddleware::class); + $route->name('api_genid'); + $route->middleware(ApiPlatformMiddleware::class); + $route->middleware($globalMiddlewares); $routeCollection->add($route); if ($config->get('api-platform.graphql.enabled')) { @@ -1305,6 +1312,7 @@ public function boot(ResourceNameCollectionFactoryInterface $resourceNameCollect return $entrypointAction->__invoke($request); }); + $route->middleware($globalMiddlewares); $routeCollection->add($route); $route = new Route(['GET'], $prefix.'/graphiql', function (Application $app) { @@ -1312,6 +1320,7 @@ public function boot(ResourceNameCollectionFactoryInterface $resourceNameCollect return $controller->__invoke(); }); + $route->middleware($globalMiddlewares); $routeCollection->add($route); } @@ -1321,7 +1330,9 @@ public function boot(ResourceNameCollectionFactoryInterface $resourceNameCollect return $entrypointAction->__invoke($request); }); $route->where('index', 'index'); - $route->name('api_entrypoint')->middleware(ApiPlatformMiddleware::class); + $route->name('api_entrypoint'); + $route->middleware(ApiPlatformMiddleware::class); + $route->middleware($globalMiddlewares); $routeCollection->add($route); $router->setRoutes($routeCollection); diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 3a0d221a19e..2d4b68783aa 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -10,12 +10,10 @@ 'version' => '1.0.0', 'routes' => [ + // Global middleware applied to every API Platform routes + // 'middleware' => [] ], - /* - * Where are ApiResource defined - * TODO: link the docs on how to plug on eloquent models or create apiResource like controllers :D - */ 'resources' => [ app_path('Models'), ], From 4312a1f55f4f80152be93734cb5cf73c70dee53a Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 4 Oct 2024 11:03:24 +0200 Subject: [PATCH 09/16] fix(metadata): register parameters on graphql operations --- src/GraphQl/Type/FieldsBuilder.php | 38 ++++-- .../State/ParameterValidatorProvider.php | 2 +- ...meterResourceMetadataCollectionFactory.php | 115 ++++++++++-------- .../State/ParameterValidatorProvider.php | 2 +- 4 files changed, 99 insertions(+), 58 deletions(-) diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 560ac638670..3a9884f45f4 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -18,11 +18,13 @@ use ApiPlatform\GraphQl\Exception\InvalidTypeException; use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactoryInterface; use ApiPlatform\GraphQl\Type\Definition\TypeInterface; +use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\InflectorInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -296,20 +298,40 @@ public function resolveResourceArgs(array $args, Operation $operation): array continue; } + $filter = $this->filterLocator->get($filterId); $parsedKey = explode('[:property]', $key); $flattenFields = []; - foreach ($this->filterLocator->get($filterId)->getDescription($operation->getClass()) as $key => $value) { - $values = []; - parse_str($key, $values); - if (isset($values[$parsedKey[0]])) { - $values = $values[$parsedKey[0]]; + + if ($filter instanceof FilterInterface) { + foreach ($filter->getDescription($operation->getClass()) as $name => $value) { + $values = []; + parse_str($name, $values); + if (isset($values[$parsedKey[0]])) { + $values = $values[$parsedKey[0]]; + } + + $name = key($values); + $flattenFields[] = ['name' => $name, 'required' => $value['required'] ?? null, 'description' => $value['description'] ?? null, 'leafs' => $values[$name], 'type' => $value['type'] ?? 'string']; + } + + $args[$parsedKey[0]] = $this->parameterToObjectType($flattenFields, $parsedKey[0]); + } + + if ($filter instanceof OpenApiParameterFilterInterface) { + foreach ($filter->getOpenApiParameters($parameter) as $value) { + $values = []; + parse_str($value->getName(), $values); + if (isset($values[$parsedKey[0]])) { + $values = $values[$parsedKey[0]]; + } + + $name = key($values); + $flattenFields[] = ['name' => $name, 'required' => $value->getRequired(), 'description' => $value->getDescription(), 'leafs' => $values[$name], 'type' => $value->getSchema()['type'] ?? 'string']; } - $name = key($values); - $flattenFields[] = ['name' => $name, 'required' => $value['required'] ?? null, 'description' => $value['description'] ?? null, 'leafs' => $values[$name], 'type' => $value['type'] ?? 'string']; + $args[$parsedKey[0]] = $this->parameterToObjectType($flattenFields, $parsedKey[0].$operation->getShortName().$operation->getName()); } - $args[$parsedKey[0]] = $this->parameterToObjectType($flattenFields, $parsedKey[0]); continue; } diff --git a/src/Laravel/State/ParameterValidatorProvider.php b/src/Laravel/State/ParameterValidatorProvider.php index 50560acd8f6..d3f4fab3d01 100644 --- a/src/Laravel/State/ParameterValidatorProvider.php +++ b/src/Laravel/State/ParameterValidatorProvider.php @@ -44,7 +44,7 @@ public function __construct( public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - if (!($request = $context['request']) instanceof Request) { + if (!($request = $context['request'] ?? null) instanceof Request) { return $this->decorated->provide($operation, $uriVariables, $context); } diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index a948b338808..8739ccb7c7a 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -34,6 +35,8 @@ */ final class ParameterResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface { + private array $localPropertyCache; + public function __construct( private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, @@ -47,51 +50,12 @@ public function create(string $resourceClass): ResourceMetadataCollection { $resourceMetadataCollection = $this->decorated?->create($resourceClass) ?? new ResourceMetadataCollection($resourceClass); - $propertyNames = []; - $properties = []; - foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $i => $property) { - $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property); - if ($propertyMetadata->isReadable()) { - $propertyNames[] = $property; - $properties[$property] = $propertyMetadata; - } - } - foreach ($resourceMetadataCollection as $i => $resource) { $operations = $resource->getOperations(); $internalPriority = -1; foreach ($operations as $operationName => $operation) { - $parameters = $operation->getParameters() ?? new Parameters(); - foreach ($parameters as $key => $parameter) { - if (':property' === $key) { - foreach ($propertyNames as $property) { - $converted = $this->nameConverter?->denormalize($property) ?? $property; - $propertyParameter = $this->setDefaults($converted, $parameter, $resourceClass, $properties); - $priority = $propertyParameter->getPriority() ?? $internalPriority--; - $parameters->add($converted, $propertyParameter->withPriority($priority)->withKey($converted)); - } - - $parameters->remove($key, $parameter::class); - continue; - } - - $key = $parameter->getKey() ?? $key; - - if (str_contains($key, ':property')) { - $p = []; - foreach ($propertyNames as $prop) { - $p[$this->nameConverter?->denormalize($prop) ?? $prop] = $prop; - } - - $parameter = $parameter->withExtraProperties(($parameter->getExtraProperties() ?? []) + ['_properties' => $p]); - } - - $parameter = $this->setDefaults($key, $parameter, $resourceClass, $properties); - $priority = $parameter->getPriority() ?? $internalPriority--; - $parameters->add($key, $parameter->withPriority($priority)); - } - + $parameters = $this->getDefaultParameters($operation, $resourceClass, $internalPriority); if (\count($parameters) > 0) { $operations->add($operationName, $operation->withParameters($parameters)); } @@ -105,15 +69,10 @@ public function create(string $resourceClass): ResourceMetadataCollection $internalPriority = -1; foreach ($graphQlOperations as $operationName => $operation) { - $parameters = $operation->getParameters() ?? new Parameters(); - foreach ($operation->getParameters() ?? [] as $key => $parameter) { - $key = $parameter->getKey() ?? $key; - $parameter = $this->setDefaults($key, $parameter, $resourceClass, $properties); - $priority = $parameter->getPriority() ?? $internalPriority--; - $parameters->add($key, $parameter->withPriority($priority)); + $parameters = $this->getDefaultParameters($operation, $resourceClass, $internalPriority); + if (\count($parameters) > 0) { + $graphQlOperations[$operationName] = $operation->withParameters($parameters); } - - $graphQlOperations[$operationName] = $operation->withParameters($parameters); } $resourceMetadataCollection[$i] = $resource->withGraphQlOperations($graphQlOperations); @@ -122,6 +81,66 @@ public function create(string $resourceClass): ResourceMetadataCollection return $resourceMetadataCollection; } + /** + * @return array{propertyNames: string[], properties: array} + */ + private function getProperties(string $resourceClass): array + { + if (isset($this->localPropertyCache[$resourceClass])) { + return $this->localPropertyCache[$resourceClass]; + } + + $propertyNames = []; + $properties = []; + foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) { + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property); + if ($propertyMetadata->isReadable()) { + $propertyNames[] = $property; + $properties[$property] = $propertyMetadata; + } + } + + $this->localPropertyCache = [$resourceClass => ['propertyNames' => $propertyNames, 'properties' => $properties]]; + + return $this->localPropertyCache[$resourceClass]; + } + + private function getDefaultParameters(Operation $operation, string $resourceClass, int &$internalPriority): Parameters + { + ['propertyNames' => $propertyNames, 'properties' => $properties] = $this->getProperties($resourceClass); + $parameters = $operation->getParameters() ?? new Parameters(); + foreach ($parameters as $key => $parameter) { + if (':property' === $key) { + foreach ($propertyNames as $property) { + $converted = $this->nameConverter?->denormalize($property) ?? $property; + $propertyParameter = $this->setDefaults($converted, $parameter, $resourceClass, $properties); + $priority = $propertyParameter->getPriority() ?? $internalPriority--; + $parameters->add($converted, $propertyParameter->withPriority($priority)->withKey($converted)); + } + + $parameters->remove($key, $parameter::class); + continue; + } + + $key = $parameter->getKey() ?? $key; + + if (str_contains($key, ':property')) { + $p = []; + foreach ($propertyNames as $prop) { + $p[$this->nameConverter?->denormalize($prop) ?? $prop] = $prop; + } + + $parameter = $parameter->withExtraProperties($parameter->getExtraProperties() + ['_properties' => $p]); + } + + $parameter = $this->setDefaults($key, $parameter, $resourceClass, $properties); + $priority = $parameter->getPriority() ?? $internalPriority--; + $parameters->add($key, $parameter->withPriority($priority)); + } + + return $parameters; + } + private function addFilterMetadata(Parameter $parameter): Parameter { if (!($filterId = $parameter->getFilter())) { diff --git a/src/Symfony/Validator/State/ParameterValidatorProvider.php b/src/Symfony/Validator/State/ParameterValidatorProvider.php index b88d3c217c9..faaa9715bca 100644 --- a/src/Symfony/Validator/State/ParameterValidatorProvider.php +++ b/src/Symfony/Validator/State/ParameterValidatorProvider.php @@ -40,7 +40,7 @@ public function __construct( public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - if (!($request = $context['request']) instanceof Request) { + if (!($request = $context['request'] ?? null) instanceof Request) { return $this->decorated->provide($operation, $uriVariables, $context); } From df701da05620a847f529ebabaee97f8cf5ecb37f Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 4 Oct 2024 11:04:04 +0200 Subject: [PATCH 10/16] feat(laravel): graphql policies --- src/Laravel/ApiPlatformProvider.php | 178 +++++++++++------- ...quentResourceCollectionMetadataFactory.php | 17 ++ .../Controller/EntrypointController.php | 24 ++- src/Laravel/State/AccessCheckerProvider.php | 4 +- src/Laravel/Tests/GraphQlAuthTest.php | 109 +++++++++++ src/Laravel/Tests/GraphQlTest.php | 47 +++++ src/Laravel/composer.json | 4 +- src/Laravel/workbench/app/Models/Vault.php | 4 +- .../database/factories/VaultFactory.php | 1 + .../database/seeders/DatabaseSeeder.php | 2 + src/Metadata/GraphQl/Operation.php | 4 + src/Metadata/GraphQl/Query.php | 4 + src/Metadata/GraphQl/QueryCollection.php | 4 + src/Metadata/GraphQl/Subscription.php | 4 + 14 files changed, 324 insertions(+), 82 deletions(-) create mode 100644 src/Laravel/Tests/GraphQlAuthTest.php create mode 100644 src/Laravel/Tests/GraphQlTest.php diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 9c3406e7d49..0a888c00b1d 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -927,6 +927,10 @@ public function register(): void ); }); + if (interface_exists(FieldsBuilderEnumInterface::class)) { + $this->registerGraphQl($this->app); + } + $this->app->singleton(JsonApiObjectNormalizer::class, function (Application $app) { return new JsonApiObjectNormalizer( $app->make(ObjectNormalizer::class), @@ -936,51 +940,6 @@ public function register(): void ); }); - if ($this->app['config']->get('api-platform.graphql.enabled')) { - $this->app->singleton(GraphQlItemNormalizer::class, function (Application $app) { - return new GraphQlItemNormalizer( - $app->make(PropertyNameCollectionFactoryInterface::class), - $app->make(PropertyMetadataFactoryInterface::class), - $app->make(IriConverterInterface::class), - $app->make(IdentifiersExtractorInterface::class), - $app->make(ResourceClassResolverInterface::class), - $app->make(PropertyAccessorInterface::class), - $app->make(NameConverterInterface::class), - $app->make(SerializerClassMetadataFactory::class), - null, - $app->make(ResourceMetadataCollectionFactoryInterface::class), - $app->make(ResourceAccessCheckerInterface::class) - ); - }); - - $this->app->singleton(GraphQlObjectNormalizer::class, function (Application $app) { - return new GraphQlObjectNormalizer( - $app->make(ObjectNormalizer::class), - $app->make(IriConverterInterface::class), - $app->make(IdentifiersExtractorInterface::class), - ); - }); - } - - $this->app->singleton(GraphQlErrorNormalizer::class, function () { - return new GraphQlErrorNormalizer(); - }); - - $this->app->singleton(GraphQlValidationExceptionNormalizer::class, function (Application $app) { - /** @var ConfigRepository */ - $config = $app['config']; - - return new GraphQlValidationExceptionNormalizer($config->get('api-platform.exception_to_status')); - }); - - $this->app->singleton(GraphQlHttpExceptionNormalizer::class, function () { - return new GraphQlHttpExceptionNormalizer(); - }); - - $this->app->singleton(GraphQlRuntimeExceptionNormalizer::class, function () { - return new GraphQlHttpExceptionNormalizer(); - }); - $this->app->bind(SerializerInterface::class, Serializer::class); $this->app->bind(NormalizerInterface::class, Serializer::class); $this->app->singleton(Serializer::class, function (Application $app) { @@ -1009,7 +968,7 @@ public function register(): void $list->insert($app->make(JsonApiItemNormalizer::class), -890); $list->insert($app->make(JsonApiObjectNormalizer::class), -995); - if ($config->get('api-platform.graphql.enabled')) { + if (interface_exists(FieldsBuilderEnumInterface::class)) { $list->insert($app->make(GraphQlItemNormalizer::class), -890); $list->insert($app->make(GraphQlObjectNormalizer::class), -995); $list->insert($app->make(GraphQlErrorNormalizer::class), -790); @@ -1033,7 +992,8 @@ public function register(): void new JsonEncoder('jsonapi'), new JsonEncoder('jsonhal'), new CsvEncoder(), - ]); + ] + ); }); $this->app->singleton(JsonLdItemNormalizer::class, function (Application $app) { @@ -1078,10 +1038,6 @@ function (Application $app) { return new Inflector(); }); - if ($this->app['config']->get('api-platform.graphql.enabled')) { - $this->registerGraphQl($this->app); - } - if ($this->app->runningInConsole()) { $this->commands([Console\InstallCommand::class]); } @@ -1089,6 +1045,49 @@ function (Application $app) { private function registerGraphQl(Application $app): void { + $this->app->singleton(GraphQlItemNormalizer::class, function (Application $app) { + return new GraphQlItemNormalizer( + $app->make(PropertyNameCollectionFactoryInterface::class), + $app->make(PropertyMetadataFactoryInterface::class), + $app->make(IriConverterInterface::class), + $app->make(IdentifiersExtractorInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(PropertyAccessorInterface::class), + $app->make(NameConverterInterface::class), + $app->make(SerializerClassMetadataFactory::class), + null, + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ResourceAccessCheckerInterface::class) + ); + }); + + $this->app->singleton(GraphQlObjectNormalizer::class, function (Application $app) { + return new GraphQlObjectNormalizer( + $app->make(ObjectNormalizer::class), + $app->make(IriConverterInterface::class), + $app->make(IdentifiersExtractorInterface::class), + ); + }); + + $this->app->singleton(GraphQlErrorNormalizer::class, function () { + return new GraphQlErrorNormalizer(); + }); + + $this->app->singleton(GraphQlValidationExceptionNormalizer::class, function (Application $app) { + /** @var ConfigRepository */ + $config = $app['config']; + + return new GraphQlValidationExceptionNormalizer($config->get('api-platform.exception_to_status')); + }); + + $this->app->singleton(GraphQlHttpExceptionNormalizer::class, function () { + return new GraphQlHttpExceptionNormalizer(); + }); + + $this->app->singleton(GraphQlRuntimeExceptionNormalizer::class, function () { + return new GraphQlHttpExceptionNormalizer(); + }); + $app->singleton('api_platform.graphql.type_locator', function (Application $app) { $tagged = iterator_to_array($app->tagged('api_platform.graphql.type')); @@ -1130,44 +1129,78 @@ private function registerGraphQl(Application $app): void return new GraphQlSerializerContextBuilder($app->make(NameConverterInterface::class)); }); - $app->singleton('api_platform.graphql.state_provider', function (Application $app) { + $app->singleton(GraphQlReadProvider::class, function (Application $app) { /** @var ConfigRepository */ $config = $app['config']; - $tagged = iterator_to_array($app->tagged(ParameterProviderInterface::class)); - $resolvers = iterator_to_array($app->tagged('api_platform.graphql.resolver')); return new GraphQlReadProvider( - new GraphQlDenormalizeProvider( - new ResolverProvider( - new ParameterProvider( - $app->make(CallableProvider::class), - new ServiceLocator($tagged) - ), - new ServiceLocator($resolvers), - ), - $app->make(SerializerInterface::class), - $app->make(GraphQlSerializerContextBuilder::class) - ), + $this->app->make(CallableProvider::class), $app->make(IriConverterInterface::class), $app->make(GraphQlSerializerContextBuilder::class), $config->get('api-platform.graphql.nesting_separator') ?? '__' ); }); + $app->alias(GraphQlReadProvider::class, 'api_platform.graphql.state_provider.read'); + + $app->singleton(ResolverProvider::class, function (Application $app) { + $resolvers = iterator_to_array($app->tagged('api_platform.graphql.resolver')); + + return new ResolverProvider( + $app->make(GraphQlReadProvider::class), + new ServiceLocator($resolvers), + ); + }); + + $app->alias(ResolverProvider::class, 'api_platform.graphql.state_provider.resolver'); + + $app->singleton(GraphQlDenormalizeProvider::class, function (Application $app) { + return new GraphQlDenormalizeProvider( + $this->app->make(ResolverProvider::class), + $app->make(SerializerInterface::class), + $app->make(GraphQlSerializerContextBuilder::class) + ); + }); + + $app->alias(GraphQlDenormalizeProvider::class, 'api_platform.graphql.state_provider.denormalize'); + + $app->singleton('api_platform.graphql.state_provider.parameter', function (Application $app) { + $tagged = iterator_to_array($app->tagged(ParameterProviderInterface::class)); + $tagged['api_platform.serializer.filter_parameter_provider'] = $app->make(SerializerFilterParameterProvider::class); + + return new ParameterProvider( + new ParameterValidatorProvider( + new SecurityParameterProvider( + $app->make(GraphQlDenormalizeProvider::class), + $app->make(ResourceAccessCheckerInterface::class) + ), + ), + new ServiceLocator($tagged) + ); + }); + + $app->singleton('api_platform.graphql.state_provider.access_checker', function (Application $app) { + return new AccessCheckerProvider($app->make('api_platform.graphql.state_provider.parameter'), $app->make(ResourceAccessCheckerInterface::class)); + }); + + $app->singleton(NormalizeProcessor::class, function (Application $app) { + return new NormalizeProcessor( + $app->make(SerializerInterface::class), + $app->make(GraphQlSerializerContextBuilder::class), + $app->make(Pagination::class) + ); + }); + $app->alias(NormalizeProcessor::class, 'api_platform.graphql.state_processor.normalize'); $app->singleton('api_platform.graphql.state_processor', function (Application $app) { return new WriteProcessor( - new NormalizeProcessor( - $app->make(SerializerInterface::class), - $app->make(GraphQlSerializerContextBuilder::class), - $app->make(Pagination::class) - ), + $app->make('api_platform.graphql.state_processor.normalize'), $app->make(CallableProcessor::class), ); }); $app->singleton(ResolverFactoryInterface::class, function (Application $app) { return new ResolverFactory( - $app->make('api_platform.graphql.state_provider'), + $app->make('api_platform.graphql.state_provider.access_checker'), $app->make('api_platform.graphql.state_processor') ); }); @@ -1227,7 +1260,8 @@ private function registerGraphQl(Application $app): void $app->make(SerializerInterface::class), $app->make(ErrorHandlerInterface::class), debug: $config->get('app.debug'), - negotiator: $app->make(Negotiator::class) + negotiator: $app->make(Negotiator::class), + formats: $config->get('api-platform.formats') ); }); } diff --git a/src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php b/src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php index c693ff55ff5..5babbfb8a1a 100644 --- a/src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php +++ b/src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php @@ -22,6 +22,11 @@ use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\DeleteMutation; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; @@ -39,6 +44,12 @@ final class EloquentResourceCollectionMetadataFactory implements ResourceMetadat GetCollection::class => 'viewAny', Delete::class => 'delete', Patch::class => 'update', + + Query::class => 'view', + QueryCollection::class => 'viewAny', + Mutation::class => 'update', + DeleteMutation::class => 'delete', + Subscription::class => 'viewAny', ]; public function __construct( @@ -94,6 +105,12 @@ public function create(string $resourceClass): ResourceMetadataCollection $graphQlOperations = $resourceMetadata->getGraphQlOperations(); foreach ($graphQlOperations ?? [] as $operationName => $graphQlOperation) { + if (!$graphQlOperation->getPolicy() && ($policy = Gate::getPolicyFor($model))) { + if (($policyMethod = self::POLICY_METHODS[$graphQlOperation::class] ?? null) && method_exists($policy, $policyMethod)) { + $graphQlOperation = $graphQlOperation->withPolicy($policyMethod); + } + } + if (!$graphQlOperation->getProvider()) { $graphQlOperation = $graphQlOperation->withProvider($graphQlOperation instanceof CollectionOperationInterface ? CollectionProvider::class : ItemProvider::class); } diff --git a/src/Laravel/GraphQl/Controller/EntrypointController.php b/src/Laravel/GraphQl/Controller/EntrypointController.php index a7c64bc55e5..9d47eb514e4 100644 --- a/src/Laravel/GraphQl/Controller/EntrypointController.php +++ b/src/Laravel/GraphQl/Controller/EntrypointController.php @@ -32,6 +32,9 @@ final class EntrypointController use ContentNegotiationTrait; private int $debug; + /** + * @param array $formats + */ public function __construct( private readonly SchemaBuilderInterface $schemaBuilder, private readonly ExecutorInterface $executor, @@ -40,6 +43,7 @@ public function __construct( private readonly ErrorHandlerInterface $errorHandler, bool $debug = false, ?Negotiator $negotiator = null, + private readonly array $formats = [], ) { $this->debug = $debug ? DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE : DebugFlag::NONE; $this->negotiator = $negotiator ?? new Negotiator(); @@ -48,14 +52,23 @@ public function __construct( public function __invoke(Request $request): Response { $formats = ['json' => ['application/json'], 'html' => ['text/html']]; + + foreach ($this->formats as $k => $f) { + if (!isset($formats[$k])) { + $formats[$k] = $f; + } + } + + $this->addRequestFormats($request, $formats); $format = $this->getRequestFormat($request, $formats, false); + $request->setRequestFormat($format); try { if ($request->isMethod('GET') && 'html' === $format) { return ($this->graphiQlAction)(); } - [$query, $operationName, $variables] = $this->parseRequest($request); + [$query, $operationName, $variables] = $this->parseRequest($request, $format); if (null === $query) { throw new BadRequestHttpException('GraphQL query is not valid.'); } @@ -78,7 +91,7 @@ public function __invoke(Request $request): Response * * @return array{0: array|null, 1: string, 2: array} */ - private function parseRequest(Request $request): array + private function parseRequest(Request $request, string $format): array { $queryParameters = $request->query->all(); $query = $queryParameters['query'] ?? null; @@ -91,16 +104,15 @@ private function parseRequest(Request $request): array return [$query, $operationName, $variables]; } - $contentType = method_exists(Request::class, 'getContentTypeFormat') ? $request->getContentTypeFormat() : $request->getContentType(); - if ('json' === $contentType) { + if ('json' === $format) { return $this->parseData($query, $operationName, $variables, $request->getContent()); } - if ('graphql' === $contentType) { + if ('graphql' === $format) { $query = $request->getContent(); } - if (\in_array($contentType, ['multipart', 'form'], true)) { + if ('multipart' === $format) { return $this->parseMultipartRequest($query, $operationName, $variables, $request->request->all(), $request->files->all()); } diff --git a/src/Laravel/State/AccessCheckerProvider.php b/src/Laravel/State/AccessCheckerProvider.php index 402d6ba0cc3..6e82e68f4ea 100644 --- a/src/Laravel/State/AccessCheckerProvider.php +++ b/src/Laravel/State/AccessCheckerProvider.php @@ -13,10 +13,12 @@ namespace ApiPlatform\Laravel\State; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\State\ProviderInterface; use Illuminate\Auth\Access\AuthorizationException; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * Allows access based on the ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface. @@ -54,7 +56,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c ]; if (!$this->resourceAccessChecker->isGranted($operation->getClass(), $policy, $resourceAccessCheckerContext)) { - throw new AuthorizationException($message ?? 'Access Denied.'); + throw $operation instanceof HttpOperation ? new AuthorizationException($message ?? 'Access Denied.') : new AccessDeniedHttpException($message ?? 'Access Denied.'); } return $body; diff --git a/src/Laravel/Tests/GraphQlAuthTest.php b/src/Laravel/Tests/GraphQlAuthTest.php new file mode 100644 index 00000000000..3804bd87e83 --- /dev/null +++ b/src/Laravel/Tests/GraphQlAuthTest.php @@ -0,0 +1,109 @@ + + * + * 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\Attributes\DefineEnvironment; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +class GraphQlAuthTest 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.routes.middleware', ['auth:sanctum']); + $config->set('api-platform.graphql.enabled', true); + }); + } + + public function testUnauthenticated(): void + { + $response = $this->postJson('/api/graphql', [], []); + $response->assertStatus(401); + } + + public function testAuthenticated(): void + { + $response = $this->post('/tokens/create'); + $token = $response->json()['token']; + $response = $this->get('/api/graphql', ['accept' => ['text/html'], 'authorization' => 'Bearer '.$token]); + $response->assertStatus(200); + $response = $this->postJson('/api/graphql', [ + 'query' => '{books { edges { node {id, name, publicationDate, author {id, name }}}}}', + ], [ + 'content-type' => 'application/json', + 'authorization' => 'Bearer '.$token, + ]); + $response->assertStatus(200); + $data = $response->json(); + $this->assertArrayHasKey('data', $data); + $this->assertArrayNotHasKey('errors', $data); + } + + public function testPolicy(): void + { + $response = $this->post('/tokens/create'); + $token = $response->json()['token']; + $response = $this->postJson('/api/graphql', ['query' => 'mutation { + updateVault(input: {secret: "secret", id: "/api/vaults/1"}) { + vault {id} + } + } +'], ['accept' => ['application/json'], 'authorization' => 'Bearer '.$token]); + $response->assertStatus(200); + $data = $response->json(); + $this->assertArrayHasKey('errors', $data); + $this->assertEquals('Access Denied.', $data['errors'][0]['message']); + } + + /** + * @param Application $app + */ + protected function useProductionMode($app): void + { + tap($app['config'], function (Repository $config): void { + $config->set('api-platform.routes.middleware', ['auth:sanctum']); + $config->set('api-platform.graphql.enabled', true); + $config->set('app.debug', false); + }); + } + + #[DefineEnvironment('useProductionMode')] + public function testProductionError(): void + { + $response = $this->post('/tokens/create'); + $token = $response->json()['token']; + $response = $this->postJson('/api/graphql', ['query' => 'mutation { + updateVault(input: {secret: "secret", id: "/api/vaults/1"}) { + vault {id} + } + } +'], ['accept' => ['application/json'], 'authorization' => 'Bearer '.$token]); + $response->assertStatus(200); + $data = $response->json(); + $this->assertArrayHasKey('errors', $data); + $this->assertArrayNotHasKey('trace', $data['errors'][0]); + } +} diff --git a/src/Laravel/Tests/GraphQlTest.php b/src/Laravel/Tests/GraphQlTest.php new file mode 100644 index 00000000000..8d0e8436977 --- /dev/null +++ b/src/Laravel/Tests/GraphQlTest.php @@ -0,0 +1,47 @@ + + * + * 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 GraphQlTest 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.graphql.enabled', true); + }); + } + + public function testGetBooks(): void + { + $response = $this->postJson('/api/graphql', ['query' => '{books { edges { node {id, name, publicationDate, author {id, name }}}}}'], ['accept' => ['application/json']]); + $response->assertStatus(200); + $data = $response->json(); + $this->assertArrayHasKey('data', $data); + $this->assertArrayNotHasKey('errors', $data); + } +} diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index eeab8e2bd2c..65c1ef6be14 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -47,7 +47,6 @@ "illuminate/routing": "^11.0", "illuminate/support": "^11.0", "illuminate/container": "^11.0", - "laravel/sanctum": "^4.0", "symfony/web-link": "^6.4 || ^7.1", "willdurand/negotiation": "^3.1", "phpstan/phpdoc-parser": "^1.29", @@ -58,7 +57,8 @@ "larastan/larastan": "^2.0", "orchestra/testbench": "^9.1", "phpunit/phpunit": "^11.2", - "api-platform/graphql": "^4.0" + "api-platform/graphql": "^4.0", + "laravel/sanctum": "^4.0" }, "autoload": { "psr-4": { diff --git a/src/Laravel/workbench/app/Models/Vault.php b/src/Laravel/workbench/app/Models/Vault.php index 70f554f3949..b25d3839d61 100644 --- a/src/Laravel/workbench/app/Models/Vault.php +++ b/src/Laravel/workbench/app/Models/Vault.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\Post; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -33,7 +34,8 @@ write: false ), new Delete(middleware: 'auth:sanctum', rules: VaultFormRequest::class, provider: [self::class, 'provide']), - ] + ], + graphQlOperations: [new Mutation(name: 'update', policy: 'update')] )] class Vault extends Model { diff --git a/src/Laravel/workbench/database/factories/VaultFactory.php b/src/Laravel/workbench/database/factories/VaultFactory.php index 7777b9ec8f3..115434984dd 100644 --- a/src/Laravel/workbench/database/factories/VaultFactory.php +++ b/src/Laravel/workbench/database/factories/VaultFactory.php @@ -14,6 +14,7 @@ namespace Workbench\Database\Factories; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Str; use Workbench\App\Models\Vault; /** diff --git a/src/Laravel/workbench/database/seeders/DatabaseSeeder.php b/src/Laravel/workbench/database/seeders/DatabaseSeeder.php index 0fd33308b1d..7732951b796 100644 --- a/src/Laravel/workbench/database/seeders/DatabaseSeeder.php +++ b/src/Laravel/workbench/database/seeders/DatabaseSeeder.php @@ -20,6 +20,7 @@ use Workbench\Database\Factories\PostFactory; use Workbench\Database\Factories\SluggableFactory; use Workbench\Database\Factories\UserFactory; +use Workbench\Database\Factories\VaultFactory; use Workbench\Database\Factories\WithAccessorFactory; class DatabaseSeeder extends Seeder @@ -34,5 +35,6 @@ public function run(): void SluggableFactory::new()->count(10)->create(); UserFactory::new()->create(); WithAccessorFactory::new()->create(); + VaultFactory::new()->count(10)->create(); } } diff --git a/src/Metadata/GraphQl/Operation.php b/src/Metadata/GraphQl/Operation.php index 0e0fb6374ba..fb87283cb89 100644 --- a/src/Metadata/GraphQl/Operation.php +++ b/src/Metadata/GraphQl/Operation.php @@ -90,6 +90,8 @@ public function __construct( ?OptionsInterface $stateOptions = null, array|Parameters|null $parameters = null, ?bool $queryParameterValidationEnabled = null, + mixed $rules = null, + ?string $policy = null, array $extraProperties = [], ) { parent::__construct( @@ -139,6 +141,8 @@ class: $class, stateOptions: $stateOptions, parameters: $parameters, queryParameterValidationEnabled: $queryParameterValidationEnabled, + rules: $rules, + policy: $policy, extraProperties: $extraProperties ); } diff --git a/src/Metadata/GraphQl/Query.php b/src/Metadata/GraphQl/Query.php index 633341923a1..9d629425310 100644 --- a/src/Metadata/GraphQl/Query.php +++ b/src/Metadata/GraphQl/Query.php @@ -73,6 +73,8 @@ public function __construct( ?OptionsInterface $stateOptions = null, array|Parameters|null $parameters = null, ?bool $queryParameterValidationEnabled = null, + mixed $rules = null, + ?string $policy = null, array $extraProperties = [], protected ?bool $nested = null, @@ -130,6 +132,8 @@ class: $class, stateOptions: $stateOptions, parameters: $parameters, queryParameterValidationEnabled: $queryParameterValidationEnabled, + policy: $policy, + rules: $rules, extraProperties: $extraProperties ); } diff --git a/src/Metadata/GraphQl/QueryCollection.php b/src/Metadata/GraphQl/QueryCollection.php index 1ec1a8c7435..6a029a531ff 100644 --- a/src/Metadata/GraphQl/QueryCollection.php +++ b/src/Metadata/GraphQl/QueryCollection.php @@ -74,6 +74,8 @@ public function __construct( protected ?OptionsInterface $stateOptions = null, array|Parameters|null $parameters = null, ?bool $queryParameterValidationEnabled = null, + mixed $rules = null, + ?string $policy = null, array $extraProperties = [], ?bool $nested = null, @@ -130,6 +132,8 @@ class: $class, processor: $processor, parameters: $parameters, queryParameterValidationEnabled: $queryParameterValidationEnabled, + policy: $policy, + rules: $rules, extraProperties: $extraProperties, nested: $nested, ); diff --git a/src/Metadata/GraphQl/Subscription.php b/src/Metadata/GraphQl/Subscription.php index b989ee1ffb3..59a66fab0c7 100644 --- a/src/Metadata/GraphQl/Subscription.php +++ b/src/Metadata/GraphQl/Subscription.php @@ -73,6 +73,8 @@ public function __construct( ?OptionsInterface $stateOptions = null, array|Parameters|null $parameters = null, ?bool $queryParameterValidationEnabled = null, + mixed $rules = null, + ?string $policy = null, array $extraProperties = [], ) { parent::__construct( @@ -128,6 +130,8 @@ class: $class, stateOptions: $stateOptions, parameters: $parameters, queryParameterValidationEnabled: $queryParameterValidationEnabled, + policy: $policy, + rules: $rules, extraProperties: $extraProperties, ); } From f9d96e546a37121244ab98d65c2d91f48b1bb112 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 4 Oct 2024 11:38:48 +0200 Subject: [PATCH 11/16] fix(metadata): graphql can be disabled but with an existing operation --- src/Laravel/ApiPlatformProvider.php | 3 ++- .../Resource/EloquentResourceCollectionMetadataFactory.php | 5 +++-- .../Factory/LinkResourceMetadataCollectionFactory.php | 6 +++++- .../Resource/Factory/MetadataCollectionFactoryTrait.php | 2 +- .../Factory/LinkResourceMetadataCollectionFactoryTest.php | 4 ++-- src/Symfony/Bundle/Resources/config/metadata/resource.xml | 1 + 6 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 0a888c00b1d..6100c806292 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -366,7 +366,8 @@ public function register(): void $config->get('api-platform.graphql.enabled'), ), ) - ) + ), + $config->get('api-platform.graphql.enabled') ) ) ) diff --git a/src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php b/src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php index 5babbfb8a1a..551a08576aa 100644 --- a/src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php +++ b/src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php @@ -103,7 +103,6 @@ public function create(string $resourceClass): ResourceMetadataCollection $resourceMetadataCollection[$i] = $resourceMetadata->withOperations($operations); $graphQlOperations = $resourceMetadata->getGraphQlOperations(); - foreach ($graphQlOperations ?? [] as $operationName => $graphQlOperation) { if (!$graphQlOperation->getPolicy() && ($policy = Gate::getPolicyFor($model))) { if (($policyMethod = self::POLICY_METHODS[$graphQlOperation::class] ?? null) && method_exists($policy, $policyMethod)) { @@ -122,7 +121,9 @@ public function create(string $resourceClass): ResourceMetadataCollection $graphQlOperations[$operationName] = $graphQlOperation; } - $resourceMetadata = $resourceMetadata->withGraphQlOperations($graphQlOperations); + if ($graphQlOperations) { + $resourceMetadata = $resourceMetadata->withGraphQlOperations($graphQlOperations); + } $resourceMetadataCollection[$i] = $resourceMetadata; } diff --git a/src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php index d2f3afcae72..dd5107f08d8 100644 --- a/src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php @@ -23,7 +23,7 @@ */ final class LinkResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface { - public function __construct(private readonly LinkFactoryInterface $linkFactory, private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null) + public function __construct(private readonly LinkFactoryInterface $linkFactory, private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, private bool $graphQlEnabled = false) { } @@ -37,6 +37,10 @@ public function create(string $resourceClass): ResourceMetadataCollection $resourceMetadataCollection = $this->decorated->create($resourceClass); } + if (!$this->graphQlEnabled) { + return $resourceMetadataCollection; + } + foreach ($resourceMetadataCollection as $i => $resource) { $graphQlOperations = []; foreach ($resource->getGraphQlOperations() ?? [] as $graphQlOperation) { diff --git a/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php b/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php index 6a7212a69e6..7561858e799 100644 --- a/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php +++ b/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php @@ -141,11 +141,11 @@ private function buildResourceOperations(array $metadataCollection, string $reso $resources[$index] = $resource = $resource->withOperations(new Operations($operations)); // @phpstan-ignore-line } - $graphQlOperations = $resource->getGraphQlOperations(); if (!$this->graphQlEnabled) { continue; } + $graphQlOperations = $resource->getGraphQlOperations(); if (null === $graphQlOperations) { if (!$hasApiResource) { $resources[$index] = $resources[$index]->withGraphQlOperations([]); diff --git a/src/Metadata/Tests/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php index d2bc4a64450..9edf2b70006 100644 --- a/src/Metadata/Tests/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php @@ -75,7 +75,7 @@ class: AttributeResource::class, ]), ); - $linkResourceMetadataCollectionFactory = new LinkResourceMetadataCollectionFactory($linkFactory, $resourceCollectionMetadataFactoryProphecy->reveal()); + $linkResourceMetadataCollectionFactory = new LinkResourceMetadataCollectionFactory($linkFactory, $resourceCollectionMetadataFactoryProphecy->reveal(), true); $this->assertEquals( new ResourceMetadataCollection(AttributeResource::class, [ @@ -123,7 +123,7 @@ class: AttributeResource::class, ]), ); - $linkResourceMetadataCollectionFactory = new LinkResourceMetadataCollectionFactory($linkFactory, $resourceCollectionMetadataFactoryProphecy->reveal()); + $linkResourceMetadataCollectionFactory = new LinkResourceMetadataCollectionFactory($linkFactory, $resourceCollectionMetadataFactoryProphecy->reveal(), true); $this->assertEquals( new ResourceMetadataCollection(AttributeResource::class, [ diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.xml b/src/Symfony/Bundle/Resources/config/metadata/resource.xml index c21c5ca8328..59b9422a9df 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.xml @@ -52,6 +52,7 @@ + %api_platform.graphql.enabled% From 4d4478eebf960d8d234b6940c1074b3ac1816c07 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 4 Oct 2024 16:00:12 +0200 Subject: [PATCH 12/16] doc: changelog 3.3.13 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 605bf3a0051..4f2da0ca78f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v3.3.13 + +### Bug fixes + +* [1b9dfccc8](https://github.com/api-platform/core/commit/1b9dfccc8d64be2b04e48490905771a77aefdd96) fix: count TraversablePaginator (#6611) +* [4c58b33e8](https://github.com/api-platform/core/commit/4c58b33e8c5a90f9377543bd068288dcf84e3236) fix(jsonapi): fixed definition name to allow using the same class names in different namespaces (#6676) +* [ef0ee6427](https://github.com/api-platform/core/commit/ef0ee6427f8056bcb2617c228a7cf9ffd9d29ccd) fix(doctrine): use parameter.property for filter value (#6572) + ## v3.3.12 ### Bug fixes From 5d3d8b1cb4af3bea21aacc311075079ec530fddf Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 4 Oct 2024 16:01:19 +0200 Subject: [PATCH 13/16] doc: changelog 3.3.14 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f2da0ca78f..08b3e2eafb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v3.3.14 + +### Bug fixes + +* [4c58b33e8](https://github.com/api-platform/core/commit/4c58b33e8c5a90f9377543bd068288dcf84e3236) fix(jsonapi): fixed definition name to allow using the same class names in different namespaces (#6676) + ## v3.3.13 ### Bug fixes From 6dfa89bf228ea1fe5a0db0f9de3054018c4ef57e Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 4 Oct 2024 16:08:00 +0200 Subject: [PATCH 14/16] doc: changelog 3.4.2 --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d0bfab071..1dc75f10a5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## v3.4.2 + +### Bug fixes + +* [0ca76fc89](https://github.com/api-platform/core/commit/0ca76fc898d2d1a679a490a5dea85473bf680901) fix(elasticsearch): allow elasticsearch 7 (#6689) +* [15d61c4b7](https://github.com/api-platform/core/commit/15d61c4b75fea2b365e0852a923fed8efbae6ab8) fix(metadata): using parameters in fromClass and toClass uriVariables' options (#6663) +* [2e2044636](https://github.com/api-platform/core/commit/2e204463675939903128037f82916d68f0016719) fix(metadata): parameter provider in a long running http worker (#6683) +* [4c58b33e8](https://github.com/api-platform/core/commit/4c58b33e8c5a90f9377543bd068288dcf84e3236) fix(jsonapi): fixed definition name to allow using the same class names in different namespaces (#6676) +* [4f5f56756](https://github.com/api-platform/core/commit/4f5f5675629fe52ea415a6bd91f3625eedea9c87) fix: remove hydra prefix on errors (#6624) +* [afe7d47d7](https://github.com/api-platform/core/commit/afe7d47d7b7ba6c8591bfb60137a65d1fa1fe38f) fix(metadata): passing class as parameter in XML ApiResource's definition (#6659) +* [b93ee467c](https://github.com/api-platform/core/commit/b93ee467c69253e0cfe60e75b48a5c7aa683474a) fix(metadata): overwriting XML ApiResource definition by YAML ApiResource definition (#6660) + +> [!WARNING] +> Hydra prefix on errors is breaking, read `title` not `hydra:title`. The `hydra_prefix` flag doesn't apply to errors as it provided redundant information (both `hydra:title` and `title` were available) + ## v3.4.1 ### Bug fixes From afc3997a07f69e5cad65c8d1c4851795fbd4f42a Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 4 Oct 2024 16:47:40 +0200 Subject: [PATCH 15/16] chore: improve changelog generator --- generate-changelog.sh | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/generate-changelog.sh b/generate-changelog.sh index f2903beb1b3..bf5cb10ec43 100755 --- a/generate-changelog.sh +++ b/generate-changelog.sh @@ -1,23 +1,26 @@ #!/bin/bash # usage: generate-changelog.sh previous_tag next_tag # example: generate-changelog.sh v2.7.2 v2.7.3 > CHANGELOG.new.md -log=$(git log "$1..HEAD" --pretty='format:* [%h](https://github.com/api-platform/core/commit/%H) %s' --no-merges) +lowerbranch=$(git branch --merged HEAD | grep '[[:digit:]]\.[[:digit:]]' | grep -v '*' | sort -rg | head -n 1) +log=$(git log "$1..HEAD" --no-merges --not $lowerbranch --pretty='format:* [%h](https://github.com/api-platform/core/commit/%H) %s') diff=$( printf "# Changelog\n\n" printf "## %s\n\n" "$2" -if [[ 0 != $(echo "$log" | grep fix | grep -v chore | wc -l) ]]; +fixes=$(echo "$log" | grep 'fix(\|fix:') +if [[ 0 != $(echo "$fixes" | wc -l) ]]; then printf "### Bug fixes\n\n" - printf "$log" | grep fix | grep -v chore | sort + printf "$fixes" | sort printf "\n\n" fi -if [[ 0 != $(echo "$log" | grep feat | grep -v chore | wc -l) ]]; +feat=$(echo "$log" | grep 'feat(\|feat:') +if [[ 0 != $(echo "$feat" | wc -l) ]]; then printf "### Features\n\n" - printf "$log" | grep feat | grep -v chore | sort + printf "$feat" | sort fi ) From 3b948b3e755eff0af11b40480c66b5c63d6cb6d3 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 4 Oct 2024 16:49:24 +0200 Subject: [PATCH 16/16] doc: changelog 4.0.3 --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1f250827d..3df12dfb4f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## v4.0.3 + +### Bug fixes + +* [025f63e69](https://github.com/api-platform/core/commit/025f63e69c2ec655a828559ed78c49a365ca043b) fix(laravel): route registration of EntrypointController should be last (#6667) +* [2b4937a3e](https://github.com/api-platform/core/commit/2b4937a3e09fb891b99fd8499b597190a4b740e0) fix(laravel): eloquent accessors (#6668) +* [4312a1f55](https://github.com/api-platform/core/commit/4312a1f55f4f80152be93734cb5cf73c70dee53a) fix(metadata): register parameters on graphql operations +* [6d4e24883](https://github.com/api-platform/core/commit/6d4e24883767f1c58dff5e52f57b0422110fa38f) fix(laravel): hiding/showing relationships (#6679) +* [85306f2f5](https://github.com/api-platform/core/commit/85306f2f5a7d480b1570471689d1d3ca4e9846a3) fix(laravel): swagger ui authentication (#6661) +* [a6e37068e](https://github.com/api-platform/core/commit/a6e37068ea49d1b5a4ee098a62a287d62fba1c35) fix(laravel): use Model::qualifyColumn instead of hardcoding $table.$column (#6658) +* [b0d5a2ade](https://github.com/api-platform/core/commit/b0d5a2adedb583074aa93d4f641bdda419d31ffa) fix(laravel): register global middleware to secure non-rest routes +* [f9d96e546](https://github.com/api-platform/core/commit/f9d96e546a37121244ab98d65c2d91f48b1bb112) fix(metadata): graphql can be disabled but with an existing operation + + +### Features + +* [df701da05](https://github.com/api-platform/core/commit/df701da05620a847f529ebabaee97f8cf5ecb37f) feat(laravel): graphql policies + ## v4.0.2 ### Bug fixes