/Wwwision.GraphQL

Easily create GraphQL APIs with Neos and Flow.

Primary LanguagePHPMIT LicenseMIT

Wwwision.GraphQL

Easily create GraphQL APIs with https://www.neos.io/ and https://flow.neos.io/.

Background

This package is a small collection of tools that'll make it easier to provide GraphQL endpoints with Neos and Flow. It is a wrapper for the PHP port of webonyx that comes with automatic Schema generation from PHP code (using wwwision/types) and an easy-to-configure PSR-15 compatible HTTP middleware.

Usage

Install via composer:

composer require wwwision/graphql

Simple tutorial

Create a class containing at least one public method with a Query attribute (see wwwision/types-graphql for more details):

// YourApi.php
<?php
namespace Your\Package;

use Neos\Flow\Annotations as Flow;
use Wwwision\TypesGraphQL\Attributes\Query;

#[Flow\Scope('singleton')]
final class YourApi
{
    #[Query]
    public function ping(string $name): string {
        return strtoupper($name);
    }
}

Now define a virtual object for the HTTP middleware in some Objects.yaml configuration:

// Objects.yaml
'Your.Package:GraphQLMiddleware':
  className: 'Wwwision\GraphQL\GraphQLMiddleware'
  scope: singleton
  factoryObjectName: Wwwision\GraphQL\GraphQLMiddlewareFactory
  arguments:
    1:
      # GraphQL URL
      value: '/graphql'
    2:
      # PHP Class with the Query/Mutation attributed methods
      value: 'Your\Package\YourApi'

And, lastly, register that custom middleware in Settings.yaml:

// Settings.yaml
Neos:
  Flow:
    http:
      middlewares:
        'Your.Package:GraphQL':
          position: 'before routing'
          middleware: 'Your.Package:GraphQLMiddleware'

And with that, a working GraphQL API is accessible underneath /graphql.

Complex types

By default, all types with the same namespace as the specified API class will be resolved automatically, so you could do:

// YourApi.php
// ...
#[Query]
public function ping(Name $name): Name {
    return strtoupper($name);
}

as long as there is a suitable Name object in the same namespace (Your\Package). To support types from different namespaces, those can be specified as third argument of the GraphQLMiddlewareFactory:

// Objects.yaml
'Your.Package:GraphQLMiddleware':
  # ...
  arguments:
    # ...
    # Look for classes in the following namespaces when resolving types:
    3:
      value:
        - 'Your\Package\Types'
        - 'SomeOther\Package\Commands'

Authentication

Commonly the GraphQL middleware is executed before the routing middleware. So the Security\Context is not yet initialized. This package allows you to "simulate" an MVC request though in order to initialize security. This is done with the fourth argument of the GraphQLMiddlewareFactory:

// Objects.yaml
'Your.Package:GraphQLMiddleware':
  # ...
  arguments:
    # ...
    # Simulate a request to the Neos NodeController in order to initialize the security context and trigger the default Neos backend authentication provider
    4:
      value: 'Neos\Neos\Controller\Frontend\NodeController'

Important

There must not be any gaps in the argument definitions due to the way Flow parses this configuration To only specify the simulated controller, you can pass an empty value: [] array for the 3rd argument

Custom Resolvers

Starting with version 5.2 custom functions can be registered that extend the behavior of types dynamically:

// Objects.yaml
'Your.Package:GraphQLMiddleware':
  # ...
  arguments:
    # ...
    # custom resolvers
    5:
      value:
        'User':
          'fullName':
            description: 'Custom resolver for User.fullName'
            resolverClassName: Some\Package\SomeCustomResolvers
            resolverMethodName: 'getFullName'
          'isAllowed':
            resolverClassName: Some\Package\SomeCustomResolvers

Note

The resolverMethodName can be omitted if it is equal to the custom field name

Important

There must not be any gaps in the argument definitions due to the way Flow parses this configuration To only specify custom resolves, you can pass an empty value: [] array for the 3rd argument and value: null for the fourth

All custom resolvers have to be public functions with the extended type as first argument (and optionally additional arguments) and a specified return type

For the example above, the corresponding resolver class could look like this:

final class SomeCustomResolvers {

    public function __construct(private readonly SomeDependency $incjection) {}
    
    public function getFullName(User $user): string {
        return $user->givenName . ' ' . $user->familyName;
    }
    
    public function isAllowed(User $user, Privilege $privilege): bool {
        return $this->incjection->isUserPrivilegeAllowed($user->id, $privilege);
    }
}

More

See wwwision/types and wwwision/types-graphql for more examples and how to use more complex types.

FAQ

Q: How can I implement lazy-loading?

The major rewrite with version 5.0 led to all fields of a type to be loaded and encoded by default. In my experience, that leads to a better performance due to the reduced i/o and (de)serialization that comes with lazy-loading every field. However, if you work with highly complex or nested types, the overhead of pre-loading all fields can be a problem. In that case you can simplify the structures by adding more specific rootlevel queries. Alternatively you can use Custom Resolvers.

I would like to make it easier to provide lazily loaded fields (see bwaidelich/types-graphql#6), but currently I don't have the personal need for this feature.

Q: I have issues with Neos Flow proxy classes

The wwwision/types package, that this library is built on top of, relies on constructors to contain all involved fields (see https://github.com/bwaidelich/types/blob/main/README.md#all-state-fields-in-the-constructor). Flow Proxy classes (and due to a bug that is practically every class of a Flow package) override the constructor with one that has no parameters.

As a work-around you can add a #Flow\Proxy(false) attribute to the affected classes.

I'm thinking about adding an extension point to the parser to allow proxy classes out of the box (see bwaidelich/types#6), but currently I don't have the personal need for this feature.

Q: What about a GraphQL Schema generator from doctrine entities?

Exposing entities directly to an API can be problematic because it increases coupling and can impede maintainance. However, sometimes and especially with smaller API it is the most pragmatic solution to use the same entity classes and value objects in the core as well as "on the edge". Personally I would avoid exposing (or even using) doctrine entities because they tend to lead to Anemic Domain Models and couple the core domain to the infrastructure. Instead, I prefer to use the PHP type system as much as possible (and the wwwision/types package for enforcing validation) and adapters to map those from/to database records.

With all that, it should still be possible to derive the GraphQL schema from doctrine entities as long as the constructor contains all fields and the class is not proxied by Flow (see above):

 use Doctrine\ORM\Mapping as ORM;
 use Neos\Flow\Annotations as Flow;

 /**
  * @ORM\Entity
  * @Flow\Proxy(false)
  */
class TestEntity
{

    /**
     * @var string
     * @ORM\Id
     */
    public readonly string $id;

    /**
     * @var string
     * @ORM\Column(length=80)
     */
    public readonly string $title;

    public function __construct(string $id, string $title) {
        $this->id = $id;
        $this->title = $title;
    }
}

With bwaidelich/types#6 integration could be improved probably.

Q: How to deal with breaking changes in version 5.0?

Version 5.0 was a major rewrite of this package with a new foundation and philosophy. If that approach does not work for you at all, you can still use older versions of this package, I plan to support version 4.x for a while!

Q: What about feature x?

I mainly created this package for my own projects and those of my clients, but of course it makes me happy to see it being used elsewhere. So feel free to provide feature suggestions or even implementations but please don't expect me to comply as I have to maintain this package in my free time.

If you need a specific feature implemented or bug fixed, you can of course also hire me to do so!

Contribution

Contributions in the form of issues or pull requests are highly appreciated

License

See LICENSE