diff --git a/src/AggregateControllerQueryProvider.php b/src/AggregateControllerQueryProvider.php index a85e97bc1f..432e8623a7 100644 --- a/src/AggregateControllerQueryProvider.php +++ b/src/AggregateControllerQueryProvider.php @@ -29,8 +29,11 @@ class AggregateControllerQueryProvider implements QueryProviderInterface * @param iterable $controllers A list of controllers name in the container. * @param ContainerInterface $controllersContainer The container we will fetch controllers from. */ - public function __construct(private readonly iterable $controllers, private readonly FieldsBuilder $fieldsBuilder, private readonly ContainerInterface $controllersContainer) - { + public function __construct( + private readonly iterable $controllers, + private readonly FieldsBuilder $fieldsBuilder, + private readonly ContainerInterface $controllersContainer, + ) { } /** @return array */ @@ -52,13 +55,26 @@ public function getMutations(): array $mutationList = []; foreach ($this->controllers as $controllerName) { - $controller = $this->controllersContainer->get($controllerName); + $controller = $this->controllersContainer->get($controllerName); $mutationList[$controllerName] = $this->fieldsBuilder->getMutations($controller); } return $this->flattenList($mutationList); } + /** @return array */ + public function getSubscriptions(): array + { + $subscriptionList = []; + + foreach ($this->controllers as $controllerName) { + $controller = $this->controllersContainer->get($controllerName); + $subscriptionList[$controllerName] = $this->fieldsBuilder->getSubscriptions($controller); + } + + return $this->flattenList($subscriptionList); + } + /** * @param array> $list * diff --git a/src/AggregateControllerQueryProviderFactory.php b/src/AggregateControllerQueryProviderFactory.php index 1eb77795e8..9a14dcd062 100644 --- a/src/AggregateControllerQueryProviderFactory.php +++ b/src/AggregateControllerQueryProviderFactory.php @@ -15,12 +15,17 @@ class AggregateControllerQueryProviderFactory implements QueryProviderFactoryInt * @param iterable $controllers A list of controllers name in the container. * @param ContainerInterface $controllersContainer The container we will fetch controllers from. */ - public function __construct(private readonly iterable $controllers, private readonly ContainerInterface $controllersContainer) - { - } + public function __construct( + private readonly iterable $controllers, + private readonly ContainerInterface $controllersContainer, + ) {} public function create(FactoryContext $context): QueryProviderInterface { - return new AggregateControllerQueryProvider($this->controllers, $context->getFieldsBuilder(), $this->controllersContainer); + return new AggregateControllerQueryProvider( + $this->controllers, + $context->getFieldsBuilder(), + $this->controllersContainer, + ); } } diff --git a/src/AggregateQueryProvider.php b/src/AggregateQueryProvider.php index fe4d51fd94..3e5110ec97 100644 --- a/src/AggregateQueryProvider.php +++ b/src/AggregateQueryProvider.php @@ -20,7 +20,9 @@ class AggregateQueryProvider implements QueryProviderInterface /** @param QueryProviderInterface[] $queryProviders */ public function __construct(iterable $queryProviders) { - $this->queryProviders = is_array($queryProviders) ? $queryProviders : iterator_to_array($queryProviders); + $this->queryProviders = is_array($queryProviders) + ? $queryProviders + : iterator_to_array($queryProviders); } /** @return QueryField[] */ @@ -48,4 +50,17 @@ public function getMutations(): array return array_merge(...$mutationsArray); } + + /** @return QueryField[] */ + public function getSubscriptions(): array + { + $subscriptionsArray = array_map(static function (QueryProviderInterface $queryProvider) { + return $queryProvider->getSubscriptions(); + }, $this->queryProviders); + if ($subscriptionsArray === []) { + return []; + } + + return array_merge(...$subscriptionsArray); + } } diff --git a/src/Annotations/Subscription.php b/src/Annotations/Subscription.php new file mode 100644 index 0000000000..1d5fce3b15 --- /dev/null +++ b/src/Annotations/Subscription.php @@ -0,0 +1,19 @@ +getFieldsByAnnotations($controller, Mutation::class, false); } + /** + * @return array + * + * @throws ReflectionException + */ + public function getSubscriptions(object $controller): array + { + return $this->getFieldsByAnnotations($controller, Subscription::class, false); + } + /** @return array QueryField indexed by name. */ public function getFields(object $controller, string|null $typeName = null): array { @@ -266,7 +277,7 @@ public function getParametersForDecorator(ReflectionMethod $refMethod): array /** * @param object|class-string $controller The controller instance, or the name of the source class name * @param class-string $annotationName - * @param bool $injectSource Whether to inject the source object or not as the first argument. True for @Field (unless @Type has no class attribute), false for @Query and @Mutation + * @param bool $injectSource Whether to inject the source object or not as the first argument. True for @Field (unless @Type has no class attribute), false for @Query, @Mutation, and @Subscription. * @param string|null $typeName Type name for which fields should be extracted for. * * @return array diff --git a/src/GlobControllerQueryProvider.php b/src/GlobControllerQueryProvider.php index 05afa6476a..208a7b5bfb 100644 --- a/src/GlobControllerQueryProvider.php +++ b/src/GlobControllerQueryProvider.php @@ -4,6 +4,10 @@ namespace TheCodingMachine\GraphQLite; +use function class_exists; +use function interface_exists; +use function is_array; +use function str_replace; use GraphQL\Type\Definition\FieldDefinition; use InvalidArgumentException; use Mouf\Composer\ClassNameMapper; @@ -16,15 +20,11 @@ use TheCodingMachine\ClassExplorer\Glob\GlobClassExplorer; use TheCodingMachine\GraphQLite\Annotations\Mutation; use TheCodingMachine\GraphQLite\Annotations\Query; - -use function class_exists; -use function interface_exists; -use function is_array; -use function str_replace; +use TheCodingMachine\GraphQLite\Annotations\Subscription; /** * Scans all the classes in a given namespace of the main project (not the vendor directory). - * Analyzes all classes and detects "Query" and "Mutation" annotations. + * Analyzes all classes and detects "Query", "Mutation", and "Subscription" annotations. * * Assumes that the container contains a class whose identifier is the same as the class name. */ @@ -53,14 +53,20 @@ public function __construct( ) { $this->classNameMapper = $classNameMapper ?? ClassNameMapper::createFromComposerFile(null, null, true); - $this->cacheContract = new Psr16Adapter($this->cache, str_replace(['\\', '{', '}', '(', ')', '/', '@', ':'], '_', $namespace), $cacheTtl ?? 0); + $this->cacheContract = new Psr16Adapter( + $this->cache, + str_replace(['\\', '{', '}', '(', ')', '/', '@', ':'], '_', $namespace), + $cacheTtl ?? 0, + ); } private function getAggregateControllerQueryProvider(): AggregateControllerQueryProvider { - if ($this->aggregateControllerQueryProvider === null) { - $this->aggregateControllerQueryProvider = new AggregateControllerQueryProvider($this->getInstancesList(), $this->fieldsBuilder, $this->container); - } + $this->aggregateControllerQueryProvider ??= new AggregateControllerQueryProvider( + $this->getInstancesList(), + $this->fieldsBuilder, + $this->container, + ); return $this->aggregateControllerQueryProvider; } @@ -100,7 +106,7 @@ private function buildInstancesList(): array if (! $refClass->isInstantiable()) { continue; } - if (! $this->hasQueriesOrMutations($refClass)) { + if (! $this->hasOperations($refClass)) { continue; } if (! $this->container->has($className)) { @@ -114,7 +120,7 @@ private function buildInstancesList(): array } /** @param ReflectionClass $reflectionClass */ - private function hasQueriesOrMutations(ReflectionClass $reflectionClass): bool + private function hasOperations(ReflectionClass $reflectionClass): bool { foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $refMethod) { $queryAnnotation = $this->annotationReader->getRequestAnnotation($refMethod, Query::class); @@ -125,6 +131,10 @@ private function hasQueriesOrMutations(ReflectionClass $reflectionClass): bool if ($mutationAnnotation !== null) { return true; } + $subscriptionAnnotation = $this->annotationReader->getRequestAnnotation($refMethod, Subscription::class); + if ($subscriptionAnnotation !== null) { + return true; + } } return false; } @@ -140,4 +150,10 @@ public function getMutations(): array { return $this->getAggregateControllerQueryProvider()->getMutations(); } + + /** @return array */ + public function getSubscriptions(): array + { + return $this->getAggregateControllerQueryProvider()->getSubscriptions(); + } } diff --git a/src/QueryProviderInterface.php b/src/QueryProviderInterface.php index f427ec1a67..abb3c783e3 100644 --- a/src/QueryProviderInterface.php +++ b/src/QueryProviderInterface.php @@ -14,4 +14,7 @@ public function getQueries(): array; /** @return QueryField[] */ public function getMutations(): array; + + /** @return QueryField[] */ + public function getSubscriptions(): array; } diff --git a/src/Schema.php b/src/Schema.php index 88f65e7c14..2e53d3c1c7 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -20,13 +20,18 @@ */ class Schema extends \GraphQL\Type\Schema { - public function __construct(QueryProviderInterface $queryProvider, RecursiveTypeMapperInterface $recursiveTypeMapper, TypeResolver $typeResolver, RootTypeMapperInterface $rootTypeMapper, SchemaConfig|null $config = null) - { + public function __construct( + QueryProviderInterface $queryProvider, + RecursiveTypeMapperInterface $recursiveTypeMapper, + TypeResolver $typeResolver, + RootTypeMapperInterface $rootTypeMapper, + SchemaConfig|null $config = null, + ) { if ($config === null) { $config = SchemaConfig::create(); } - $query = new ObjectType([ + $query = new ObjectType([ 'name' => 'Query', 'fields' => static function () use ($queryProvider) { $queries = $queryProvider->getQueries(); @@ -36,7 +41,7 @@ public function __construct(QueryProviderInterface $queryProvider, RecursiveType 'type' => Type::string(), 'description' => 'A placeholder query used by thecodingmachine/graphqlite when there are no declared queries.', 'resolve' => static function () { - return 'This is a placeholder query. Please create a query using the @Query annotation.'; + return 'This is a placeholder query. Please create a query using the "Query" attribute.'; }, ], ]; @@ -45,6 +50,7 @@ public function __construct(QueryProviderInterface $queryProvider, RecursiveType return $queries; }, ]); + $mutation = new ObjectType([ 'name' => 'Mutation', 'fields' => static function () use ($queryProvider) { @@ -55,7 +61,7 @@ public function __construct(QueryProviderInterface $queryProvider, RecursiveType 'type' => Type::string(), 'description' => 'A placeholder query used by thecodingmachine/graphqlite when there are no declared mutations.', 'resolve' => static function () { - return 'This is a placeholder mutation. Please create a mutation using the @Mutation annotation.'; + return 'This is a placeholder mutation. Please create a mutation using the "Mutation" attribute.'; }, ], ]; @@ -65,14 +71,35 @@ public function __construct(QueryProviderInterface $queryProvider, RecursiveType }, ]); + $subscription = new ObjectType([ + 'name' => 'Subscription', + 'fields' => static function () use ($queryProvider) { + $subscriptions = $queryProvider->getSubscriptions(); + if (empty($subscriptions)) { + return [ + 'dummySubscription' => [ + 'type' => Type::string(), + 'description' => 'A placeholder query used by thecodingmachine/graphqlite when there are no declared subscriptions.', + 'resolve' => static function () { + return 'This is a placeholder subscription. Please create a subscription using the "Subscription" attribute.'; + }, + ], + ]; + } + + return $subscriptions; + }, + ]); + $config->setQuery($query); $config->setMutation($mutation); + $config->setSubscription($subscription); $config->setTypes(static function () use ($recursiveTypeMapper) { return $recursiveTypeMapper->getOutputTypes(); }); - $config->setTypeLoader(static function (string $name) use ($query, $mutation, $rootTypeMapper) { + $config->setTypeLoader(static function (string $name) use ($query, $mutation, $subscription, $rootTypeMapper) { // We need to find a type FROM a GraphQL type name if ($name === 'Query') { return $query; @@ -81,7 +108,11 @@ public function __construct(QueryProviderInterface $queryProvider, RecursiveType return $mutation; } - $type = $rootTypeMapper->mapNameToType($name); + if ($name === 'Subscription') { + return $subscription; + } + + $type = $rootTypeMapper->mapNameToType($name); assert($type instanceof Type); return $type; }); diff --git a/tests/AggregateControllerQueryProviderTest.php b/tests/AggregateControllerQueryProviderTest.php index cda3e78aa8..edabec397b 100644 --- a/tests/AggregateControllerQueryProviderTest.php +++ b/tests/AggregateControllerQueryProviderTest.php @@ -2,14 +2,8 @@ namespace TheCodingMachine\GraphQLite; -use Doctrine\Common\Annotations\AnnotationReader; -use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; -use Psr\Container\NotFoundExceptionInterface; use TheCodingMachine\GraphQLite\Fixtures\TestController; -use TheCodingMachine\GraphQLite\Security\VoidAuthenticationService; -use TheCodingMachine\GraphQLite\Security\VoidAuthorizationService; class AggregateControllerQueryProviderTest extends AbstractQueryProviderTest { @@ -41,5 +35,8 @@ public function has($id):bool $mutations = $aggregateQueryProvider->getMutations(); $this->assertCount(2, $mutations); + + $subscriptions = $aggregateQueryProvider->getSubscriptions(); + $this->assertCount(2, $subscriptions); } } diff --git a/tests/AggregateQueryProviderTest.php b/tests/AggregateQueryProviderTest.php index bf63a7c215..cd140a0672 100644 --- a/tests/AggregateQueryProviderTest.php +++ b/tests/AggregateQueryProviderTest.php @@ -21,9 +21,24 @@ public function getMutations(): array $queryFieldRef = new ReflectionClass(QueryField::class); return [ $queryFieldRef->newInstanceWithoutConstructor() ]; } + + public function getSubscriptions(): array + { + $queryFieldRef = new ReflectionClass(QueryField::class); + return [ $queryFieldRef->newInstanceWithoutConstructor() ]; + } }; } + public function testGetQueries(): void + { + $aggregateQueryProvider = new AggregateQueryProvider([$this->getMockQueryProvider(), $this->getMockQueryProvider()]); + $this->assertCount(2, $aggregateQueryProvider->getQueries()); + + $aggregateQueryProvider = new AggregateQueryProvider([]); + $this->assertCount(0, $aggregateQueryProvider->getQueries()); + } + public function testGetMutations(): void { $aggregateQueryProvider = new AggregateQueryProvider([$this->getMockQueryProvider(), $this->getMockQueryProvider()]); @@ -33,12 +48,12 @@ public function testGetMutations(): void $this->assertCount(0, $aggregateQueryProvider->getMutations()); } - public function testGetQueries(): void + public function testGetSubscriptions(): void { $aggregateQueryProvider = new AggregateQueryProvider([$this->getMockQueryProvider(), $this->getMockQueryProvider()]); - $this->assertCount(2, $aggregateQueryProvider->getQueries()); + $this->assertCount(2, $aggregateQueryProvider->getSubscriptions()); $aggregateQueryProvider = new AggregateQueryProvider([]); - $this->assertCount(0, $aggregateQueryProvider->getQueries()); + $this->assertCount(0, $aggregateQueryProvider->getSubscriptions()); } } diff --git a/tests/FieldsBuilderTest.php b/tests/FieldsBuilderTest.php index ae039b1c18..0b3bcf2c00 100644 --- a/tests/FieldsBuilderTest.php +++ b/tests/FieldsBuilderTest.php @@ -14,7 +14,6 @@ use GraphQL\Type\Definition\StringType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\UnionType; -use PhpParser\Builder\Property; use ReflectionMethod; use stdClass; use Symfony\Component\Cache\Adapter\ArrayAdapter; @@ -142,11 +141,16 @@ public function testMutations(): void $this->assertCount(2, $mutations); - $mutation = $mutations['mutation']; - $this->assertSame('mutation', $mutation->name); + $testReturnMutation = $mutations['testReturn']; + $this->assertSame('testReturn', $testReturnMutation->name); - $resolve = $mutation->resolveFn; - $result = $resolve(new stdClass(), ['testObject' => ['test' => 42]], null, $this->createMock(ResolveInfo::class)); + $resolve = $testReturnMutation->resolveFn; + $result = $resolve( + new stdClass(), + ['testObject' => ['test' => 42]], + null, + $this->createMock(ResolveInfo::class), + ); $this->assertInstanceOf(TestObject::class, $result); $this->assertEquals('42', $result->getTest()); @@ -155,6 +159,23 @@ public function testMutations(): void $this->assertInstanceOf(VoidType::class, $testVoidMutation->getType()); } + public function testSubscriptions(): void + { + $controller = new TestController(); + + $queryProvider = $this->buildFieldsBuilder(); + + $subscriptions = $queryProvider->getSubscriptions($controller); + + $this->assertCount(2, $subscriptions); + + $testSubscribeSubscription = $subscriptions['testSubscribe']; + $this->assertSame('testSubscribe', $testSubscribeSubscription->name); + + $testSubscribeWithInputSubscription = $subscriptions['testSubscribeWithInput']; + $this->assertInstanceOf(IDType::class, $testSubscribeWithInputSubscription->getType()); + } + public function testErrors(): void { $controller = new class { diff --git a/tests/Fixtures/Integration/Controllers/ContactController.php b/tests/Fixtures/Integration/Controllers/ContactController.php index 58a26d88b2..9fe871b7d6 100644 --- a/tests/Fixtures/Integration/Controllers/ContactController.php +++ b/tests/Fixtures/Integration/Controllers/ContactController.php @@ -8,16 +8,16 @@ use Porpaginas\Result; use TheCodingMachine\GraphQLite\Annotations\Mutation; use TheCodingMachine\GraphQLite\Annotations\Query; -use TheCodingMachine\GraphQLite\Annotations\Security; +use TheCodingMachine\GraphQLite\Annotations\Subscription; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Contact; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\User; class ContactController { /** - * @Query() * @return Contact[] */ + #[Query] public function getContacts(): array { return [ @@ -26,21 +26,13 @@ public function getContacts(): array ]; } - /** - * @Mutation() - * @param Contact $contact - * @return Contact - */ + #[Mutation] public function saveContact(Contact $contact): Contact { return $contact; } - /** - * @Mutation() - * @param \DateTimeInterface $birthDate - * @return Contact - */ + #[Mutation] public function saveBirthDate(\DateTimeInterface $birthDate): Contact { $contact = new Contact('Bill'); $contact->setBirthDate($birthDate); @@ -49,9 +41,9 @@ public function saveBirthDate(\DateTimeInterface $birthDate): Contact { } /** - * @Query() * @return Contact[] */ + #[Query] public function getContactsIterator(): ArrayResult { return new ArrayResult([ @@ -61,9 +53,9 @@ public function getContactsIterator(): ArrayResult } /** - * @Query() * @return string[]|ArrayResult */ + #[Query] public function getContactsNamesIterator(): ArrayResult { return new ArrayResult([ @@ -72,9 +64,7 @@ public function getContactsNamesIterator(): ArrayResult ]); } - /** - * @Query(outputType="ContactOther") - */ + #[Query(outputType: 'ContactOther')] public function getOtherContact(): Contact { return new Contact('Joe'); @@ -83,11 +73,23 @@ public function getOtherContact(): Contact /** * Test that we can have nullable results from Porpaginas. * - * @Query() * @return Result|Contact[]|null */ + #[Query] public function getNullableResult(): ?Result { return null; } + + #[Subscription] + public function contactAdded(): Contact + { + return new Contact('Joe'); + } + + #[Subscription(outputType: 'Contact')] + public function contactAddedWithFilter(Contact $contact): void + { + // Save the subscription somewhere + } } diff --git a/tests/Fixtures/Integration/Types/ContactFactory.php b/tests/Fixtures/Integration/Types/ContactFactory.php index 5f81fc602a..782394bdd4 100644 --- a/tests/Fixtures/Integration/Types/ContactFactory.php +++ b/tests/Fixtures/Integration/Types/ContactFactory.php @@ -7,7 +7,6 @@ use DateTimeInterface; use Psr\Http\Message\UploadedFileInterface; use TheCodingMachine\GraphQLite\Annotations\Factory; -use TheCodingMachine\GraphQLite\Annotations\Parameter; use TheCodingMachine\GraphQLite\Annotations\UseInputType; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Contact; @@ -16,12 +15,15 @@ class ContactFactory /** * @Factory() * @UseInputType(for="$relations", inputType="[ContactRef!]!") - * @param string $name - * @param Contact|null $manager * @param Contact[] $relations - * @return Contact */ - public function createContact(string $name, DateTimeInterface $birthDate, ?UploadedFileInterface $photo = null, ?Contact $manager = null, array $relations= []): Contact + public function createContact( + string $name, + DateTimeInterface $birthDate, + ?UploadedFileInterface $photo = null, + ?Contact $manager = null, + array $relations= [], + ): Contact { $contact = new Contact($name); if ($photo) { diff --git a/tests/Fixtures/TestController.php b/tests/Fixtures/TestController.php index ab69dbc14b..c827451625 100644 --- a/tests/Fixtures/TestController.php +++ b/tests/Fixtures/TestController.php @@ -9,35 +9,27 @@ use TheCodingMachine\GraphQLite\Annotations\Mutation; use TheCodingMachine\GraphQLite\Annotations\Query; use TheCodingMachine\GraphQLite\Annotations\Right; +use TheCodingMachine\GraphQLite\Annotations\Subscription; use TheCodingMachine\GraphQLite\Types\ID; class TestController { /** - * @Mutation - * @param TestObject $testObject - * @return TestObject - */ - public function mutation(TestObject $testObject): TestObject - { - return $testObject; - } - - /** - * @Query - * @param int $int * @param TestObject[] $list - * @param bool|null $boolean - * @param float|null $float - * @param \DateTimeImmutable|null $dateTimeImmutable - * @param \DateTimeInterface|null $dateTime - * @param string $withDefault - * @param null|string $string - * @param ID|null $id - * @param TestEnum $enum - * @return TestObject */ - public function test(int $int, array $list, ?bool $boolean, ?float $float, ?\DateTimeImmutable $dateTimeImmutable, ?\DateTimeInterface $dateTime, string $withDefault = 'default', ?string $string = null, ID $id = null, TestEnum $enum = null): TestObject + #[Query] + public function test( + int $int, + array $list, + ?bool $boolean, + ?float $float, + ?\DateTimeImmutable $dateTimeImmutable, + ?\DateTimeInterface $dateTime, + string $withDefault = 'default', + ?string $string = null, + ID $id = null, + TestEnum $enum = null, + ): TestObject { $str = ''; foreach ($list as $test) { @@ -49,99 +41,101 @@ public function test(int $int, array $list, ?bool $boolean, ?float $float, ?\Dat return new TestObject($string.$int.$str.($boolean?'true':'false').$float.$dateTimeImmutable->format('YmdHis').$dateTime->format('YmdHis').$withDefault.($id !== null ? $id->val() : '').$enum->getValue()); } - /** - * @Query - * @Logged - * @HideIfUnauthorized() - */ + #[Query] + #[HideIfUnauthorized] + #[Logged] public function testLogged(): TestObject { return new TestObject('foo'); } - /** - * @Query - * @Right(name="CAN_FOO") - * @HideIfUnauthorized() - */ + #[Query] + #[Right(name: "CAN_FOO")] + #[HideIfUnauthorized] public function testRight(): TestObject { return new TestObject('foo'); } - /** - * @Query(outputType="ID") - */ + #[Query(outputType: 'ID')] public function testFixReturnType(): TestObject { return new TestObject('foo'); } - /** - * @Query(name="nameFromAnnotation") - */ + #[Query(name: 'nameFromAnnotation')] public function testNameFromAnnotation(): TestObject { return new TestObject('foo'); } /** - * @Query(name="arrayObject") * @return ArrayObject|TestObject[] */ + #[Query(name: 'arrayObject')] public function testArrayObject(): ArrayObject { return new ArrayObject([]); } /** - * @Query(name="arrayObjectGeneric") * @return ArrayObject */ + #[Query(name: 'arrayObjectGeneric')] public function testArrayObjectGeneric(): ArrayObject { return new ArrayObject([]); } /** - * @Query(name="iterable") * @return iterable|TestObject[] */ + #[Query(name: 'iterable')] public function testIterable(): iterable { return array(); } /** - * @Query(name="iterableGeneric") * @return iterable */ + #[Query(name: 'iterableGeneric')] public function testIterableGeneric(): iterable { return array(); } /** - * @Query(name="union") * @return TestObject|TestObject2 */ + #[Query(name: 'union')] public function testUnion() { return new TestObject2('foo'); } - /** - * @Query(outputType="[ID!]!") - */ + #[Query(outputType: '[ID!]!')] public function testFixComplexReturnType(): array { return ['42']; } - /** - * @Mutation - */ + #[Mutation] public function testVoid(): void { } + + #[Mutation] + public function testReturn(TestObject $testObject): TestObject + { + return $testObject; + } + + #[Subscription(outputType: 'ID')] + public function testSubscribe(): void + {} + + #[Subscription(outputType: 'ID')] + public function testSubscribeWithInput(TestObject $testObject): void + {} } diff --git a/tests/GlobControllerQueryProviderTest.php b/tests/GlobControllerQueryProviderTest.php index 48b5adcbb4..8f9674fc37 100644 --- a/tests/GlobControllerQueryProviderTest.php +++ b/tests/GlobControllerQueryProviderTest.php @@ -5,7 +5,6 @@ use Psr\Container\ContainerInterface; use Symfony\Component\Cache\Adapter\NullAdapter; use Symfony\Component\Cache\Psr16Cache; -use Symfony\Component\Cache\Simple\NullCache; use TheCodingMachine\GraphQLite\Fixtures\TestController; class GlobControllerQueryProviderTest extends AbstractQueryProviderTest @@ -36,7 +35,16 @@ public function has($id):bool } }; - $globControllerQueryProvider = new GlobControllerQueryProvider('TheCodingMachine\\GraphQLite\\Fixtures', $this->getFieldsBuilder(), $container, $this->getAnnotationReader(), new Psr16Cache(new NullAdapter()), null, false, false); + $globControllerQueryProvider = new GlobControllerQueryProvider( + 'TheCodingMachine\\GraphQLite\\Fixtures', + $this->getFieldsBuilder(), + $container, + $this->getAnnotationReader(), + new Psr16Cache(new NullAdapter), + null, + false, + false, + ); $queries = $globControllerQueryProvider->getQueries(); $this->assertCount(9, $queries); @@ -44,5 +52,7 @@ public function has($id):bool $mutations = $globControllerQueryProvider->getMutations(); $this->assertCount(2, $mutations); + $subscriptions = $globControllerQueryProvider->getSubscriptions(); + $this->assertCount(2, $subscriptions); } } diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index a00daeeeea..f58e80ffb2 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -2270,4 +2270,63 @@ public function testEndToEndVoidResult(): void 'deleteButton' => null, ], $this->getSuccessResult($result)); } + + public function testEndToEndSubscription(): void + { + $schema = $this->mainContainer->get(Schema::class); + assert($schema instanceof Schema); + $queryString = ' + subscription { + contactAdded { + nickName + age + } + } + '; + + $result = GraphQL::executeQuery($schema, $queryString); + + $this->assertSame([ + 'contactAdded' => [ + 'nickName' => 'foo', + 'age' => 42, + ], + ], $this->getSuccessResult($result)); + } + + public function testEndToEndSubscriptionWithInput(): void + { + $schema = $this->mainContainer->get(Schema::class); + assert($schema instanceof Schema); + $queryString = ' + subscription { + contactAddedWithFilter( + contact: { + name: "foo", + birthDate: "1942-12-24T00:00:00+00:00", + relations: [ + { + name: "bar" + } + ] + } + ) { + name, + birthDate, + relations { + name + } + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString, + ); + + $this->assertSame([ + 'contactAddedWithFilter' => null, + ], $this->getSuccessResult($result)); + } } diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index d060beb932..3863b6d412 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -2,8 +2,6 @@ namespace TheCodingMachine\GraphQLite; -use PHPUnit\Framework\TestCase; - class SchemaTest extends AbstractQueryProviderTest { @@ -19,6 +17,11 @@ public function getMutations(): array { return []; } + + public function getSubscriptions(): array + { + return []; + } }; $schema = new Schema($queryProvider, $this->getTypeMapper(), $this->getTypeResolver(), $this->getRootTypeMapper()); @@ -26,11 +29,16 @@ public function getMutations(): array $fields = $schema->getQueryType()->getFields(); $this->assertArrayHasKey('dummyQuery', $fields); $resolve = $fields['dummyQuery']->resolveFn; - $this->assertSame('This is a placeholder query. Please create a query using the @Query annotation.', $resolve()); + $this->assertSame('This is a placeholder query. Please create a query using the "Query" attribute.', $resolve()); $fields = $schema->getMutationType()->getFields(); $this->assertArrayHasKey('dummyMutation', $fields); $resolve = $fields['dummyMutation']->resolveFn; - $this->assertSame('This is a placeholder mutation. Please create a mutation using the @Mutation annotation.', $resolve()); + $this->assertSame('This is a placeholder mutation. Please create a mutation using the "Mutation" attribute.', $resolve()); + + $fields = $schema->getSubscriptionType()->getFields(); + $this->assertArrayHasKey('dummySubscription', $fields); + $resolve = $fields['dummySubscription']->resolveFn; + $this->assertSame('This is a placeholder subscription. Please create a subscription using the "Subscription" attribute.', $resolve()); } } diff --git a/website/docs/README.mdx b/website/docs/README.mdx index 5ac2ccb123..0293578109 100644 --- a/website/docs/README.mdx +++ b/website/docs/README.mdx @@ -19,7 +19,8 @@ A PHP library that allows you to write your GraphQL queries in simple-to-write c * Create a complete GraphQL API by simply annotating your PHP classes * Framework agnostic, but Symfony, Laravel and PSR-15 bindings available! -* Comes with batteries included: queries, mutations, mapping of arrays / iterators, file uploads, security, validation, extendable types and more! +* Comes with batteries included: queries, mutations, subscriptions, mapping of arrays / iterators, +file uploads, security, validation, extendable types and more! ## Basic example diff --git a/website/docs/annotations-reference.md b/website/docs/annotations-reference.md index 0a1c872661..e838cbf987 100644 --- a/website/docs/annotations-reference.md +++ b/website/docs/annotations-reference.md @@ -29,6 +29,17 @@ Attribute | Compulsory | Type | Definition name | *no* | string | The name of the mutation. If skipped, the name of the method is used instead. [outputType](custom-types.mdx) | *no* | string | Forces the GraphQL output type of a query. +## @Subscription + +The `@Subscription` annotation is used to declare a GraphQL subscription. + +**Applies on**: controller methods. + +Attribute | Compulsory | Type | Definition +---------------|------------|------|-------- +name | *no* | string | The name of the subscription. If skipped, the name of the method is used instead. +[outputType](custom-types.mdx) | *no* | string | Defines the GraphQL output type that will be sent for the subscription. + ## @Type The `@Type` annotation is used to declare a GraphQL object type. This is used with standard output @@ -67,7 +78,7 @@ Attribute | Compulsory | Type | Definition name | *no* | string | The name of the GraphQL input type generated. If not passed, the name of the class with suffix "Input" is used. If the class ends with "Input", the "Input" suffix is not added. description | *no* | string | Description of the input type in the documentation. If not passed, PHP doc comment is used. default | *no* | bool | Name of the input type represented in your GraphQL schema. Defaults to `true` *only if* the name is not specified. If `name` is specified, this will default to `false`, so must also be included for `true` when `name` is used. -update | *no* | bool | Determines if the the input represents a partial update. When set to `true` all input fields will become optional and won't have default values thus won't be set on resolve if they are not specified in the query/mutation. This primarily applies to nullable fields. +update | *no* | bool | Determines if the the input represents a partial update. When set to `true` all input fields will become optional and won't have default values thus won't be set on resolve if they are not specified in the query/mutation/subscription. This primarily applies to nullable fields. ## @Field @@ -151,7 +162,7 @@ name | *yes* | string | The name of the right. ## @FailWith The `@FailWith` annotation is used to declare a default value to return in the user is not authorized to see a specific -query / mutation / field (according to the `@Logged` and `@Right` annotations). +query/mutation/subscription/field (according to the `@Logged` and `@Right` annotations). **Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field` and one of `@Logged` or `@Right` annotations. @@ -161,11 +172,11 @@ value | *yes* | mixed | The value to return if the user is not au ## @HideIfUnauthorized -
This annotation only works when a Schema is used to handle exactly one use request. -If you serve your GraphQL API from long-running standalone servers (like Laravel Octane, Swoole, RoadRunner etc) and +
This annotation only works when a Schema is used to handle exactly one use request. +If you serve your GraphQL API from long-running standalone servers (like Laravel Octane, Swoole, RoadRunner etc) and share the same Schema instance between multiple requests, please avoid using @HideIfUnauthorized.
-The `@HideIfUnauthorized` annotation is used to completely hide the query / mutation / field if the user is not authorized +The `@HideIfUnauthorized` annotation is used to completely hide the query/mutation/subscription/field if the user is not authorized to access it (according to the `@Logged` and `@Right` annotations). **Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field` and one of `@Logged` or `@Right` annotations. @@ -175,7 +186,7 @@ to access it (according to the `@Logged` and `@Right` annotations). ## @InjectUser Use the `@InjectUser` annotation to inject an instance of the current user logged in into a parameter of your -query / mutation / field. +query/mutation/subscription/field. See [the authentication and authorization page](authentication-authorization.mdx) for more details. @@ -255,11 +266,11 @@ Attribute | Compulsory | Type | Definition Sets complexity and multipliers on fields for [automatic query complexity](operation-complexity.md#static-request-analysis). -Attribute | Compulsory | Type | Definition +Attribute | Compulsory | Type | Definition --------------------|------------|-----------------|----------------------------------------------------------------- -*complexity* | *no* | int | Complexity for that field -*multipliers* | *no* | array\ | Names of fields by value of which complexity will be multiplied -*defaultMultiplier* | *no* | int | Default multiplier value if all multipliers are missing/null +*complexity* | *no* | int | Complexity for that field +*multipliers* | *no* | array\ | Names of fields by value of which complexity will be multiplied +*defaultMultiplier* | *no* | int | Default multiplier value if all multipliers are missing/null ## @Validate diff --git a/website/docs/authentication-authorization.mdx b/website/docs/authentication-authorization.mdx index 0e6a483a59..f716d02d30 100644 --- a/website/docs/authentication-authorization.mdx +++ b/website/docs/authentication-authorization.mdx @@ -7,19 +7,20 @@ sidebar_label: Authentication and authorization import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -You might not want to expose your GraphQL API to anyone. Or you might want to keep some queries/mutations or fields -reserved to some users. +You might not want to expose your GraphQL API to anyone. Or you might want to keep some +queries/mutations/subscriptions or fields reserved to some users. -GraphQLite offers some control over what a user can do with your API. You can restrict access to resources: +GraphQLite offers some control over what a user can do with your API. You can restrict access to +resources: - based on authentication using the [`@Logged` annotation](#logged-and-right-annotations) (restrict access to logged users) - based on authorization using the [`@Right` annotation](#logged-and-right-annotations) (restrict access to logged users with certain rights). - based on fine-grained authorization using the [`@Security` annotation](fine-grained-security.mdx) (restrict access for some given resources to some users).
-GraphQLite does not have its own security mechanism. -Unless you're using our Symfony Bundle or our Laravel package, it is up to you to connect this feature to your framework's security mechanism.
-See Connecting GraphQLite to your framework's security module. + GraphQLite does not have its own security mechanism. + Unless you're using our Symfony Bundle or our Laravel package, it is up to you to connect this feature to your framework's security mechanism.
+ See Connecting GraphQLite to your framework's security module.
## `@Logged` and `@Right` annotations @@ -93,11 +94,15 @@ has the `CAN_VIEW_USER_LIST` right. * `@Mutation` annotations * `@Field` annotations -
By default, if a user tries to access an unauthorized query/mutation/field, an error is raised and the query fails.
+
+ By default, if a user tries to access an unauthorized query/mutation/subscription/field, an error is + raised and the query fails. +
## Not throwing errors -If you do not want an error to be thrown when a user attempts to query a field/query/mutation he has no access to, you can use the `@FailWith` annotation. +If you do not want an error to be thrown when a user attempts to query a field/query/mutation/subscription +they have no access to, you can use the `@FailWith` annotation. The `@FailWith` annotation contains the value that will be returned for users with insufficient rights. @@ -226,9 +231,9 @@ The object injected as the current user depends on your framework. It is in fact ["authentication service" configured in GraphQLite](implementing-security.md). If user is not authenticated and parameter's type is not nullable, an authorization exception is thrown, similar to `@Logged` annotation. -## Hiding fields / queries / mutations +## Hiding fields / queries / mutations / subscriptions -By default, a user analysing the GraphQL schema can see all queries/mutations/types available. +By default, a user analysing the GraphQL schema can see all queries/mutations/subscriptions/types available. Some will be available to him and some won't. If you want to add an extra level of security (or if you want your schema to be kept secret to unauthorized users), diff --git a/website/docs/custom-types.mdx b/website/docs/custom-types.mdx index 3943b22519..800ab2846c 100644 --- a/website/docs/custom-types.mdx +++ b/website/docs/custom-types.mdx @@ -98,6 +98,7 @@ You can use the `outputType` attribute in the following annotations: * `@Query` * `@Mutation` +* `@Subscription` * `@Field` * `@SourceField` * `@MagicField` diff --git a/website/docs/subscriptions.mdx b/website/docs/subscriptions.mdx new file mode 100644 index 0000000000..bcfd118aac --- /dev/null +++ b/website/docs/subscriptions.mdx @@ -0,0 +1,53 @@ +--- +id: subscriptions +title: Subscriptions +sidebar_label: Subscriptions +--- + +In GraphQLite, subscriptions are created [like queries](queries.mdx) or [mutations](mutations.mdx). + +To create a subscription, you must annotate a method in a controller with the `#[Subscription]` attribute. + +For instance: + +```php +namespace App\Controller; + +use TheCodingMachine\GraphQLite\Annotations\Mutation; + +class ProductController +{ + #[Subscription(outputType: 'Product')] + public function productAdded(?ID $categoryId = null): void + { + // Some code that sets up any connections, stores the subscription details, etc. + } +} +``` + +As you will notice in the above example, we're returning `void`. In general, this is probably the +correct return type. + +You could, however, type the `Product` as the return type of the method, instead +of using the `outputType` argument on the `#[Subscription]` attribute. This means you +would have to return an instance of `Product` from the method though. One exception here, is if +you intend to use PHP for your long-running streaming process, you could block the process inside +the controller and basically never return anything from the method, just terminating the +connection/stream when it breaks, or when the client disconnects. + +Most implementations will want to offload the actual real-time streaming connection to a better suited +technology, like SSE (server-sent events), WebSockets, etc. GraphQLite does not make any assumptions +here. Therefore, it's most practical to return `void` from the controller method. Since GraphQL +is a strictly typed spec, we cannot return anything other than the defined `outputType` from the request. +That would be a violation of the GraphQL specification. Returning `void`, which is translated to `null` +in the GraphQL response body, allows for us to complete the request and terminate the PHP process. + +We recommend using response headers to pass back any necessary information realted to the subscription. +This might be a subscription ID, a streaming server URL to connect to, or whatever you need to pass +back to the client. + +
+ In the future, it may make sense to implement streaming servers directly into GraphQLite, especially + as PHP progresses with async and parallel processing. At this time, we might consider returning a + `Generator` (or `Fiber`) from the controller method. +
diff --git a/website/docs/validation.mdx b/website/docs/validation.mdx index ee1a0e93a9..dfb2c925dd 100644 --- a/website/docs/validation.mdx +++ b/website/docs/validation.mdx @@ -199,7 +199,7 @@ If a validation fails, GraphQLite will return the failed validations in the "err ``` -### Using the validator directly on a query / mutation / factory ... +### Using the validator directly on a query / mutation / subscription / factory ... If the data entered by the user is mapped to an object, please use the "validator" instance directly as explained in the last chapter. It is a best practice to put your validation layer as close as possible to your domain model. diff --git a/website/sidebars.json b/website/sidebars.json index 68ec666d7f..571f243dba 100755 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -13,6 +13,7 @@ "Usage": [ "queries", "mutations", + "subscriptions", "type-mapping", "autowiring", "extend-type",