diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 747ef12961..15de4411c4 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -15,6 +15,7 @@ ->in(__DIR__) ->exclude([ 'src/Core/Bridge/Symfony/Maker/Resources/skeleton', + 'src/Laravel/Console/Maker/Resources/skeleton', 'src/Laravel/config', 'tests/Fixtures/app/var', 'docs/guides', diff --git a/CHANGELOG.md b/CHANGELOG.md index 3df12dfb4f..6086ab0fa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## v4.0.5 + +### Bug fixes + +* [4171d5f9c](https://github.com/api-platform/core/commit/4171d5f9cd41731b857c53a186270ba0626baedf) fix(graphql): register query parameter arguments with filters (#6726) +* [48ab53816](https://github.com/api-platform/core/commit/48ab53816c55e6116aa64ac81f522f4b7b9bb9f6) fix(laravel): make command writes to app instead of src (#6723) + +### Features + +## v4.0.4 + +### Bug fixes + +* [2e8287dad](https://github.com/api-platform/core/commit/2e8287dad0c0315dd6527279a6359c0a22f40d93) fix(laravel): allow serializer attributes through ApiProperty (#6680) +* [439c188ea](https://github.com/api-platform/core/commit/439c188ea1685676d5e705a49a4b835f35a84d72) fix(laravel): match integer type (#6715) +* [4ad7a50aa](https://github.com/api-platform/core/commit/4ad7a50aaabf0d85e2eb5bb3a6d4ef8d5b7b39a7) fix(laravel): openapi Options binding (#6714) +* [ec6e64512](https://github.com/api-platform/core/commit/ec6e6451299a50fcab397e86fafe6db132ce7519) fix(laravel): skip resource path when not available (#6697) + +### Features + +* [5aa799321](https://github.com/api-platform/core/commit/5aa7993219a6fb55f11476a031963a542b2d3586) feat(laravel): command to generate state providers/processors (#6708) + ## v4.0.3 ### Bug fixes @@ -130,6 +152,25 @@ Notes: * [0d5f35683](https://github.com/api-platform/core/commit/0d5f356839eb6aa9f536044abe4affa736553e76) feat(laravel): laravel component (#5882) +## v3.4.4 + +### Bug fixes + +* [550347867](https://github.com/api-platform/core/commit/550347867f30611b673d8df99f65186d013919dd) fix(graphql): register query parameter arguments with filters (#6727) +* [99262dce7](https://github.com/api-platform/core/commit/99262dce739800bd841c95e026848b587ba25801) fix(jsonschema): handle @id when genId is false (#6716) +* [ad5efa535](https://github.com/api-platform/core/commit/ad5efa535a4dcbaad64ecff89514eaa6e07f5b7c) fix: multiple parameter provider #6673 (#6732) +* [d34cd7be8](https://github.com/api-platform/core/commit/d34cd7be8e7a12fd08a8b10270a614c06c10aa89) fix: use stateOptions when retrieving a Parameter filter (#6728) +* [e7fb04fab](https://github.com/api-platform/core/commit/e7fb04fab05bc077e2dbeb0fa0fc2c1d28c96105) fix(symfony): fetch api-platform/symfony version debug bar (#6722) +* [e96623ebf](https://github.com/api-platform/core/commit/e96623ebfd8691ba943bdb56a4d91e160497a311) fix(jsonld): prefix error @type with hydra: (#6721) + +## v3.4.3 + +### Bug fixes + +* [3ca599158](https://github.com/api-platform/core/commit/3ca599158139d56fbd6ee66f2de3e586120d735c) fix(hydra): hydra_prefix on errors (#6704) +* [f7f605dc8](https://github.com/api-platform/core/commit/f7f605dc8b798b975d2286c970c9091436d7f890) fix: check that api-platform/ramsey-uuid is installed before registering related services (#6696) +* [fbb53e5e3](https://github.com/api-platform/core/commit/fbb53e5e35ca0ec3de26ddc7de7ea4d1dda5c20b) fix(symfony): metadata aware name converter has 0 arguments by default (#6711) + ## v3.4.2 ### Bug fixes @@ -142,9 +183,6 @@ Notes: * [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 diff --git a/docs/guides/error-resource.php b/docs/guides/error-resource.php index 166745c78a..aee265e283 100644 --- a/docs/guides/error-resource.php +++ b/docs/guides/error-resource.php @@ -18,12 +18,12 @@ class MyDomainException extends \Exception implements ProblemExceptionInterface { public function getType(): string { - return 'teapot'; + return '/errors/418'; } public function getTitle(): ?string { - return null; + return 'Teapot error'; } public function getStatus(): ?int @@ -40,6 +40,8 @@ public function getInstance(): ?string { return null; } + + public string $myCustomField = 'I usually prefer coffee.'; } use ApiPlatform\Metadata\ApiResource; @@ -83,7 +85,12 @@ public function testBookDoesNotExists(): void // you can override this by looking at the [Error Provider guide](/docs/guides/error-provider). $this->assertResponseStatusCodeSame(418); $this->assertJsonContains([ + '@id' => '/my_domain_exceptions', + '@type' => 'MyDomainException', + 'type' => '/errors/418', + 'title' => 'Teapot error', 'detail' => 'I am teapot', + 'myCustomField' => 'I usually prefer coffee.' ]); } } diff --git a/features/hydra/error.feature b/features/hydra/error.feature index 793d835e88..d43ea714cb 100644 --- a/features/hydra/error.feature +++ b/features/hydra/error.feature @@ -17,8 +17,10 @@ Feature: Error handling And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' And the JSON node "type" should exist And the JSON node "title" should be equal to "An error occurred" + And the JSON node "hydra:title" should be equal to "An error occurred" And the JSON node "detail" should exist And the JSON node "description" should exist + And the JSON node "hydra:description" should exist And the JSON node "trace" should exist And the JSON node "status" should exist And the JSON node "@context" should exist @@ -48,6 +50,8 @@ Feature: Error handling ], "detail": "name: This value should not be blank.", "title": "An error occurred", + "hydra:title": "An error occurred", + "hydra:description": "name: This value should not be blank.", "description": "name: This value should not be blank.", "type": "/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3" } diff --git a/features/main/relation.feature b/features/main/relation.feature index 206a4789ff..0f16560dfb 100644 --- a/features/main/relation.feature +++ b/features/main/relation.feature @@ -493,7 +493,7 @@ Feature: Relations support "properties": { "@type": { "type": "string", - "pattern": "^Error$" + "pattern": "^hydra:Error$" }, "title": { "type": "string", diff --git a/features/mongodb/filters.feature b/features/mongodb/filters.feature index 8761ee0282..7658ed5c32 100644 --- a/features/mongodb/filters.feature +++ b/features/mongodb/filters.feature @@ -12,7 +12,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 "Error" + And the JSON node "@type" should be equal to "hydra: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 @@ -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 "Error" + And the JSON node "@type" should be equal to "hydra: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 diff --git a/features/security/strong_typing.feature b/features/security/strong_typing.feature index a12d3c354f..6650654871 100644 --- a/features/security/strong_typing.feature +++ b/features/security/strong_typing.feature @@ -54,7 +54,7 @@ Feature: Handle properly invalid data submitted to the API 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 "Error" + And the JSON node "@type" should be equal to "hydra:Error" And the JSON node "title" should be equal to "An error occurred" And the JSON node "description" should be equal to 'The type of the "name" attribute must be "string", "NULL" given.' @@ -71,7 +71,7 @@ Feature: Handle properly invalid data submitted to the API 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 "Error" + And the JSON node "@type" should be equal to "hydra:Error" And the JSON node "title" should be equal to "An error occurred" And the JSON node "description" should be equal to 'Invalid IRI "1".' And the JSON node "trace" should exist @@ -102,7 +102,7 @@ Feature: Handle properly invalid data submitted to the API 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 "Error" + And the JSON node "@type" should be equal to "hydra:Error" And the JSON node "title" should be equal to "An error occurred" And the JSON node "description" should be equal to 'The type of the "relatedDummies" attribute must be "array", "string" given.' And the JSON node "trace" should exist @@ -120,7 +120,7 @@ Feature: Handle properly invalid data submitted to the API 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 "Error" + And the JSON node "@type" should be equal to "hydra:Error" And the JSON node "title" should be equal to "An error occurred" And the JSON node "description" should be equal to 'The type of the key "a" must be "int", "string" given.' @@ -136,7 +136,7 @@ Feature: Handle properly invalid data submitted to the API 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 "Error" + And the JSON node "@type" should be equal to "hydra:Error" And the JSON node "title" should be equal to "An error occurred" And the JSON node "description" should be equal to 'The type of the "name" attribute must be "string", "integer" given.' diff --git a/features/serializer/vo_relations.feature b/features/serializer/vo_relations.feature index ccf4943981..63600d7b8a 100644 --- a/features/serializer/vo_relations.feature +++ b/features/serializer/vo_relations.feature @@ -148,7 +148,7 @@ Feature: Value object as ApiResource "properties": { "@type": { "type": "string", - "pattern": "^Error$" + "pattern": "^hydra:Error$" }, "title": { "type": "string", diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 3a9884f45f..0952929981 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -287,61 +287,6 @@ public function resolveResourceArgs(array $args, Operation $operation): array $args[$id]['type'] = $this->typeConverter->resolveType($arg['type']); } - /* - * This is @experimental, read the comment on the parameterToObjectType function as additional information. - */ - foreach ($operation->getParameters() ?? [] as $parameter) { - $key = $parameter->getKey(); - - if (str_contains($key, ':property')) { - if (!($filterId = $parameter->getFilter()) || !$this->filterLocator->has($filterId)) { - continue; - } - - $filter = $this->filterLocator->get($filterId); - $parsedKey = explode('[:property]', $key); - $flattenFields = []; - - 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']; - } - - $args[$parsedKey[0]] = $this->parameterToObjectType($flattenFields, $parsedKey[0].$operation->getShortName().$operation->getName()); - } - - continue; - } - - $args[$key] = ['type' => GraphQLType::string()]; - - if ($parameter->getRequired()) { - $args[$key]['type'] = GraphQLType::nonNull($args[$key]['type']); - } - } - return $args; } @@ -463,12 +408,15 @@ private function getResourceFieldConfiguration(?string $property, ?string $field $args = []; - if (!$input && !$rootOperation instanceof Mutation && !$rootOperation instanceof Subscription && !$isStandardGraphqlType && $isCollectionType) { - if (!$this->isEnumClass($resourceClass) && $this->pagination->isGraphQlEnabled($resourceOperation)) { - $args = $this->getGraphQlPaginationArgs($resourceOperation); - } + if (!$input && !$rootOperation instanceof Mutation && !$rootOperation instanceof Subscription && !$isStandardGraphqlType) { + if ($isCollectionType) { + if (!$this->isEnumClass($resourceClass) && $this->pagination->isGraphQlEnabled($resourceOperation)) { + $args = $this->getGraphQlPaginationArgs($resourceOperation); + } - $args = $this->getFilterArgs($args, $resourceClass, $rootResource, $resourceOperation, $rootOperation, $property, $depth); + $args = $this->getFilterArgs($args, $resourceClass, $rootResource, $resourceOperation, $rootOperation, $property, $depth); + $args = $this->getParameterArgs($rootOperation, $args); + } } if ($isStandardGraphqlType || $input) { @@ -491,6 +439,67 @@ private function getResourceFieldConfiguration(?string $property, ?string $field return null; } + /* + * This function is @experimental, read the comment on the parameterToObjectType function for additional information. + * @experimental + */ + private function getParameterArgs(Operation $operation, array $args = []): array + { + foreach ($operation->getParameters() ?? [] as $parameter) { + $key = $parameter->getKey(); + + if (!str_contains($key, ':property')) { + $args[$key] = ['type' => GraphQLType::string()]; + + if ($parameter->getRequired()) { + $args[$key]['type'] = GraphQLType::nonNull($args[$key]['type']); + } + + continue; + } + + if (!($filterId = $parameter->getFilter()) || !$this->filterLocator->has($filterId)) { + continue; + } + + $filter = $this->filterLocator->get($filterId); + $parsedKey = explode('[:property]', $key); + $flattenFields = []; + + 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']; + } + + $args[$parsedKey[0]] = $this->parameterToObjectType($flattenFields, $parsedKey[0].$operation->getShortName().$operation->getName()); + } + } + + return $args; + } + private function getGraphQlPaginationArgs(Operation $queryOperation): array { $paginationType = $this->pagination->getGraphQlPaginationType($queryOperation); diff --git a/src/GraphQl/Type/TypeConverter.php b/src/GraphQl/Type/TypeConverter.php index fa9430f5c7..d76d6eb261 100644 --- a/src/GraphQl/Type/TypeConverter.php +++ b/src/GraphQl/Type/TypeConverter.php @@ -168,10 +168,14 @@ private function getResourceType(Type $type, bool $input, Operation $rootOperati try { $operation = $resourceMetadataCollection->getOperation($operationName); } catch (OperationNotFoundException) { - $operation = $resourceMetadataCollection->getOperation($isCollection ? 'collection_query' : 'item_query'); + try { + $operation = $resourceMetadataCollection->getOperation($isCollection ? 'collection_query' : 'item_query'); + } catch (OperationNotFoundException) { + throw new OperationNotFoundException(\sprintf('A GraphQl operation named "%s" should exist on the type "%s" as we reference this type in another query.', $isCollection ? 'collection_query' : 'item_query', $resourceClass)); + } } if (!$operation instanceof Operation) { - throw new OperationNotFoundException(); + throw new OperationNotFoundException(\sprintf('A GraphQl operation named "%s" should exist on the type "%s" as we reference this type in another query.', $operationName, $resourceClass)); } return $this->typeBuilder->getResourceObjectType($resourceMetadataCollection, $operation, $propertyMetadata, [ diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index 0fe1063636..5cb591d0fa 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -14,7 +14,6 @@ namespace ApiPlatform\JsonLd; use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait; -use ApiPlatform\Metadata\Error; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; @@ -185,7 +184,7 @@ private function getResourceContextWithShortname(string $resourceClass, int $ref } } - if (false === ($this->defaultContext[self::HYDRA_CONTEXT_HAS_PREFIX] ?? true) || $operation instanceof Error) { + if (false === ($this->defaultContext[self::HYDRA_CONTEXT_HAS_PREFIX] ?? true)) { return ['http://www.w3.org/ns/hydra/context.jsonld', $context]; } diff --git a/src/JsonLd/Serializer/ErrorNormalizer.php b/src/JsonLd/Serializer/ErrorNormalizer.php new file mode 100644 index 0000000000..ce8dde4c9b --- /dev/null +++ b/src/JsonLd/Serializer/ErrorNormalizer.php @@ -0,0 +1,62 @@ + + * + * 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\JsonLd\Serializer; + +use ApiPlatform\State\ApiResource\Error; +use ApiPlatform\Validator\Exception\ValidationException; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final class ErrorNormalizer implements NormalizerInterface +{ + use HydraPrefixTrait; + + public function __construct(private readonly NormalizerInterface $inner, private readonly array $defaultContext = []) + { + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $context += $this->defaultContext; + $normalized = $this->inner->normalize($object, $format, $context); + $hydraPrefix = $this->getHydraPrefix($context); + if (!$hydraPrefix) { + return $normalized; + } + + if ('Error' === $normalized['@type']) { + $normalized['@type'] = 'hydra:Error'; + } + + if (isset($normalized['description'])) { + $normalized['hydra:description'] = $normalized['description']; + } + + if (isset($normalized['title'])) { + $normalized['hydra:title'] = $normalized['title']; + } + + return $normalized; + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $this->inner->supportsNormalization($data, $format, $context) + && (is_a($data, Error::class) || is_a($data, ValidationException::class)); + } + + public function getSupportedTypes(?string $format): array + { + return $this->inner->getSupportedTypes($format); + } +} diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 45fd44670d..85b43e64ba 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -222,6 +222,14 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str continue; } + if (false === $propertyMetadata->getGenId()) { + $subDefinitionName = $this->definitionNameFactory->create($className, $format, $className, null, $serializerContext); + + if (isset($subSchema->getDefinitions()[$subDefinitionName])) { + unset($subSchema->getDefinitions()[$subDefinitionName]['properties']['@id']); + } + } + if ($isCollection) { $propertySchema['items']['$ref'] = $subSchema['$ref']; unset($propertySchema['items']['type']); diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 6100c80629..0a9272b557 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -165,6 +165,7 @@ use ApiPlatform\Serializer\ItemNormalizer; use ApiPlatform\Serializer\JsonEncoder; use ApiPlatform\Serializer\Mapping\Factory\ClassMetadataFactory as SerializerClassMetadataFactory; +use ApiPlatform\Serializer\Mapping\Loader\PropertyMetadataLoader; use ApiPlatform\Serializer\Parameter\SerializerFilterParameterProvider; use ApiPlatform\Serializer\SerializerContextBuilder; use ApiPlatform\State\CallableProcessor; @@ -206,6 +207,7 @@ use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; +use Symfony\Component\Serializer\Mapping\Loader\LoaderChain; use Symfony\Component\Serializer\Mapping\Loader\LoaderInterface; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; @@ -244,8 +246,15 @@ public function register(): void $this->app->bind(LoaderInterface::class, AttributeLoader::class); $this->app->bind(ClassMetadataFactoryInterface::class, ClassMetadataFactory::class); - $this->app->singleton(ClassMetadataFactory::class, function () { - return new ClassMetadataFactory(new AttributeLoader()); + $this->app->singleton(ClassMetadataFactory::class, function (Application $app) { + return new ClassMetadataFactory( + new LoaderChain([ + new PropertyMetadataLoader( + $app->make(PropertyNameCollectionFactoryInterface::class), + ), + new AttributeLoader(), + ]) + ); }); $this->app->singleton(SerializerClassMetadataFactory::class, function (Application $app) { @@ -261,6 +270,15 @@ public function register(): void $refl = new \ReflectionClass(Error::class); $paths[] = \dirname($refl->getFileName()); + $logger = $app->make(LoggerInterface::class); + + foreach ($paths as $i => $path) { + if (!file_exists($path)) { + $logger->warning(\sprintf('We skipped reading resources in "%s" as the path does not exist. Please check the configuration at "api-platform.resources".', $path)); + unset($paths[$i]); + } + } + return new ConcernsResourceNameCollectionFactory($paths, new AttributesResourceNameCollectionFactory($paths)); }); @@ -790,7 +808,7 @@ public function register(): void $app->make(SchemaFactoryInterface::class), null, $config->get('api-platform.formats'), - null, // ?Options $openApiOptions = null, + $app->make(Options::class), $app->make(PaginationOptions::class), // ?PaginationOptions $paginationOptions = null, // ?RouterInterface $router = null ); @@ -1040,7 +1058,11 @@ function (Application $app) { }); if ($this->app->runningInConsole()) { - $this->commands([Console\InstallCommand::class]); + $this->commands([ + Console\InstallCommand::class, + Console\Maker\MakeStateProcessorCommand::class, + Console\Maker\MakeStateProviderCommand::class, + ]); } } diff --git a/src/Laravel/Console/Maker/AbstractMakeStateCommand.php b/src/Laravel/Console/Maker/AbstractMakeStateCommand.php new file mode 100644 index 0000000000..c2359af59c --- /dev/null +++ b/src/Laravel/Console/Maker/AbstractMakeStateCommand.php @@ -0,0 +1,81 @@ + + * + * 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\Console\Maker; + +use ApiPlatform\Laravel\Console\Maker\Utils\AppServiceProviderTagger; +use ApiPlatform\Laravel\Console\Maker\Utils\StateTemplateGenerator; +use ApiPlatform\Laravel\Console\Maker\Utils\StateTypeEnum; +use ApiPlatform\Laravel\Console\Maker\Utils\SuccessMessageTrait; +use Illuminate\Console\Command; +use Illuminate\Contracts\Filesystem\FileNotFoundException; +use Illuminate\Filesystem\Filesystem; + +abstract class AbstractMakeStateCommand extends Command +{ + use SuccessMessageTrait; + + public function __construct( + private readonly Filesystem $filesystem, + private readonly StateTemplateGenerator $stateTemplateGenerator, + private readonly AppServiceProviderTagger $appServiceProviderTagger, + ) { + parent::__construct(); + } + + /** + * @throws FileNotFoundException + */ + public function handle(): int + { + $stateName = $this->askForStateName(); + + $directoryPath = base_path('app/State/'); + $this->filesystem->ensureDirectoryExists($directoryPath); + + $filePath = $this->stateTemplateGenerator->getFilePath($directoryPath, $stateName); + if ($this->filesystem->exists($filePath)) { + $this->error(\sprintf('[ERROR] The file "%s" can\'t be generated because it already exists.', $filePath)); + + return self::FAILURE; + } + + $this->stateTemplateGenerator->generate($filePath, $stateName, $this->getStateType()); + if (!$this->filesystem->exists($filePath)) { + $this->error(\sprintf('[ERROR] The file "%s" could not be created.', $filePath)); + + return self::FAILURE; + } + + $this->appServiceProviderTagger->addTagToServiceProvider($stateName, $this->getStateType()); + + $this->writeSuccessMessage($filePath, $this->getStateType()); + + return self::SUCCESS; + } + + protected function askForStateName(): string + { + do { + $stateType = $this->getStateType()->name; + $stateName = $this->ask(\sprintf('Choose a class name for your state %s (e.g. AwesomeState%s)', strtolower($stateType), ucfirst($stateType))); + if (empty($stateName)) { + $this->error('[ERROR] This value cannot be blank.'); + } + } while (empty($stateName)); + + return $stateName; + } + + abstract protected function getStateType(): StateTypeEnum; +} diff --git a/src/Laravel/Console/Maker/MakeStateProcessorCommand.php b/src/Laravel/Console/Maker/MakeStateProcessorCommand.php new file mode 100644 index 0000000000..960ae42582 --- /dev/null +++ b/src/Laravel/Console/Maker/MakeStateProcessorCommand.php @@ -0,0 +1,27 @@ + + * + * 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\Console\Maker; + +use ApiPlatform\Laravel\Console\Maker\Utils\StateTypeEnum; + +final class MakeStateProcessorCommand extends AbstractMakeStateCommand +{ + protected $signature = 'make:state-processor'; + protected $description = 'Creates an API Platform state processor'; + + protected function getStateType(): StateTypeEnum + { + return StateTypeEnum::Processor; + } +} diff --git a/src/Laravel/Console/Maker/MakeStateProviderCommand.php b/src/Laravel/Console/Maker/MakeStateProviderCommand.php new file mode 100644 index 0000000000..ebb89b6032 --- /dev/null +++ b/src/Laravel/Console/Maker/MakeStateProviderCommand.php @@ -0,0 +1,27 @@ + + * + * 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\Console\Maker; + +use ApiPlatform\Laravel\Console\Maker\Utils\StateTypeEnum; + +final class MakeStateProviderCommand extends AbstractMakeStateCommand +{ + protected $signature = 'make:state-provider'; + protected $description = 'Creates an API Platform state provider'; + + protected function getStateType(): StateTypeEnum + { + return StateTypeEnum::Provider; + } +} diff --git a/src/Laravel/Console/Maker/Resources/skeleton/StateProcessor.tpl.php b/src/Laravel/Console/Maker/Resources/skeleton/StateProcessor.tpl.php new file mode 100644 index 0000000000..3dfbe22549 --- /dev/null +++ b/src/Laravel/Console/Maker/Resources/skeleton/StateProcessor.tpl.php @@ -0,0 +1,16 @@ + + * + * 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\Console\Maker\Utils; + +use Illuminate\Contracts\Filesystem\FileNotFoundException; +use Illuminate\Filesystem\Filesystem; + +final readonly class AppServiceProviderTagger +{ + /** @var string */ + private const APP_SERVICE_PROVIDER_PATH = 'Providers/AppServiceProvider.php'; + + /** @var string */ + private const ITEM_PROVIDER_USE_STATEMENT = 'use ApiPlatform\State\ProviderInterface;'; + + /** @var string */ + private const ITEM_PROCESSOR_USE_STATEMENT = 'use ApiPlatform\State\ProcessorInterface;'; + + public function __construct(private Filesystem $filesystem) + { + } + + /** + * @throws FileNotFoundException + */ + public function addTagToServiceProvider(string $providerName, StateTypeEnum $stateTypeEnum): void + { + $appServiceProviderPath = app_path(self::APP_SERVICE_PROVIDER_PATH); + if (!$this->filesystem->exists($appServiceProviderPath)) { + throw new \RuntimeException('The AppServiceProvider is missing!'); + } + + $serviceProviderContent = $this->filesystem->get($appServiceProviderPath); + + $this->addUseStatement($serviceProviderContent, $this->getStateTypeStatement($stateTypeEnum)); + $this->addUseStatement($serviceProviderContent, \sprintf('use App\\State\\%s;', $providerName)); + $this->addTag($serviceProviderContent, $providerName, $appServiceProviderPath, $stateTypeEnum); + } + + private function addUseStatement(string &$content, string $useStatement): void + { + if (!str_contains($content, $useStatement)) { + $content = preg_replace( + '/^(namespace\s[^;]+;\s*)(\n)/m', + "$1\n$useStatement$2", + $content, + 1 + ); + } + } + + private function addTag(string &$content, string $stateName, string $serviceProviderPath, StateTypeEnum $stateTypeEnum): void + { + $tagStatement = \sprintf("\n\n\t\t\$this->app->tag(%s::class, %sInterface::class);", $stateName, $stateTypeEnum->name); + + if (!str_contains($content, $tagStatement)) { + $content = preg_replace( + '/(public function register\(\)[^{]*{)(.*?)(\s*}\s*})/s', + "$1$2$tagStatement$3", + $content + ); + + $this->filesystem->put($serviceProviderPath, $content); + } + } + + private function getStateTypeStatement(StateTypeEnum $stateTypeEnum): string + { + return match ($stateTypeEnum) { + StateTypeEnum::Provider => self::ITEM_PROVIDER_USE_STATEMENT, + StateTypeEnum::Processor => self::ITEM_PROCESSOR_USE_STATEMENT, + }; + } +} diff --git a/src/Laravel/Console/Maker/Utils/StateTemplateGenerator.php b/src/Laravel/Console/Maker/Utils/StateTemplateGenerator.php new file mode 100644 index 0000000000..2082d512f7 --- /dev/null +++ b/src/Laravel/Console/Maker/Utils/StateTemplateGenerator.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\Console\Maker\Utils; + +use Illuminate\Contracts\Filesystem\FileNotFoundException; +use Illuminate\Filesystem\Filesystem; + +final readonly class StateTemplateGenerator +{ + public function __construct(private Filesystem $filesystem) + { + } + + public function getFilePath(string $directoryPath, string $stateFileName): string + { + return $directoryPath.$stateFileName.'.php'; + } + + /** + * @throws FileNotFoundException + */ + public function generate(string $pathLink, string $stateClassName, StateTypeEnum $stateTypeEnum): void + { + $namespace = 'App\\State'; + $template = $this->loadTemplate($stateTypeEnum); + + $content = strtr($template, [ + '{{ namespace }}' => $namespace, + '{{ class_name }}' => $stateClassName, + ]); + + $this->filesystem->put($pathLink, $content); + } + + /** + * @throws FileNotFoundException + */ + private function loadTemplate(StateTypeEnum $stateTypeEnum): string + { + $templateFile = match ($stateTypeEnum) { + StateTypeEnum::Provider => 'StateProvider.tpl.php', + StateTypeEnum::Processor => 'StateProcessor.tpl.php', + }; + + $templatePath = \dirname(__DIR__).'/Resources/skeleton/'.$templateFile; + + return $this->filesystem->get($templatePath); + } +} diff --git a/src/Laravel/Console/Maker/Utils/StateTypeEnum.php b/src/Laravel/Console/Maker/Utils/StateTypeEnum.php new file mode 100644 index 0000000000..a3c97de623 --- /dev/null +++ b/src/Laravel/Console/Maker/Utils/StateTypeEnum.php @@ -0,0 +1,20 @@ + + * + * 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\Console\Maker\Utils; + +enum StateTypeEnum +{ + case Provider; + case Processor; +} diff --git a/src/Laravel/Console/Maker/Utils/SuccessMessageTrait.php b/src/Laravel/Console/Maker/Utils/SuccessMessageTrait.php new file mode 100644 index 0000000000..e4f112d23c --- /dev/null +++ b/src/Laravel/Console/Maker/Utils/SuccessMessageTrait.php @@ -0,0 +1,31 @@ + + * + * 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\Console\Maker\Utils; + +trait SuccessMessageTrait +{ + private function writeSuccessMessage(string $filePath, StateTypeEnum $stateTypeEnum): void + { + $stateText = strtolower($stateTypeEnum->name); + + $this->newLine(); + $this->line(' '); + $this->line(' Success! '); + $this->line(' '); + $this->newLine(); + $this->line('created: '.$filePath.''); + $this->newLine(); + $this->line("Next: Open your new state $stateText class and start customizing it."); + } +} diff --git a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php index 83906610a7..b0aee59ee8 100644 --- a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php +++ b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php @@ -71,6 +71,7 @@ public function create(string $resourceClass, string $property, array $options = // see https://laravel.com/docs/11.x/eloquent-mutators#attribute-casting $builtinType = $p['cast'] ?? $p['type']; $type = match ($builtinType) { + 'integer' => new Type(Type::BUILTIN_TYPE_INT, $p['nullable']), 'double', 'real' => new Type(Type::BUILTIN_TYPE_FLOAT, $p['nullable']), 'datetime', 'date', 'timestamp' => new Type(Type::BUILTIN_TYPE_OBJECT, $p['nullable'], \DateTime::class), 'immutable_datetime', 'immutable_date' => new Type(Type::BUILTIN_TYPE_OBJECT, $p['nullable'], \DateTimeImmutable::class), diff --git a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyNameCollectionMetadataFactory.php b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyNameCollectionMetadataFactory.php index 5f0811d23e..2db75b3972 100644 --- a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyNameCollectionMetadataFactory.php +++ b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyNameCollectionMetadataFactory.php @@ -39,8 +39,12 @@ public function create(string $resourceClass, array $options = []): PropertyName return $this->decorated?->create($resourceClass, $options) ?? new PropertyNameCollection(); } - $refl = new \ReflectionClass($resourceClass); try { + $refl = new \ReflectionClass($resourceClass); + if ($refl->isAbstract()) { + return $this->decorated?->create($resourceClass, $options) ?? new PropertyNameCollection(); + } + $model = $refl->newInstanceWithoutConstructor(); } catch (\ReflectionException) { return $this->decorated?->create($resourceClass, $options) ?? new PropertyNameCollection(); diff --git a/src/Laravel/Tests/AuthTest.php b/src/Laravel/Tests/AuthTest.php index d72977f81c..4b7d3bc8c9 100644 --- a/src/Laravel/Tests/AuthTest.php +++ b/src/Laravel/Tests/AuthTest.php @@ -14,9 +14,12 @@ namespace ApiPlatform\Laravel\Tests; use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; +use Illuminate\Config\Repository; +use Illuminate\Foundation\Application; use Illuminate\Foundation\Testing\RefreshDatabase; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase; +use Workbench\Database\Factories\UserFactory; class AuthTest extends TestCase { @@ -24,6 +27,22 @@ class AuthTest extends TestCase 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); + $config->set('app.key', 'AckfSECXIvnK5r28GVIWUAxmbBSjTsmF'); + }); + } + + protected function afterRefreshingDatabase(): void + { + UserFactory::new()->create(); + } + public function testGetCollection(): void { $response = $this->get('/api/vaults', ['accept' => ['application/ld+json']]); @@ -44,7 +63,7 @@ public function testAuthenticatedPolicy(): void { $response = $this->post('/tokens/create'); $token = $response->json()['token']; - $response = $this->post('/api/vaults', [], ['accept' => ['application/ld+json'], 'content-type' => ['application/ld+json'], 'authorization' => 'Bearer '.$token]); + $response = $this->postJson('/api/vaults', [], ['accept' => ['application/ld+json'], 'content-type' => ['application/ld+json'], 'authorization' => 'Bearer '.$token]); $response->assertStatus(403); } diff --git a/src/Laravel/Tests/Console/Maker/MakeStateProcessorCommandTest.php b/src/Laravel/Tests/Console/Maker/MakeStateProcessorCommandTest.php new file mode 100644 index 0000000000..c51b5de1fc --- /dev/null +++ b/src/Laravel/Tests/Console/Maker/MakeStateProcessorCommandTest.php @@ -0,0 +1,118 @@ + + * + * 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\Console\Maker; + +use ApiPlatform\Laravel\Tests\Console\Maker\Utils\AppServiceFileGenerator; +use ApiPlatform\Laravel\Tests\Console\Maker\Utils\PathResolver; +use Illuminate\Console\Command; +use Illuminate\Contracts\Filesystem\FileNotFoundException; +use Illuminate\Filesystem\Filesystem; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +class MakeStateProcessorCommandTest extends TestCase +{ + use WithWorkbench; + + /** @var string */ + private const STATE_PROCESSOR_COMMAND = 'make:state-processor'; + /** @var string */ + private const CHOSEN_CLASS_NAME = 'Choose a class name for your state processor (e.g. AwesomeStateProcessor)'; + + private ?Filesystem $filesystem; + private PathResolver $pathResolver; + private AppServiceFileGenerator $appServiceFileGenerator; + + /** + * @throws FileNotFoundException + */ + protected function setup(): void + { + parent::setUp(); + + $this->filesystem = new Filesystem(); + $this->pathResolver = new PathResolver(); + $this->appServiceFileGenerator = new AppServiceFileGenerator($this->filesystem); + + $this->appServiceFileGenerator->regenerateProviderFile(); + } + + /** + * @throws FileNotFoundException + */ + public function testMakeStateProviderCommand(): void + { + $processorName = 'MyStateProcessor'; + $filePath = $this->pathResolver->generateStateFilename($processorName); + $appServiceProviderPath = $this->pathResolver->getServiceProviderFilePath(); + + $this->artisan(self::STATE_PROCESSOR_COMMAND) + ->expectsQuestion(self::CHOSEN_CLASS_NAME, $processorName) + ->expectsOutputToContain('Success!') + ->expectsOutputToContain("created: $filePath") + ->expectsOutputToContain('Next: Open your new state processor class and start customizing it.') + ->assertExitCode(Command::SUCCESS); + + $this->assertFileExists($filePath); + + $appServiceProviderContent = $this->filesystem->get($appServiceProviderPath); + $this->assertStringContainsString('use ApiPlatform\State\ProcessorInterface;', $appServiceProviderContent); + $this->assertStringContainsString("use App\State\\$processorName;", $appServiceProviderContent); + $this->assertStringContainsString('$this->app->tag(MyStateProcessor::class, ProcessorInterface::class);', $appServiceProviderContent); + + $this->filesystem->delete($filePath); + } + + public function testWhenStateProviderClassAlreadyExists(): void + { + $processorName = 'ExistingProcessor'; + $existingFile = $this->pathResolver->generateStateFilename($processorName); + $this->filesystem->put($existingFile, 'artisan(self::STATE_PROCESSOR_COMMAND) + ->expectsQuestion(self::CHOSEN_CLASS_NAME, $processorName) + ->expectsOutput($expectedError) + ->assertExitCode(Command::FAILURE); + + $this->filesystem->delete($existingFile); + } + + public function testMakeStateProviderCommandWithoutGivenClassName(): void + { + $processorName = 'NoEmptyClassName'; + $filePath = $this->pathResolver->generateStateFilename($processorName); + + $this->artisan(self::STATE_PROCESSOR_COMMAND) + ->expectsQuestion(self::CHOSEN_CLASS_NAME, '') + ->expectsOutput('[ERROR] This value cannot be blank.') + ->expectsQuestion(self::CHOSEN_CLASS_NAME, $processorName) + ->assertExitCode(Command::SUCCESS); + + $this->assertFileExists($filePath); + + $this->filesystem->delete($filePath); + } + + /** + * @throws FileNotFoundException + */ + protected function tearDown(): void + { + parent::tearDown(); + + $this->appServiceFileGenerator->regenerateProviderFile(); + } +} diff --git a/src/Laravel/Tests/Console/Maker/MakeStateProviderCommandTest.php b/src/Laravel/Tests/Console/Maker/MakeStateProviderCommandTest.php new file mode 100644 index 0000000000..c259c79fa3 --- /dev/null +++ b/src/Laravel/Tests/Console/Maker/MakeStateProviderCommandTest.php @@ -0,0 +1,118 @@ + + * + * 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\Console\Maker; + +use ApiPlatform\Laravel\Tests\Console\Maker\Utils\AppServiceFileGenerator; +use ApiPlatform\Laravel\Tests\Console\Maker\Utils\PathResolver; +use Illuminate\Console\Command; +use Illuminate\Contracts\Filesystem\FileNotFoundException; +use Illuminate\Filesystem\Filesystem; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +class MakeStateProviderCommandTest extends TestCase +{ + use WithWorkbench; + + /** @var string */ + private const MAKE_STATE_PROVIDER_COMMAND = 'make:state-provider'; + /** @var string */ + private const STATE_PROVIDER_CLASS_NAME = 'Choose a class name for your state provider (e.g. AwesomeStateProvider)'; + + private ?Filesystem $filesystem; + private PathResolver $pathResolver; + private AppServiceFileGenerator $appServiceFileGenerator; + + /** + * @throws FileNotFoundException + */ + protected function setup(): void + { + parent::setUp(); + + $this->filesystem = new Filesystem(); + $this->pathResolver = new PathResolver(); + $this->appServiceFileGenerator = new AppServiceFileGenerator($this->filesystem); + + $this->appServiceFileGenerator->regenerateProviderFile(); + } + + /** + * @throws FileNotFoundException + */ + public function testMakeStateProviderCommand(): void + { + $providerName = 'MyStateProvider'; + $filePath = $this->pathResolver->generateStateFilename($providerName); + $appServiceProviderPath = $this->pathResolver->getServiceProviderFilePath(); + + $this->artisan(self::MAKE_STATE_PROVIDER_COMMAND) + ->expectsQuestion(self::STATE_PROVIDER_CLASS_NAME, $providerName) + ->expectsOutputToContain('Success!') + ->expectsOutputToContain("created: $filePath") + ->expectsOutputToContain('Next: Open your new state provider class and start customizing it.') + ->assertExitCode(Command::SUCCESS); + + $this->assertFileExists($filePath); + + $appServiceProviderContent = $this->filesystem->get($appServiceProviderPath); + $this->assertStringContainsString('use ApiPlatform\State\ProviderInterface;', $appServiceProviderContent); + $this->assertStringContainsString("use App\State\\$providerName;", $appServiceProviderContent); + $this->assertStringContainsString('$this->app->tag(MyStateProvider::class, ProviderInterface::class);', $appServiceProviderContent); + + $this->filesystem->delete($filePath); + } + + public function testWhenStateProviderClassAlreadyExists(): void + { + $providerName = 'ExistingProvider'; + $existingFile = $this->pathResolver->generateStateFilename($providerName); + $this->filesystem->put($existingFile, 'artisan(self::MAKE_STATE_PROVIDER_COMMAND) + ->expectsQuestion(self::STATE_PROVIDER_CLASS_NAME, $providerName) + ->expectsOutput($expectedError) + ->assertExitCode(Command::FAILURE); + + $this->filesystem->delete($existingFile); + } + + public function testMakeStateProviderCommandWithoutGivenClassName(): void + { + $providerName = 'NoEmptyClassName'; + $filePath = $this->pathResolver->generateStateFilename($providerName); + + $this->artisan(self::MAKE_STATE_PROVIDER_COMMAND) + ->expectsQuestion(self::STATE_PROVIDER_CLASS_NAME, '') + ->expectsOutput('[ERROR] This value cannot be blank.') + ->expectsQuestion(self::STATE_PROVIDER_CLASS_NAME, $providerName) + ->assertExitCode(Command::SUCCESS); + + $this->assertFileExists($filePath); + + $this->filesystem->delete($filePath); + } + + /** + * @throws FileNotFoundException + */ + protected function tearDown(): void + { + parent::tearDown(); + + $this->appServiceFileGenerator->regenerateProviderFile(); + } +} diff --git a/src/Laravel/Tests/Console/Maker/Resources/skeleton/AppServiceProvider.tpl.php b/src/Laravel/Tests/Console/Maker/Resources/skeleton/AppServiceProvider.tpl.php new file mode 100644 index 0000000000..ff02bb3a99 --- /dev/null +++ b/src/Laravel/Tests/Console/Maker/Resources/skeleton/AppServiceProvider.tpl.php @@ -0,0 +1,27 @@ + + * + * 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\Providers; + +use Illuminate\Support\ServiceProvider; + +class AppServiceProvider extends ServiceProvider +{ + public function boot(): void + { + } + + public function register(): void + { + } +} diff --git a/src/Laravel/Tests/Console/Maker/Utils/AppServiceFileGenerator.php b/src/Laravel/Tests/Console/Maker/Utils/AppServiceFileGenerator.php new file mode 100644 index 0000000000..6edd7363b0 --- /dev/null +++ b/src/Laravel/Tests/Console/Maker/Utils/AppServiceFileGenerator.php @@ -0,0 +1,45 @@ + + * + * 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\Console\Maker\Utils; + +use Illuminate\Contracts\Filesystem\FileNotFoundException; +use Illuminate\Filesystem\Filesystem; + +final readonly class AppServiceFileGenerator +{ + public function __construct(private Filesystem $filesystem) + { + } + + /** + * @throws FileNotFoundException + */ + public function regenerateProviderFile(): void + { + $templatePath = \dirname(__DIR__).'/Resources/skeleton/AppServiceProvider.tpl.php'; + $targetPath = base_path('app/Providers/AppServiceProvider.php'); + + $this->regenerateFileFromTemplate($templatePath, $targetPath); + } + + /** + * @throws FileNotFoundException + */ + private function regenerateFileFromTemplate(string $templatePath, string $targetPath): void + { + $content = $this->filesystem->get($templatePath); + + $this->filesystem->put($targetPath, $content); + } +} diff --git a/src/Laravel/Tests/Console/Maker/Utils/PathResolver.php b/src/Laravel/Tests/Console/Maker/Utils/PathResolver.php new file mode 100644 index 0000000000..ba04cacfbf --- /dev/null +++ b/src/Laravel/Tests/Console/Maker/Utils/PathResolver.php @@ -0,0 +1,32 @@ + + * + * 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\Console\Maker\Utils; + +final readonly class PathResolver +{ + public function getServiceProviderFilePath(): string + { + return base_path('app/Providers/AppServiceProvider.php'); + } + + public function generateStateFilename(string $stateFilename): string + { + return $this->getStateDirectoryPath().$stateFilename.'.php'; + } + + public function getStateDirectoryPath(): string + { + return base_path('app/State/'); + } +} diff --git a/src/Laravel/Tests/Eloquent/Metadata/ModelMetadataTest.php b/src/Laravel/Tests/Eloquent/Metadata/ModelMetadataTest.php index e306c731b8..c76babf070 100644 --- a/src/Laravel/Tests/Eloquent/Metadata/ModelMetadataTest.php +++ b/src/Laravel/Tests/Eloquent/Metadata/ModelMetadataTest.php @@ -37,9 +37,9 @@ public function testHiddenAttributesAreCorrectlyIdentified(): void /** * @return HasMany */ - public function secret(): HasMany + public function secret(): HasMany // @phpstan-ignore-line { - return $this->hasMany(Book::class); + return $this->hasMany(Book::class); // @phpstan-ignore-line } }; @@ -55,9 +55,9 @@ public function testVisibleAttributesAreCorrectlyIdentified(): void /** * @return HasMany */ - public function secret(): HasMany + public function secret(): HasMany // @phpstan-ignore-line { - return $this->hasMany(Book::class); + return $this->hasMany(Book::class); // @phpstan-ignore-line } }; @@ -71,9 +71,9 @@ public function testAllAttributesVisibleByDefault(): void /** * @return HasMany */ - public function secret(): HasMany + public function secret(): HasMany // @phpstan-ignore-line { - return $this->hasMany(Book::class); + return $this->hasMany(Book::class); // @phpstan-ignore-line } }; diff --git a/src/Laravel/Tests/EloquentTest.php b/src/Laravel/Tests/EloquentTest.php index b4bf0ece2f..23960b7ad2 100644 --- a/src/Laravel/Tests/EloquentTest.php +++ b/src/Laravel/Tests/EloquentTest.php @@ -17,6 +17,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase; +use Workbench\Database\Factories\AuthorFactory; +use Workbench\Database\Factories\BookFactory; +use Workbench\Database\Factories\WithAccessorFactory; class EloquentTest extends TestCase { @@ -26,6 +29,8 @@ class EloquentTest extends TestCase public function testSearchFilter(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $book = $response->json()['member'][0]; @@ -35,18 +40,24 @@ public function testSearchFilter(): void public function testValidateSearchFilter(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $response = $this->get('/api/books?isbn=a', ['Accept' => ['application/ld+json']]); $this->assertSame($response->json()['detail'], 'The isbn field must be at least 2 characters.'); } public function testSearchFilterRelation(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $response = $this->get('/api/books?author=1', ['Accept' => ['application/ld+json']]); $this->assertSame($response->json()['member'][0]['author'], '/api/authors/1'); } public function testPropertyFilter(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $book = $response->json()['member'][0]; @@ -60,6 +71,8 @@ public function testPropertyFilter(): void public function testPartialSearchFilter(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $book = $response->json()['member'][0]; @@ -76,6 +89,8 @@ public function testPartialSearchFilter(): void public function testDateFilterEqual(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $book = $response->json()['member'][0]; $updated = $this->patchJson( @@ -93,6 +108,8 @@ public function testDateFilterEqual(): void public function testDateFilterIncludeNull(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $book = $response->json()['member'][0]; $updated = $this->patchJson( @@ -110,6 +127,8 @@ public function testDateFilterIncludeNull(): void public function testDateFilterExcludeNull(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $book = $response->json()['member'][0]; $updated = $this->patchJson( @@ -127,6 +146,8 @@ public function testDateFilterExcludeNull(): void public function testDateFilterGreaterThan(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $bookBefore = $response->json()['member'][0]; $updated = $this->patchJson( @@ -155,9 +176,10 @@ public function testDateFilterGreaterThan(): void public function testDateFilterLowerThanEqual(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $bookBefore = $response->json()['member'][0]; - $updated = $this->patchJson( + $this->patchJson( $bookBefore['@id'], ['publicationDate' => '0001-02-18 00:00:00'], [ @@ -184,6 +206,7 @@ public function testDateFilterLowerThanEqual(): void public function testDateFilterBetween(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $book = $response->json()['member'][0]; $updated = $this->patchJson( @@ -223,6 +246,7 @@ public function testDateFilterBetween(): void public function testSearchFilterWithPropertyPlaceholder(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $response = $this->get('/api/authors', ['Accept' => ['application/ld+json']])->json(); $author = $response['member'][0]; @@ -235,12 +259,14 @@ public function testSearchFilterWithPropertyPlaceholder(): void public function testOrderFilterWithPropertyPlaceholder(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $res = $this->get('/api/authors?order[id]=desc', ['Accept' => ['application/ld+json']])->json(); $this->assertSame($res['member'][0]['id'], 10); } public function testOrFilter(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $response = $this->get('/api/books', ['Accept' => ['application/ld+json']])->json()['member']; $book = $response[0]; $book2 = $response[1]; @@ -251,6 +277,7 @@ public function testOrFilter(): void public function testRangeLowerThanFilter(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $bookBefore = $response->json()['member'][0]; $this->patchJson( @@ -279,6 +306,7 @@ public function testRangeLowerThanFilter(): void public function testRangeLowerThanEqualFilter(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $bookBefore = $response->json()['member'][0]; $this->patchJson( @@ -308,6 +336,7 @@ public function testRangeLowerThanEqualFilter(): void public function testRangeGreaterThanFilter(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $bookBefore = $response->json()['member'][0]; $updated = $this->patchJson( @@ -336,6 +365,7 @@ public function testRangeGreaterThanFilter(): void public function testRangeGreaterThanEqualFilter(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $response = $this->get('/api/books', ['Accept' => ['application/ld+json']]); $bookBefore = $response->json()['member'][0]; $updated = $this->patchJson( @@ -365,12 +395,14 @@ public function testRangeGreaterThanEqualFilter(): void public function testWrongOrderFilter(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $res = $this->get('/api/authors?order[name]=something', ['Accept' => ['application/ld+json']]); $this->assertEquals($res->getStatusCode(), 422); } public function testWithAccessor(): void { + WithAccessorFactory::new()->create(); $res = $this->get('/api/with_accessors/1', ['Accept' => ['application/ld+json']]); $this->assertArraySubset(['name' => 'test'], $res->json()); } diff --git a/src/Laravel/Tests/GraphQlAuthTest.php b/src/Laravel/Tests/GraphQlAuthTest.php index 3804bd87e8..2af920dd61 100644 --- a/src/Laravel/Tests/GraphQlAuthTest.php +++ b/src/Laravel/Tests/GraphQlAuthTest.php @@ -20,6 +20,10 @@ use Orchestra\Testbench\Attributes\DefineEnvironment; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase; +use Workbench\Database\Factories\AuthorFactory; +use Workbench\Database\Factories\BookFactory; +use Workbench\Database\Factories\UserFactory; +use Workbench\Database\Factories\VaultFactory; class GraphQlAuthTest extends TestCase { @@ -27,6 +31,11 @@ class GraphQlAuthTest extends TestCase use RefreshDatabase; use WithWorkbench; + protected function afterRefreshingDatabase(): void + { + UserFactory::new()->create(); + } + /** * @param Application $app */ @@ -34,6 +43,7 @@ protected function defineEnvironment($app): void { tap($app['config'], function (Repository $config): void { $config->set('api-platform.routes.middleware', ['auth:sanctum']); + $config->set('app.key', 'AckfSECXIvnK5r28GVIWUAxmbBSjTsmF'); $config->set('api-platform.graphql.enabled', true); }); } @@ -46,6 +56,7 @@ public function testUnauthenticated(): void public function testAuthenticated(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $response = $this->post('/tokens/create'); $token = $response->json()['token']; $response = $this->get('/api/graphql', ['accept' => ['text/html'], 'authorization' => 'Bearer '.$token]); @@ -64,6 +75,7 @@ public function testAuthenticated(): void public function testPolicy(): void { + VaultFactory::new()->count(10)->create(); $response = $this->post('/tokens/create'); $token = $response->json()['token']; $response = $this->postJson('/api/graphql', ['query' => 'mutation { @@ -86,6 +98,7 @@ 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.key', 'AckfSECXIvnK5r28GVIWUAxmbBSjTsmF'); $config->set('app.debug', false); }); } @@ -93,6 +106,7 @@ protected function useProductionMode($app): void #[DefineEnvironment('useProductionMode')] public function testProductionError(): void { + VaultFactory::new()->count(10)->create(); $response = $this->post('/tokens/create'); $token = $response->json()['token']; $response = $this->postJson('/api/graphql', ['query' => 'mutation { diff --git a/src/Laravel/Tests/GraphQlTest.php b/src/Laravel/Tests/GraphQlTest.php index 8d0e843697..2c3889b84e 100644 --- a/src/Laravel/Tests/GraphQlTest.php +++ b/src/Laravel/Tests/GraphQlTest.php @@ -14,11 +14,13 @@ namespace ApiPlatform\Laravel\Tests; use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; -use Illuminate\Contracts\Config\Repository; +use Illuminate\Config\Repository; use Illuminate\Foundation\Application; use Illuminate\Foundation\Testing\RefreshDatabase; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase; +use Workbench\Database\Factories\AuthorFactory; +use Workbench\Database\Factories\BookFactory; class GraphQlTest extends TestCase { @@ -38,10 +40,30 @@ protected function defineEnvironment($app): void public function testGetBooks(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $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); } + + public function testGetBooksWithPaginationAndOrder(): void + { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); + $response = $this->postJson('/api/graphql', ['query' => '{ + books(first: 3, order: {name: "desc"}) { + edges { + node { + id, name, publicationDate, author { id, name } + } + } + } +}'], ['accept' => ['application/json']]); + $response->assertStatus(200); + $data = $response->json(); + $this->assertArrayHasKey('data', $data); + $this->assertCount(3, $data['data']['books']['edges']); + $this->assertArrayNotHasKey('errors', $data); + } } diff --git a/src/Laravel/Tests/HalTest.php b/src/Laravel/Tests/HalTest.php index 4fc11f4046..b73f9bcf03 100644 --- a/src/Laravel/Tests/HalTest.php +++ b/src/Laravel/Tests/HalTest.php @@ -20,6 +20,8 @@ use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase; use Workbench\App\Models\Book; +use Workbench\Database\Factories\AuthorFactory; +use Workbench\Database\Factories\BookFactory; class HalTest extends TestCase { @@ -35,6 +37,7 @@ protected function defineEnvironment($app): void tap($app['config'], function (Repository $config): void { $config->set('api-platform.formats', ['jsonhal' => ['application/hal+json']]); $config->set('api-platform.docs_formats', ['jsonhal' => ['application/hal+json']]); + $config->set('app.debug', true); }); } @@ -61,6 +64,7 @@ public function testGetEntrypoint(): void public function testGetCollection(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $response = $this->get('/api/books', ['accept' => 'application/hal+json']); $response->assertStatus(200); $response->assertHeader('content-type', 'application/hal+json; charset=utf-8'); @@ -79,6 +83,7 @@ public function testGetCollection(): void public function testGetBook(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->get($iri, ['accept' => ['application/hal+json']]); @@ -103,6 +108,7 @@ public function testGetBook(): void public function testDeleteBook(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->delete($iri, headers: ['accept' => 'application/hal+json']); diff --git a/src/Laravel/Tests/JsonApiTest.php b/src/Laravel/Tests/JsonApiTest.php index 610ad42d70..74833c3ebc 100644 --- a/src/Laravel/Tests/JsonApiTest.php +++ b/src/Laravel/Tests/JsonApiTest.php @@ -21,6 +21,9 @@ use Orchestra\Testbench\TestCase; use Workbench\App\Models\Author; use Workbench\App\Models\Book; +use Workbench\Database\Factories\AuthorFactory; +use Workbench\Database\Factories\BookFactory; +use Workbench\Database\Factories\WithAccessorFactory; class JsonApiTest extends TestCase { @@ -36,6 +39,7 @@ protected function defineEnvironment($app): void tap($app['config'], function (Repository $config): void { $config->set('api-platform.formats', ['jsonapi' => ['application/vnd.api+json']]); $config->set('api-platform.docs_formats', ['jsonapi' => ['application/vnd.api+json']]); + $config->set('app.debug', true); }); } @@ -55,6 +59,7 @@ public function testGetEntrypoint(): void public function testGetCollection(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $response = $this->get('/api/books', ['accept' => ['application/vnd.api+json']]); $response->assertStatus(200); $response->assertHeader('content-type', 'application/vnd.api+json; charset=utf-8'); @@ -72,6 +77,7 @@ public function testGetCollection(): void public function testGetBook(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->get($iri, ['accept' => ['application/vnd.api+json']]); @@ -91,6 +97,7 @@ public function testGetBook(): void public function testCreateBook(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $author = Author::find(1); $response = $this->postJson( '/api/books', @@ -132,6 +139,7 @@ public function testCreateBook(): void public function testUpdateBook(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->putJson( @@ -157,6 +165,7 @@ public function testUpdateBook(): void public function testPatchBook(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->patchJson( @@ -182,10 +191,22 @@ public function testPatchBook(): void public function testDeleteBook(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->delete($iri, headers: ['accept' => 'application/vnd.api+json']); $response->assertStatus(204); $this->assertNull(Book::find($book->id)); } + + public function testRelationWithGroups(): void + { + WithAccessorFactory::new()->create(); + $response = $this->get('/api/with_accessors/1', ['accept' => 'application/vnd.api+json']); + $content = $response->json(); + $this->assertArrayHasKey('data', $content); + $this->assertArrayHasKey('relationships', $content['data']); + $this->assertArrayHasKey('relation', $content['data']['relationships']); + $this->assertArrayHasKey('data', $content['data']['relationships']['relation']); + } } diff --git a/src/Laravel/Tests/JsonLdTest.php b/src/Laravel/Tests/JsonLdTest.php index 4a4363b711..46039ebe68 100644 --- a/src/Laravel/Tests/JsonLdTest.php +++ b/src/Laravel/Tests/JsonLdTest.php @@ -14,11 +14,19 @@ 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; use Workbench\App\Models\Author; use Workbench\App\Models\Book; +use Workbench\Database\Factories\AuthorFactory; +use Workbench\Database\Factories\BookFactory; +use Workbench\Database\Factories\CommentFactory; +use Workbench\Database\Factories\PostFactory; +use Workbench\Database\Factories\SluggableFactory; +use Workbench\Database\Factories\WithAccessorFactory; class JsonLdTest extends TestCase { @@ -26,8 +34,19 @@ class JsonLdTest extends TestCase use RefreshDatabase; use WithWorkbench; + /** + * @param Application $app + */ + protected function defineEnvironment($app): void + { + tap($app['config'], function (Repository $config): void { + $config->set('app.debug', true); + }); + } + public function testGetCollection(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $response = $this->get('/api/books', ['accept' => 'application/ld+json']); $response->assertStatus(200); $response->assertHeader('content-type', 'application/ld+json; charset=utf-8'); @@ -42,6 +61,7 @@ public function testGetCollection(): void public function testGetBook(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $book = Book::first(); $response = $this->get($this->getIriFromResource($book), ['accept' => 'application/ld+json']); $response->assertStatus(200); @@ -56,6 +76,7 @@ public function testGetBook(): void public function testCreateBook(): void { + AuthorFactory::new()->create(); $author = Author::find(1); $response = $this->postJson( '/api/books', @@ -83,6 +104,7 @@ public function testCreateBook(): void public function testUpdateBook(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->putJson( @@ -103,6 +125,7 @@ public function testUpdateBook(): void public function testPatchBook(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->patchJson( @@ -124,6 +147,7 @@ public function testPatchBook(): void public function testDeleteBook(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->delete($iri, headers: ['accept' => 'application/ld+json']); @@ -133,6 +157,7 @@ public function testDeleteBook(): void public function testPatchBookAuthor(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $author = Author::find(2); @@ -169,6 +194,7 @@ public function testSkolemIris(): void public function testSubresourceCollection(): void { + PostFactory::new()->has(CommentFactory::new()->count(10))->count(10)->create(); $response = $this->get('/api/posts', ['accept' => 'application/ld+json']); $response->assertStatus(200); $response->assertHeader('content-type', 'application/ld+json; charset=utf-8'); @@ -201,6 +227,7 @@ public function testSubresourceCollection(): void public function testCreateNotValid(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $author = Author::find(1); $response = $this->postJson( '/api/books', @@ -255,6 +282,7 @@ public function testCreateNotValidPost(): void public function testSluggable(): void { + SluggableFactory::new()->count(10)->create(); $response = $this->get('/api/sluggables', ['accept' => 'application/ld+json']); $response->assertStatus(200); $response->assertHeader('content-type', 'application/ld+json; charset=utf-8'); @@ -277,6 +305,7 @@ public function testApiDocsRegex(): void public function testHidden(): void { + PostFactory::new()->has(CommentFactory::new()->count(10))->count(10)->create(); $response = $this->get('/api/posts/1/comments/1', ['accept' => 'application/ld+json']); $response->assertStatus(200); $response->assertHeader('content-type', 'application/ld+json; charset=utf-8'); @@ -285,6 +314,7 @@ public function testHidden(): void public function testVisible(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $response = $this->get('/api/books', ['accept' => 'application/ld+json']); $response->assertStatus(200); $response->assertHeader('content-type', 'application/ld+json; charset=utf-8'); @@ -298,4 +328,13 @@ public function testError(): void $content = $response->json(); $this->assertArrayHasKey('trace', $content); } + + public function testRelationWithGroups(): void + { + WithAccessorFactory::new()->create(); + $response = $this->get('/api/with_accessors/1', ['accept' => 'application/ld+json']); + $content = $response->json(); + $this->assertArrayHasKey('relation', $content); + $this->assertArrayHasKey('name', $content['relation']); + } } diff --git a/src/Laravel/Tests/Policy/PolicyAllowTest.php b/src/Laravel/Tests/Policy/PolicyAllowTest.php index c5124a6d5e..c07eeb7a45 100644 --- a/src/Laravel/Tests/Policy/PolicyAllowTest.php +++ b/src/Laravel/Tests/Policy/PolicyAllowTest.php @@ -22,6 +22,8 @@ use Orchestra\Testbench\TestCase; use Workbench\App\Models\Author; use Workbench\App\Models\Book; +use Workbench\Database\Factories\AuthorFactory; +use Workbench\Database\Factories\BookFactory; class PolicyAllowTest extends TestCase { @@ -43,17 +45,20 @@ protected function defineEnvironment($app): void tap($app['config'], function (Repository $config): void { $config->set('api-platform.formats', ['jsonapi' => ['application/vnd.api+json']]); $config->set('api-platform.docs_formats', ['jsonapi' => ['application/vnd.api+json']]); + $config->set('app.debug', true); }); } public function testGetCollection(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $response = $this->get('/api/books', ['accept' => ['application/vnd.api+json']]); $response->assertStatus(200); } - public function testGetEmptyColelction(): void + public function testGetEmptyCollection(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $response = $this->get('/api/books?publicationDate[gt]=9999-12-31', ['accept' => ['application/vnd.api+json']]); $response->assertStatus(200); $response->assertJsonFragment([ @@ -67,6 +72,7 @@ public function testGetEmptyColelction(): void public function testGetBook(): void { + BookFactory::new()->has(AuthorFactory::new())->count(10)->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->get($iri, ['accept' => ['application/vnd.api+json']]); @@ -75,6 +81,7 @@ public function testGetBook(): void public function testCreateBook(): void { + AuthorFactory::new()->create(); $author = Author::find(1); $response = $this->postJson( '/api/books', @@ -106,6 +113,7 @@ public function testCreateBook(): void public function testUpdateBook(): void { + BookFactory::new()->has(AuthorFactory::new())->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->putJson( @@ -123,6 +131,7 @@ public function testUpdateBook(): void public function testPatchBook(): void { + BookFactory::new()->has(AuthorFactory::new())->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->patchJson( @@ -140,6 +149,7 @@ public function testPatchBook(): void public function testDeleteBook(): void { + BookFactory::new()->has(AuthorFactory::new())->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->delete($iri, headers: ['accept' => 'application/vnd.api+json']); diff --git a/src/Laravel/Tests/Policy/PolicyDenyTest.php b/src/Laravel/Tests/Policy/PolicyDenyTest.php index 60d6eaa9ed..dfdf114ae4 100644 --- a/src/Laravel/Tests/Policy/PolicyDenyTest.php +++ b/src/Laravel/Tests/Policy/PolicyDenyTest.php @@ -22,6 +22,8 @@ use Orchestra\Testbench\TestCase; use Workbench\App\Models\Author; use Workbench\App\Models\Book; +use Workbench\Database\Factories\AuthorFactory; +use Workbench\Database\Factories\BookFactory; class PolicyDenyTest extends TestCase { @@ -43,6 +45,7 @@ protected function defineEnvironment($app): void tap($app['config'], function (Repository $config): void { $config->set('api-platform.formats', ['jsonapi' => ['application/vnd.api+json']]); $config->set('api-platform.docs_formats', ['jsonapi' => ['application/vnd.api+json']]); + $config->set('app.debug', true); }); } @@ -54,6 +57,7 @@ public function testGetCollection(): void public function testGetBook(): void { + BookFactory::new()->has(AuthorFactory::new())->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->get($iri, ['accept' => ['application/vnd.api+json']]); @@ -62,6 +66,7 @@ public function testGetBook(): void public function testCreateBook(): void { + BookFactory::new()->has(AuthorFactory::new())->create(); $author = Author::find(1); $response = $this->postJson( '/api/books', @@ -93,6 +98,7 @@ public function testCreateBook(): void public function testUpdateBook(): void { + BookFactory::new()->has(AuthorFactory::new())->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->putJson( @@ -110,6 +116,7 @@ public function testUpdateBook(): void public function testPatchBook(): void { + BookFactory::new()->has(AuthorFactory::new())->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->patchJson( @@ -127,6 +134,7 @@ public function testPatchBook(): void public function testDeleteBook(): void { + BookFactory::new()->has(AuthorFactory::new())->create(); $book = Book::first(); $iri = $this->getIriFromResource($book); $response = $this->delete($iri, headers: ['accept' => 'application/vnd.api+json']); diff --git a/src/Laravel/phpstan.neon.dist b/src/Laravel/phpstan.neon.dist index b752955ad8..4bae067611 100644 --- a/src/Laravel/phpstan.neon.dist +++ b/src/Laravel/phpstan.neon.dist @@ -13,8 +13,8 @@ parameters: - State - Test - Tests -# ignoreErrors: -# - '#PHPDoc tag @var#' + ignoreErrors: + - '#Cannot call method expectsQuestion#' # # excludePaths: # - ./*/*/FileToBeExcluded.php diff --git a/src/Laravel/workbench/app/Models/Book.php b/src/Laravel/workbench/app/Models/Book.php index c458cb68c4..33bcf09342 100644 --- a/src/Laravel/workbench/app/Models/Book.php +++ b/src/Laravel/workbench/app/Models/Book.php @@ -15,6 +15,7 @@ use ApiPlatform\Laravel\Eloquent\Filter\DateFilter; use ApiPlatform\Laravel\Eloquent\Filter\EqualsFilter; +use ApiPlatform\Laravel\Eloquent\Filter\OrderFilter; use ApiPlatform\Laravel\Eloquent\Filter\OrFilter; use ApiPlatform\Laravel\Eloquent\Filter\PartialSearchFilter; use ApiPlatform\Laravel\Eloquent\Filter\RangeFilter; @@ -22,6 +23,8 @@ use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; @@ -44,6 +47,14 @@ new Post(), new Delete(), new GetCollection(), + ], + graphQlOperations: [ + new Query(), + new QueryCollection( + parameters: [ + new QueryParameter(key: 'order[:property]', filter: OrderFilter::class), + ], + ), ] )] #[QueryParameter(key: 'isbn', filter: PartialSearchFilter::class, constraints: 'min:2')] diff --git a/src/Laravel/workbench/app/Models/Vault.php b/src/Laravel/workbench/app/Models/Vault.php index b25d3839d6..2e22a52d6c 100644 --- a/src/Laravel/workbench/app/Models/Vault.php +++ b/src/Laravel/workbench/app/Models/Vault.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\Post; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -35,7 +36,7 @@ ), new Delete(middleware: 'auth:sanctum', rules: VaultFormRequest::class, provider: [self::class, 'provide']), ], - graphQlOperations: [new Mutation(name: 'update', policy: 'update')] + graphQlOperations: [new Query(name: 'item_query'), new Mutation(name: 'update', policy: 'update')] )] class Vault extends Model { diff --git a/src/Laravel/workbench/app/Models/WithAccessor.php b/src/Laravel/workbench/app/Models/WithAccessor.php index 74d4d4f282..0c19809fa8 100644 --- a/src/Laravel/workbench/app/Models/WithAccessor.php +++ b/src/Laravel/workbench/app/Models/WithAccessor.php @@ -13,18 +13,28 @@ namespace Workbench\App\Models; +use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Symfony\Component\Serializer\Attribute\Groups; -#[ApiResource] +#[ApiResource(normalizationContext: ['groups' => ['read']])] class WithAccessor extends Model { use HasFactory; protected $hidden = ['created_at', 'updated_at', 'id']; + #[ApiProperty(serialize: [new Groups(['read'])])] + public function relation(): BelongsTo + { + return $this->belongsTo(WithAccessorRelation::class); + } + + #[ApiProperty(serialize: [new Groups(['read'])])] protected function name(): Attribute { return Attribute::make( diff --git a/src/Laravel/workbench/app/Models/WithAccessorRelation.php b/src/Laravel/workbench/app/Models/WithAccessorRelation.php new file mode 100644 index 0000000000..7cad5bbd45 --- /dev/null +++ b/src/Laravel/workbench/app/Models/WithAccessorRelation.php @@ -0,0 +1,26 @@ + + * + * 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\ApiResource; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Symfony\Component\Serializer\Attribute\Groups; + +#[Groups(['read'])] +#[ApiResource(operations: [])] +class WithAccessorRelation extends Model +{ + use HasFactory; +} diff --git a/src/Laravel/workbench/database/factories/WithAccessorFactory.php b/src/Laravel/workbench/database/factories/WithAccessorFactory.php index f5c026199d..9420af363d 100644 --- a/src/Laravel/workbench/database/factories/WithAccessorFactory.php +++ b/src/Laravel/workbench/database/factories/WithAccessorFactory.php @@ -39,6 +39,7 @@ public function definition(): array { return [ 'name' => strtolower(fake()->name()), + 'relation_id' => WithAccessorRelationFactory::new(), ]; } } diff --git a/src/Laravel/workbench/database/factories/WithAccessorRelationFactory.php b/src/Laravel/workbench/database/factories/WithAccessorRelationFactory.php new file mode 100644 index 0000000000..7352776cb2 --- /dev/null +++ b/src/Laravel/workbench/database/factories/WithAccessorRelationFactory.php @@ -0,0 +1,44 @@ + + * + * 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\WithAccessorRelation; + +/** + * @template TModel of \Workbench\App\Models\WithAccessorRelation + * + * @extends \Illuminate\Database\Eloquent\Factories\Factory + */ +class WithAccessorRelationFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = WithAccessorRelation::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => strtolower(fake()->name()), + ]; + } +} diff --git a/src/Laravel/workbench/database/migrations/2024_09_24_065934_create_with_accessors_table.php b/src/Laravel/workbench/database/migrations/2024_09_24_065934_create_with_accessors_table.php index 58b640ecc9..7636f04818 100644 --- a/src/Laravel/workbench/database/migrations/2024_09_24_065934_create_with_accessors_table.php +++ b/src/Laravel/workbench/database/migrations/2024_09_24_065934_create_with_accessors_table.php @@ -21,9 +21,17 @@ */ public function up(): void { + Schema::create('with_accessor_relations', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->timestamps(); + }); + Schema::create('with_accessors', function (Blueprint $table): void { $table->id(); $table->string('name'); + $table->integer('relation_id')->unsigned(); + $table->foreign('relation_id')->references('id')->on('with_accessor_relations'); $table->timestamps(); }); } @@ -34,5 +42,6 @@ public function up(): void public function down(): void { Schema::dropIfExists('with_accessors'); + Schema::dropIfExists('with_accessors_relation'); } }; diff --git a/src/Metadata/ApiProperty.php b/src/Metadata/ApiProperty.php index b2cbca4eec..1839f220b2 100644 --- a/src/Metadata/ApiProperty.php +++ b/src/Metadata/ApiProperty.php @@ -14,6 +14,12 @@ namespace ApiPlatform\Metadata; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\Attribute\Context; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\Ignore; +use Symfony\Component\Serializer\Attribute\MaxDepth; +use Symfony\Component\Serializer\Attribute\SerializedName; +use Symfony\Component\Serializer\Attribute\SerializedPath; /** * ApiProperty annotation. @@ -23,24 +29,28 @@ #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER | \Attribute::TARGET_CLASS_CONSTANT | \Attribute::TARGET_CLASS)] final class ApiProperty { + private ?array $types; + private ?array $serialize; + /** - * @param bool|null $readableLink https://api-platform.com/docs/core/serialization/#force-iri-with-relations-of-the-same-type-parentchilds-relations - * @param bool|null $writableLink https://api-platform.com/docs/core/serialization/#force-iri-with-relations-of-the-same-type-parentchilds-relations - * @param bool|null $required https://api-platform.com/docs/admin/validation/#client-side-validation - * @param bool|null $identifier https://api-platform.com/docs/core/identifiers/ - * @param mixed $example https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts - * @param string|null $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties - * @param bool|null $fetchEager https://api-platform.com/docs/core/performance/#eager-loading - * @param array|null $jsonldContext https://api-platform.com/docs/core/extending-jsonld-context/#extending-json-ld-and-hydra-contexts - * @param array|null $openapiContext https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts - * @param bool|null $push https://api-platform.com/docs/core/push-relations/ - * @param string|\Stringable|null $security https://api-platform.com/docs/core/security - * @param string|\Stringable|null $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization - * @param string[] $types the RDF types of this property - * @param string[] $iris - * @param Type[] $builtinTypes - * @param string|null $uriTemplate (experimental) whether to return the subRessource collection IRI instead of an iterable of IRI - * @param string|null $property The property name + * @param bool|null $readableLink https://api-platform.com/docs/core/serialization/#force-iri-with-relations-of-the-same-type-parentchilds-relations + * @param bool|null $writableLink https://api-platform.com/docs/core/serialization/#force-iri-with-relations-of-the-same-type-parentchilds-relations + * @param bool|null $required https://api-platform.com/docs/admin/validation/#client-side-validation + * @param bool|null $identifier https://api-platform.com/docs/core/identifiers/ + * @param mixed $example https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts + * @param string|null $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties + * @param bool|null $fetchEager https://api-platform.com/docs/core/performance/#eager-loading + * @param array|null $jsonldContext https://api-platform.com/docs/core/extending-jsonld-context/#extending-json-ld-and-hydra-contexts + * @param array|null $openapiContext https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts + * @param bool|null $push https://api-platform.com/docs/core/push-relations/ + * @param string|\Stringable|null $security https://api-platform.com/docs/core/security + * @param string|\Stringable|null $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization + * @param string[] $types the RDF types of this property + * @param string[] $iris + * @param Type[] $builtinTypes + * @param string|null $uriTemplate (experimental) whether to return the subRessource collection IRI instead of an iterable of IRI + * @param string|null $property The property name + * @param Context|Groups|Ignore|SerializedName|SerializedPath|MaxDepth|array $serialize Serializer attributes */ public function __construct( private ?string $description = null, @@ -193,7 +203,7 @@ public function __construct( * */ private string|\Stringable|null $securityPostDenormalize = null, - private array|string|null $types = null, + array|string|null $types = null, /* * The related php types. */ @@ -205,11 +215,11 @@ public function __construct( private ?string $uriTemplate = null, private ?string $property = null, private ?string $policy = null, + array|Context|Groups|Ignore|SerializedName|SerializedPath|MaxDepth|null $serialize = null, private array $extraProperties = [], ) { - if (\is_string($types)) { - $this->types = (array) $types; - } + $this->types = \is_string($types) ? (array) $types : $types; + $this->serialize = \is_array($serialize) ? $serialize : (array) $serialize; } public function getProperty(): ?string @@ -600,4 +610,20 @@ public function withPolicy(?string $policy): static return $self; } + + public function getSerialize(): ?array + { + return $this->serialize; + } + + /** + * @param Context|Groups|Ignore|SerializedName|SerializedPath|MaxDepth|array $serialize + */ + public function withSerialize(array|Context|Groups|Ignore|SerializedName|SerializedPath|MaxDepth $serialize): static + { + $self = clone $this; + $self->serialize = (array) $serialize; + + return $self; + } } diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 8739ccb7c7..9e13fb2121 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Metadata\Resource\Factory; +use ApiPlatform\Doctrine\Odm\State\Options as DoctrineODMOptions; +use ApiPlatform\Doctrine\Orm\State\Options as DoctrineORMOptions; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\JsonSchemaFilterInterface; @@ -113,7 +115,7 @@ private function getDefaultParameters(Operation $operation, string $resourceClas if (':property' === $key) { foreach ($propertyNames as $property) { $converted = $this->nameConverter?->denormalize($property) ?? $property; - $propertyParameter = $this->setDefaults($converted, $parameter, $resourceClass, $properties); + $propertyParameter = $this->setDefaults($converted, $parameter, $resourceClass, $properties, $operation); $priority = $propertyParameter->getPriority() ?? $internalPriority--; $parameters->add($converted, $propertyParameter->withPriority($priority)->withKey($converted)); } @@ -133,7 +135,7 @@ private function getDefaultParameters(Operation $operation, string $resourceClas $parameter = $parameter->withExtraProperties($parameter->getExtraProperties() + ['_properties' => $p]); } - $parameter = $this->setDefaults($key, $parameter, $resourceClass, $properties); + $parameter = $this->setDefaults($key, $parameter, $resourceClass, $properties, $operation); $priority = $parameter->getPriority() ?? $internalPriority--; $parameters->add($key, $parameter->withPriority($priority)); } @@ -171,7 +173,7 @@ private function addFilterMetadata(Parameter $parameter): Parameter /** * @param array $properties */ - private function setDefaults(string $key, Parameter $parameter, string $resourceClass, array $properties): Parameter + private function setDefaults(string $key, Parameter $parameter, string $resourceClass, array $properties, Operation $operation): Parameter { if (null === $parameter->getKey()) { $parameter = $parameter->withKey($key); @@ -187,7 +189,7 @@ private function setDefaults(string $key, Parameter $parameter, string $resource } // Read filter description to populate the Parameter - $description = $filter instanceof FilterInterface ? $filter->getDescription($resourceClass) : []; + $description = $filter instanceof FilterInterface ? $filter->getDescription($this->getFilterClass($operation)) : []; if (($schema = $description[$key]['schema'] ?? null) && null === $parameter->getSchema()) { $parameter = $parameter->withSchema($schema); } @@ -220,4 +222,17 @@ private function setDefaults(string $key, Parameter $parameter, string $resource return $this->addFilterMetadata($parameter); } + + private function getFilterClass(Operation $operation): ?string + { + $stateOptions = $operation->getStateOptions(); + if ($stateOptions instanceof DoctrineORMOptions) { + return $stateOptions->getEntityClass(); + } + if ($stateOptions instanceof DoctrineODMOptions) { + return $stateOptions->getDocumentClass(); + } + + return $operation->getClass(); + } } diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlPropertyAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlPropertyAdapter.php index 950ba56fd4..5af03c7119 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlPropertyAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlPropertyAdapter.php @@ -47,7 +47,8 @@ final class XmlPropertyAdapter implements PropertyAdapterInterface 'property', ]; - private const EXCLUDE = ['policy']; + // TODO: add serialize support for XML (policy is Laravel-only) + private const EXCLUDE = ['policy', 'serialize']; /** * {@inheritdoc} diff --git a/src/Serializer/Mapping/Loader/PropertyMetadataLoader.php b/src/Serializer/Mapping/Loader/PropertyMetadataLoader.php new file mode 100644 index 0000000000..87d144a7a6 --- /dev/null +++ b/src/Serializer/Mapping/Loader/PropertyMetadataLoader.php @@ -0,0 +1,161 @@ + + * + * 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\Serializer\Mapping\Loader; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use Illuminate\Database\Eloquent\Model; +use Symfony\Component\Serializer\Attribute\Context; +use Symfony\Component\Serializer\Attribute\DiscriminatorMap; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\Ignore; +use Symfony\Component\Serializer\Attribute\MaxDepth; +use Symfony\Component\Serializer\Attribute\SerializedName; +use Symfony\Component\Serializer\Attribute\SerializedPath; +use Symfony\Component\Serializer\Mapping\AttributeMetadata; +use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; +use Symfony\Component\Serializer\Mapping\ClassDiscriminatorMapping; +use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; +use Symfony\Component\Serializer\Mapping\Loader\LoaderInterface; + +/** + * Loader for PHP attributes using ApiProperty. + */ +final class PropertyMetadataLoader implements LoaderInterface +{ + public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory) + { + } + + public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool + { + $attributesMetadata = $classMetadata->getAttributesMetadata(); + // It's very weird to grab Eloquent's properties in that case as they're never serialized + // the Serializer makes a call on the abstract class, let's save some unneeded work with a condition + if (Model::class === $classMetadata->getName()) { + return false; + } + + $refl = $classMetadata->getReflectionClass(); + $attributes = []; + $classGroups = []; + $classContextAnnotation = null; + + foreach ($refl->getAttributes(ApiProperty::class) as $clAttr) { + $this->addAttributeMetadata($clAttr->newInstance(), $attributes); + } + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + + foreach ($refl->getAttributes() as $a) { + $attribute = $a->newInstance(); + if ($attribute instanceof DiscriminatorMap) { + $classMetadata->setClassDiscriminatorMapping(new ClassDiscriminatorMapping( + $attribute->getTypeProperty(), + $attribute->getMapping() + )); + continue; + } + + if ($attribute instanceof Groups) { + $classGroups = $attribute->getGroups(); + + continue; + } + + if ($attribute instanceof Context) { + $classContextAnnotation = $attribute; + } + } + + foreach ($refl->getProperties() as $reflProperty) { + foreach ($reflProperty->getAttributes(ApiProperty::class) as $propAttr) { + $this->addAttributeMetadata($propAttr->newInstance()->withProperty($reflProperty->name), $attributes); + } + } + + foreach ($refl->getMethods() as $reflMethod) { + foreach ($reflMethod->getAttributes(ApiProperty::class) as $methodAttr) { + $this->addAttributeMetadata($methodAttr->newInstance()->withProperty($reflMethod->getName()), $attributes); + } + } + + foreach ($this->propertyNameCollectionFactory->create($classMetadata->getName()) as $propertyName) { + if (!isset($attributesMetadata[$propertyName])) { + $attributesMetadata[$propertyName] = new AttributeMetadata($propertyName); + $classMetadata->addAttributeMetadata($attributesMetadata[$propertyName]); + } + + foreach ($classGroups as $group) { + $attributesMetadata[$propertyName]->addGroup($group); + } + + if ($classContextAnnotation) { + $this->setAttributeContextsForGroups($classContextAnnotation, $attributesMetadata[$propertyName]); + } + + if (!isset($attributes[$propertyName])) { + continue; + } + + $attributeMetadata = $attributesMetadata[$propertyName]; + + // This code is adapted from Symfony\Component\Serializer\Mapping\Loader\AttributeLoader + foreach ($attributes[$propertyName] as $attr) { + if ($attr instanceof Groups) { + foreach ($attr->getGroups() as $group) { + $attributeMetadata->addGroup($group); + } + continue; + } + + match (true) { + $attr instanceof MaxDepth => $attributeMetadata->setMaxDepth($attr->getMaxDepth()), + $attr instanceof SerializedName => $attributeMetadata->setSerializedName($attr->getSerializedName()), + $attr instanceof SerializedPath => $attributeMetadata->setSerializedPath($attr->getSerializedPath()), + $attr instanceof Ignore => $attributeMetadata->setIgnore(true), + $attr instanceof Context => $this->setAttributeContextsForGroups($attr, $attributeMetadata), + default => null, + }; + } + } + + return true; + } + + /** + * @param ApiProperty[] $attributes + */ + private function addAttributeMetadata(ApiProperty $attribute, array &$attributes): void + { + if (($prop = $attribute->getProperty()) && ($value = $attribute->getSerialize())) { + $attributes[$prop] = $value; + } + } + + private function setAttributeContextsForGroups(Context $annotation, AttributeMetadataInterface $attributeMetadata): void + { + $context = $annotation->getContext(); + $groups = $annotation->getGroups(); + $normalizationContext = $annotation->getNormalizationContext(); + $denormalizationContext = $annotation->getDenormalizationContext(); + if ($normalizationContext || $context) { + $attributeMetadata->setNormalizationContextForGroups($normalizationContext ?: $context, $groups); + } + + if ($denormalizationContext || $context) { + $attributeMetadata->setDenormalizationContextForGroups($denormalizationContext ?: $context, $groups); + } + } +} diff --git a/src/Serializer/Tests/Fixtures/Model/HasRelation.php b/src/Serializer/Tests/Fixtures/Model/HasRelation.php new file mode 100644 index 0000000000..833fee5549 --- /dev/null +++ b/src/Serializer/Tests/Fixtures/Model/HasRelation.php @@ -0,0 +1,26 @@ + + * + * 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\Serializer\Tests\Fixtures\Model; + +use ApiPlatform\Metadata\ApiProperty; +use Symfony\Component\Serializer\Attribute\Groups; + +class HasRelation +{ + #[ApiProperty(serialize: [new Groups(['read'])])] + public function relation(): Relation + { + return new Relation(); + } +} diff --git a/src/Serializer/Tests/Fixtures/Model/Relation.php b/src/Serializer/Tests/Fixtures/Model/Relation.php new file mode 100644 index 0000000000..4d06da7d1b --- /dev/null +++ b/src/Serializer/Tests/Fixtures/Model/Relation.php @@ -0,0 +1,21 @@ + + * + * 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\Serializer\Tests\Fixtures\Model; + +use Symfony\Component\Serializer\Attribute\Groups; + +#[Groups(['read'])] +class Relation +{ +} diff --git a/src/Serializer/Tests/Mapping/Loader/PropertyMetadataLoaderTest.php b/src/Serializer/Tests/Mapping/Loader/PropertyMetadataLoaderTest.php new file mode 100644 index 0000000000..27a8fc5593 --- /dev/null +++ b/src/Serializer/Tests/Mapping/Loader/PropertyMetadataLoaderTest.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\Serializer\Tests\Mapping\Loader; + +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Serializer\Mapping\Loader\PropertyMetadataLoader; +use ApiPlatform\Serializer\Tests\Fixtures\Model\HasRelation; +use ApiPlatform\Serializer\Tests\Fixtures\Model\Relation; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Mapping\ClassMetadata; + +final class PropertyMetadataLoaderTest extends TestCase +{ + public function testCreateMappingForASetOfProperties(): void + { + $coll = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $coll->method('create')->willReturn(new PropertyNameCollection(['relation'])); + $loader = new PropertyMetadataLoader($coll); + $classMetadata = new ClassMetadata(HasRelation::class); + $loader->loadClassMetadata($classMetadata); + $this->assertArrayHasKey('relation', $classMetadata->attributesMetadata); + $this->assertEquals(['read'], $classMetadata->attributesMetadata['relation']->getGroups()); + } + + public function testCreateMappingForAClass(): void + { + $coll = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $coll->method('create')->willReturn(new PropertyNameCollection(['name'])); + $loader = new PropertyMetadataLoader($coll); + $classMetadata = new ClassMetadata(Relation::class); + $loader->loadClassMetadata($classMetadata); + $this->assertArrayHasKey('name', $classMetadata->attributesMetadata); + $this->assertEquals(['read'], $classMetadata->attributesMetadata['name']->getGroups()); + } +} diff --git a/src/State/Provider/ParameterProvider.php b/src/State/Provider/ParameterProvider.php index 7ffa0c1b92..548d364b7e 100644 --- a/src/State/Provider/ParameterProvider.php +++ b/src/State/Provider/ParameterProvider.php @@ -49,13 +49,13 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $request->attributes->set('_api_header_parameters', $request->headers->all()); } - $context = ['operation' => $operation] + $context; $parameters = $operation->getParameters(); foreach ($parameters ?? [] as $parameter) { $extraProperties = $parameter->getExtraProperties(); unset($extraProperties['_api_values']); $parameters->add($parameter->getKey(), $parameter = $parameter->withExtraProperties($extraProperties)); + $context = ['operation' => $operation] + $context; $values = $this->getParameterValues($parameter, $request, $context); $value = $this->extractParameterValues($parameter, $values); diff --git a/src/Symfony/Bundle/ApiPlatformBundle.php b/src/Symfony/Bundle/ApiPlatformBundle.php index cd5d08e974..f7fc270f08 100644 --- a/src/Symfony/Bundle/ApiPlatformBundle.php +++ b/src/Symfony/Bundle/ApiPlatformBundle.php @@ -23,6 +23,7 @@ use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlResolverPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlTypePass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\MetadataAwareNameConverterPass; +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\SerializerMappingLoaderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\TestClientPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\TestMercureHubPass; use Symfony\Component\DependencyInjection\Compiler\PassConfig; @@ -58,5 +59,6 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new TestClientPass()); $container->addCompilerPass(new TestMercureHubPass()); $container->addCompilerPass(new AuthenticatorManagerPass()); + $container->addCompilerPass(new SerializerMappingLoaderPass()); } } diff --git a/src/Symfony/Bundle/DataCollector/RequestDataCollector.php b/src/Symfony/Bundle/DataCollector/RequestDataCollector.php index ddde1871a0..f409b5cd36 100644 --- a/src/Symfony/Bundle/DataCollector/RequestDataCollector.php +++ b/src/Symfony/Bundle/DataCollector/RequestDataCollector.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\State\Util\RequestAttributesExtractor; +use Composer\InstalledVersions; use PackageVersions\Versions; use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Request; @@ -67,16 +68,38 @@ private function setFilters(ApiResource $resourceMetadata, int $index, array &$f } } + // TODO: 4.1 remove Versions as its deprecated public function getVersion(): ?string { + if (class_exists(InstalledVersions::class)) { + return InstalledVersions::getPrettyVersion('api-platform/symfony') ?? InstalledVersions::getPrettyVersion('api-platform/core'); + } + if (!class_exists(Versions::class)) { return null; } - $version = Versions::getVersion('api-platform/core'); - preg_match('/^v(.*?)@/', (string) $version, $output); + try { + $version = strtok(Versions::getVersion('api-platform/symfony'), '@'); + } catch (\OutOfBoundsException) { + $version = false; + } + + if (false === $version) { + try { + $version = strtok(Versions::getVersion('api-platform/core'), '@'); + } catch (\OutOfBoundsException) { + $version = false; + } + } + + if (false === $version) { + return null; + } + + preg_match('/^v(.*?)$/', $version, $output); - return $output[1] ?? strtok($version, '@'); + return $output[1] ?? $version; } /** diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index c831e7c60f..5c83b24b09 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -34,6 +34,7 @@ use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\UriVariableTransformerInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\RamseyUuid\Serializer\UuidDenormalizer; use ApiPlatform\State\ApiResource\Error; use ApiPlatform\State\ParameterProviderInterface; use ApiPlatform\State\ProcessorInterface; @@ -180,7 +181,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $loader->load('api.xml'); $loader->load('filter.xml'); - if (class_exists(Uuid::class)) { + if (class_exists(UuidDenormalizer::class) && class_exists(Uuid::class)) { $loader->load('ramsey_uuid.xml'); } diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/MetadataAwareNameConverterPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/MetadataAwareNameConverterPass.php index a1b93b067f..a611e305ec 100644 --- a/src/Symfony/Bundle/DependencyInjection/Compiler/MetadataAwareNameConverterPass.php +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/MetadataAwareNameConverterPass.php @@ -39,14 +39,20 @@ public function process(ContainerBuilder $container): void } $definition = $container->getDefinition('serializer.name_converter.metadata_aware'); - $num = \count($definition->getArguments()); + $key = '$fallbackNameConverter'; + $arguments = $definition->getArguments(); + if (false === \array_key_exists($key, $arguments)) { + $key = 1; + } if ($container->hasAlias('api_platform.name_converter')) { $nameConverter = new Reference((string) $container->getAlias('api_platform.name_converter')); - if (1 === $num) { + + // old symfony versions + if (false === \array_key_exists($key, $arguments)) { $definition->addArgument($nameConverter); - } elseif (1 < $num && null === $definition->getArgument(1)) { - $definition->setArgument(1, $nameConverter); + } elseif (null === $definition->getArgument($key)) { + $definition->setArgument($key, $nameConverter); } } diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/SerializerMappingLoaderPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/SerializerMappingLoaderPass.php new file mode 100644 index 0000000000..7a9cc0d5c1 --- /dev/null +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/SerializerMappingLoaderPass.php @@ -0,0 +1,28 @@ + + * + * 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\Symfony\Bundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +final class SerializerMappingLoaderPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + $chainLoader = $container->getDefinition('serializer.mapping.chain_loader'); + $loaders = $chainLoader->getArgument(0); + $loaders[] = $container->getDefinition('api_platform.serializer.property_metadata_loader'); + $container->getDefinition('serializer.mapping.cache_warmer')->replaceArgument(0, $loaders); + } +} diff --git a/src/Symfony/Bundle/Resources/config/api.xml b/src/Symfony/Bundle/Resources/config/api.xml index 275bef9278..7bce6a8f3a 100644 --- a/src/Symfony/Bundle/Resources/config/api.xml +++ b/src/Symfony/Bundle/Resources/config/api.xml @@ -175,5 +175,9 @@ + + + + diff --git a/src/Symfony/Bundle/Resources/config/hydra.xml b/src/Symfony/Bundle/Resources/config/hydra.xml index cad570788a..021b885d3b 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.xml +++ b/src/Symfony/Bundle/Resources/config/hydra.xml @@ -25,6 +25,7 @@ %api_platform.validator.serialize_payload_fields% + %api_platform.serializer.default_context% diff --git a/src/Symfony/Bundle/Resources/config/jsonld.xml b/src/Symfony/Bundle/Resources/config/jsonld.xml index 0a1b8eb052..6bb7ea31d1 100644 --- a/src/Symfony/Bundle/Resources/config/jsonld.xml +++ b/src/Symfony/Bundle/Resources/config/jsonld.xml @@ -36,6 +36,12 @@ + + + %api_platform.serializer.default_context% + + + @@ -46,7 +52,7 @@ - + diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index a251115188..ac70c86a0d 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -65,10 +65,10 @@ "api-platform/doctrine-odm": "To support MongoDB. Only versions 4.0 and later are supported.", "api-platform/elasticsearch": "To support Elasticsearch.", "api-platform/graphql": "To support GraphQL.", + "api-platform/ramsey-uuid": "To support Ramsey's UUID identifiers.", "ocramius/package-versions": "To display the API Platform's version in the debug bar.", "phpstan/phpdoc-parser": "To support extracting metadata from PHPDoc.", "psr/cache-implementation": "To use metadata caching.", - "ramsey/uuid": "To support Ramsey's UUID identifiers.", "symfony/cache": "To have metadata caching when using Symfony integration.", "symfony/config": "To load XML configuration files.", "symfony/expression-language": "To use authorization and mercure advanced features.", diff --git a/tests/Fixtures/TestBundle/ApiResource/AgentApi.php b/tests/Fixtures/TestBundle/ApiResource/AgentApi.php new file mode 100644 index 0000000000..9d040921bb --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/AgentApi.php @@ -0,0 +1,90 @@ + + * + * 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\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Doctrine\Odm\Filter\DateFilter as OdmDateFilter; +use ApiPlatform\Doctrine\Odm\State\Options as OdmOptions; +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\AgentDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Agent; + +#[ApiFilter(DateFilter::class, properties: ['birthday'], alias: 'app_filter_date')] +#[ApiResource( + shortName: 'Agent', + operations: [ + new GetCollection(parameters: [ + 'birthday' => new QueryParameter(filter: 'app_filter_date'), + ]), + ], + stateOptions: new Options(entityClass: Agent::class) +)] +#[ApiFilter(OdmDateFilter::class, properties: ['birthday'], alias: 'app_filter_date_odm')] +#[ApiResource( + shortName: 'AgentDocument', + operations: [ + new GetCollection(parameters: [ + 'birthday' => new QueryParameter(filter: 'app_filter_date_odm'), + ]), + ], + stateOptions: new OdmOptions(documentClass: AgentDocument::class) +)] +class AgentApi +{ + private ?int $id = null; + + private ?string $name = null; + + private ?\DateTimeInterface $birthday = null; + + public function getId(): ?int + { + return $this->id; + } + + public function setId(?int $id): self + { + $this->id = $id; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function getBirthday(): ?\DateTimeInterface + { + return $this->birthday; + } + + public function setBirthday(?\DateTimeInterface $birthday): self + { + $this->birthday = $birthday; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue6673/MutlipleParameterProvider.php b/tests/Fixtures/TestBundle/ApiResource/Issue6673/MutlipleParameterProvider.php new file mode 100644 index 0000000000..83ef082930 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue6673/MutlipleParameterProvider.php @@ -0,0 +1,63 @@ + + * + * 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\Tests\Fixtures\TestBundle\ApiResource\Issue6673; + +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; + +#[GetCollection( + uriTemplate: 'issue6673_multiple_parameter_provider', + shortName: 'multiple_parameter_provider', + outputFormats: ['json'], + parameters: [ + 'a' => new QueryParameter( + provider: [self::class, 'parameterOneProvider'], + ), + 'b' => new QueryParameter( + provider: [self::class, 'parameterTwoProvider'], + ), + ], + provider: [self::class, 'provide'] +)] +final class MutlipleParameterProvider +{ + public function __construct(public readonly string $id) + { + } + + public static function provide(Operation $operation): ?array + { + return $operation->getNormalizationContext(); + } + + public static function parameterOneProvider(Parameter $parameter, array $parameters = [], array $context = []): ?Operation + { + $operation = $context['operation']; + $context = $operation->getNormalizationContext() ?? []; + $context['a'] = $parameter->getValue(); + + return $operation->withNormalizationContext($context); + } + + public static function parameterTwoProvider(Parameter $parameter, array $parameters = [], array $context = []): ?Operation + { + $operation = $context['operation']; + $context = $operation->getNormalizationContext() ?? []; + $context['b'] = $parameter->getValue(); + + return $operation->withNormalizationContext($context); + } +} diff --git a/tests/Fixtures/TestBundle/Document/AgentDocument.php b/tests/Fixtures/TestBundle/Document/AgentDocument.php new file mode 100644 index 0000000000..6605e9cac7 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/AgentDocument.php @@ -0,0 +1,95 @@ + + * + * 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\Tests\Fixtures\TestBundle\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +class AgentDocument +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + public ?int $id = null; + + #[ODM\Field] + public ?string $name = null; + + #[ODM\Field] + public ?string $apiKey = null; + + #[ODM\Field] + public ?\DateTimeImmutable $createdAt = null; + + #[ODM\Field] + public ?\DateTimeImmutable $birthday = null; + + public function getId(): ?int + { + return $this->id; + } + + public function setId(int $id): static + { + $this->id = $id; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getApiKey(): ?string + { + return $this->apiKey; + } + + public function setApiKey(string $apiKey): static + { + $this->apiKey = $apiKey; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getBirthday(): ?\DateTimeImmutable + { + return $this->birthday; + } + + public function setBirthday(\DateTimeImmutable $birthday): static + { + $this->birthday = $birthday; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Agent.php b/tests/Fixtures/TestBundle/Entity/Agent.php new file mode 100644 index 0000000000..2b32c4aee7 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Agent.php @@ -0,0 +1,82 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class Agent +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + public ?int $id = null; + + #[ORM\Column(length: 255)] + public ?string $name = null; + + #[ORM\Column] + public ?\DateTimeImmutable $createdAt = null; + + #[ORM\Column] + public ?\DateTimeImmutable $birthday = null; + + public function getId(): ?int + { + return $this->id; + } + + public function setId(int $id): static + { + $this->id = $id; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getBirthday(): ?\DateTimeImmutable + { + return $this->birthday; + } + + public function setBirthday(\DateTimeImmutable $birthday): static + { + $this->birthday = $birthday; + + return $this; + } +} diff --git a/tests/Functional/Parameters/ParameterProviderTest.php b/tests/Functional/Parameters/ParameterProviderTest.php new file mode 100644 index 0000000000..0f5f6840bd --- /dev/null +++ b/tests/Functional/Parameters/ParameterProviderTest.php @@ -0,0 +1,37 @@ + + * + * 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\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6673\MutlipleParameterProvider; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ParameterProviderTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [MutlipleParameterProvider::class]; + } + + public function testMultipleParameterProviderShouldChangeTheOperation(): void + { + $response = self::createClient()->request('GET', 'issue6673_multiple_parameter_provider?a=1&b=2', ['headers' => ['accept' => 'application/json']]); + $this->assertArraySubset(['a' => '1', 'b' => '2'], $response->toArray()); + } +} diff --git a/tests/Functional/Parameters/QueryParameterStateOptionsTest.php b/tests/Functional/Parameters/QueryParameterStateOptionsTest.php new file mode 100644 index 0000000000..a415f7632a --- /dev/null +++ b/tests/Functional/Parameters/QueryParameterStateOptionsTest.php @@ -0,0 +1,103 @@ + + * + * 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\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\AgentApi; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\AgentDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Agent; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Tools\SchemaTool; + +final class QueryParameterStateOptionsTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [AgentApi::class]; + } + + public function testQueryParameterStateOptions(): void + { + $this->recreateSchema(); + $response = self::createClient()->request('GET', ($this->isMongoDb() ? 'agent_documents' : 'agents').'?birthday[before]=2000-01-01&birthday[after]=1990-01-01'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $agents = $data['hydra:member']; + $this->assertCount(1, $agents); + + $validBirthdays = array_column($agents, 'birthday'); + $this->assertValidBirthdayRange($validBirthdays); + } + + /** + * @param array $birthdays + */ + private function assertValidBirthdayRange(array $birthdays): void + { + foreach ($birthdays as $birthday) { + $this->assertLessThanOrEqual('2000-01-01T00:00:00+00:00', $birthday, "The birthday date {$birthday} exceeds the upper limit."); + $this->assertGreaterThanOrEqual('1990-01-01T00:00:00+00:00', $birthday, "The birthday date {$birthday} is below the lower limit."); + } + } + + /** + * @param array $options kernel options + */ + private function recreateSchema(array $options = []): void + { + self::bootKernel($options); + $container = static::getContainer(); + $isMongoDb = $this->isMongoDb(); + $registry = $container->get($isMongoDb ? 'doctrine_mongodb' : 'doctrine'); + $resourceClass = $isMongoDb ? AgentDocument::class : Agent::class; + $manager = $registry->getManager(); + + if ($manager instanceof EntityManagerInterface) { + $classes = $manager->getClassMetadata($resourceClass); + $schemaTool = new SchemaTool($manager); + + @$schemaTool->dropSchema([$classes]); + @$schemaTool->createSchema([$classes]); + } elseif ($manager instanceof DocumentManager) { + @$manager->getSchemaManager()->dropCollections(); + } + + $birthdays = [new \DateTimeImmutable('2002-01-01'), new \DateTimeImmutable(), new \DateTimeImmutable('1990-12-31')]; + foreach ($birthdays as $birthday) { + $agent = (new $resourceClass()) + ->setName('Agent '.$birthday->format('Y')) + ->setBirthday($birthday) + ->setCreatedAt(new \DateTimeImmutable()); + + $manager->persist($agent); + } + + $manager->flush(); + } + + private function isMongoDb(): bool + { + $container = static::getContainer(); + + return 'mongodb' === $container->getParameter('kernel.environment'); + } +} diff --git a/tests/JsonLd/Serializer/ErrorNormalizerTest.php b/tests/JsonLd/Serializer/ErrorNormalizerTest.php new file mode 100644 index 0000000000..916217d409 --- /dev/null +++ b/tests/JsonLd/Serializer/ErrorNormalizerTest.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); + +namespace ApiPlatform\Tests\JsonLd\Serializer; + +use ApiPlatform\JsonLd\ContextBuilder; +use ApiPlatform\JsonLd\Serializer\ErrorNormalizer; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final class ErrorNormalizerTest extends TestCase +{ + public function testAddHydraPrefix(): void + { + $provider = $this->createMock(NormalizerInterface::class); + $provider->method('normalize')->willReturn(['@type' => 'Error', 'title' => 'foo', 'description' => 'bar']); + $errorNormalizer = new ErrorNormalizer($provider, ['hydra_prefix' => ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX]); + $res = $errorNormalizer->normalize(new \stdClass()); + $this->assertEquals('hydra:Error', $res['@type']); + $this->assertArrayHasKey('hydra:description', $res); + $this->assertEquals($res['hydra:description'], $res['description']); + $this->assertArrayHasKey('hydra:title', $res); + $this->assertEquals($res['hydra:title'], $res['title']); + } +} diff --git a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php index 68222c33a1..a3ec829b63 100644 --- a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php +++ b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php @@ -365,4 +365,15 @@ public function testResourceWithEnumPropertiesSchema(): void $properties['genders'] ); } + + /** + * Test feature #6716. + */ + public function testGenId(): void + { + $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => 'ApiPlatform\Tests\Fixtures\TestBundle\Entity\DisableIdGeneration', '--type' => 'output', '--format' => 'jsonld']); + $result = $this->tester->getDisplay(); + $json = json_decode($result, associative: true); + $this->assertArrayNotHasKey('@id', $json['definitions']['DisableIdGenerationItem.jsonld']['properties']); + } } diff --git a/tests/Symfony/Bundle/ApiPlatformBundleTest.php b/tests/Symfony/Bundle/ApiPlatformBundleTest.php index 49b423959f..d6b1643e57 100644 --- a/tests/Symfony/Bundle/ApiPlatformBundleTest.php +++ b/tests/Symfony/Bundle/ApiPlatformBundleTest.php @@ -24,6 +24,7 @@ use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlResolverPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlTypePass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\MetadataAwareNameConverterPass; +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\SerializerMappingLoaderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\TestClientPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\TestMercureHubPass; use PHPUnit\Framework\TestCase; @@ -54,6 +55,7 @@ public function testBuild(): void $containerProphecy->addCompilerPass(Argument::type(TestClientPass::class))->willReturn($containerProphecy->reveal())->shouldBeCalled(); $containerProphecy->addCompilerPass(Argument::type(TestMercureHubPass::class))->willReturn($containerProphecy->reveal())->shouldBeCalled(); $containerProphecy->addCompilerPass(Argument::type(AuthenticatorManagerPass::class))->willReturn($containerProphecy->reveal())->shouldBeCalled(); + $containerProphecy->addCompilerPass(Argument::type(SerializerMappingLoaderPass::class))->willReturn($containerProphecy->reveal())->shouldBeCalled(); $bundle = new ApiPlatformBundle(); $bundle->build($containerProphecy->reveal()); diff --git a/tests/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php b/tests/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php index c9af7f056e..9a5a35a00f 100644 --- a/tests/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php +++ b/tests/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Symfony\Bundle\DataCollector\RequestDataCollector; use ApiPlatform\Tests\Fixtures\DummyEntity; +use Composer\InstalledVersions; use PackageVersions\Versions; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -163,7 +164,11 @@ public function testVersionCollection(): void $this->response ); - $this->assertSame(null !== $dataCollector->getVersion(), class_exists(Versions::class)); + if (class_exists(InstalledVersions::class)) { + $this->assertTrue(null !== $dataCollector->getVersion()); + } else { + $this->assertSame(null !== $dataCollector->getVersion(), class_exists(Versions::class)); + } } public function testWithPreviousData(): void diff --git a/tests/Symfony/Bundle/DependencyInjection/Compiler/MetadataAwareNameConverterPassTest.php b/tests/Symfony/Bundle/DependencyInjection/Compiler/MetadataAwareNameConverterPassTest.php index 2ecfd99bc4..d069cc8b39 100644 --- a/tests/Symfony/Bundle/DependencyInjection/Compiler/MetadataAwareNameConverterPassTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/Compiler/MetadataAwareNameConverterPassTest.php @@ -101,4 +101,23 @@ public function testProcessOnlyOneArg(): void $pass->process($containerBuilderProphecy->reveal()); } + + public function testProcessWithAbstractMetadataAware(): void + { + $pass = new MetadataAwareNameConverterPass(); + + $definition = $this->prophesize(Definition::class); + $definition->getArguments()->willReturn(['$metadataFactory' => [], '$fallbackNameConverter' => null])->shouldBeCalled(); + $definition->getArgument('$fallbackNameConverter')->willReturn(null)->shouldBeCalled(); + $definition->setArgument('$fallbackNameConverter', new Reference('app.name_converter'))->willReturn($definition)->shouldBeCalled(); + + $containerBuilderProphecy = $this->prophesize(ContainerBuilder::class); + $containerBuilderProphecy->hasDefinition('serializer.name_converter.metadata_aware')->willReturn(true)->shouldBeCalled(); + $containerBuilderProphecy->hasAlias('api_platform.name_converter')->shouldBeCalled()->willReturn(true); + $containerBuilderProphecy->getAlias('api_platform.name_converter')->shouldBeCalled()->willReturn(new Alias('app.name_converter')); + $containerBuilderProphecy->setAlias('api_platform.name_converter', 'serializer.name_converter.metadata_aware')->shouldBeCalled(); + $containerBuilderProphecy->getDefinition('serializer.name_converter.metadata_aware')->shouldBeCalled()->willReturn($definition); + + $pass->process($containerBuilderProphecy->reveal()); + } }