Flow Package to add graphql APIs to Neos and Flow that also supports advanced features like schema stitching, validation rules, schema directives and more. This package doesn't provide a GraphQL client to test your API. We suggest to use the GraphlQL Playground
Simply install the package via composer:
composer require "t3n/graphql"
Version 2.x supports neos/flow >= 6.0.0
In order to use your GraphQL API endpoint some configuration is necessary.
Let's assume that the API should be accessible under the URL http://localhost/api/my-endpoint.
To make this possible, you first have to add the route to your Routes.yaml
:
- name: 'GraphQL API'
uriPattern: 'api/<GraphQLSubroutes>'
subRoutes:
'GraphQLSubroutes':
package: 't3n.GraphQL'
variables:
'endpoint': 'my-endpoint'
Don't forget to load your routes at all:
Neos:
Flow:
mvc:
routes:
'Your.Package':
position: 'start'
Now the route is activated and available.
The next step is to define a schema that can be queried.
Create a schema.graphql
file:
/Your.Package/Resources/Private/GraphQL/schema.root.graphql
type Query {
ping: String!
}
type Mutation {
pong: String!
}
schema {
query: Query
mutation: Mutation
}
Under the hood we use t3n/graphql-tools. This package is a php port from Apollos graphql-tools. This enables you to use some advanced features like schema stitching. So it's possible to configure multiple schemas per endpoint. All schemas will be merged internally together to a single schema.
Add a schema to your endpoint like this:
t3n:
GraphQL:
endpoints:
'my-endpoint': # use your endpoint variable here
schemas:
root: # use any key you like here
typeDefs: 'resource://Your.Package/Private/GraphQL/schema.root.graphql'
To add another schema just add a new entry below the schemas
index for your endpoint.
You can also use the extend feature:
/Your.Package/Resources/Private/GraphQL/schema.yeah.graphql
extend type Query {
yippie: String!
}
t3n:
GraphQL:
endpoints:
'my-endpoint': #
schemas:
yeah:
typeDefs: 'resource://Your.Package/Private/GraphQL/schema.yeah.graphql'
Now you need to add some Resolver. You can add a Resolver for each of your types. Given this schema:
type Query {
product(id: ID!): Product
products: [Product]
}
type Product {
id: ID!
name: String!
price: Float!
}
You might want to configure Resolver for both types:
t3n:
GraphQL:
endpoints:
'my-endpoint':
schemas:
mySchema:
resolvers:
Query: 'Your\Package\GraphQL\Resolver\QueryResolver'
Product: 'Your\Package\GraphQL\Resolver\ProductResolver'
Each resolver must implement t3n\GraphQL\ResolverInterface
!
You can also add resolvers dynamically so you don't have to configure each resolver separately:
t3n:
GraphQL:
endpoints:
'my-endpoint':
schemas:
mySchema:
resolverPathPattern: 'Your\Package\GraphQL\Resolver\Type\{Type}Resolver'
resolvers:
Query: 'Your\Package\GraphQL\Resolver\QueryResolver'
With this configuration the class Your\Package\GraphQL\Resolver\Type\ProductResolver
would be responsible
for queries on a Product type. The {Type} will evaluate to your type name.
As a third option you can create resolvers programmatically. Therefore you can register a class that implements
the t3n\GraphQL\ResolverGeneratorInterface
. This might be useful to auto generate a resolver mapping:
t3n:
GraphQL:
endpoints:
'my-endpoint':
schemas:
mySchema:
resolverGenerator: 'Your\Package\GraphQL\Resolver\ResolverGenerator'
The Generator must return an array with this structure: ['typeName' => \Resolver\Class\Name]
☝️ Note: Your Resolver can override each other. All resolver configurations are applied in this order:
- ResolverGenerator
- dynamic "{Type}Resolver"
- specific Resolver
A implementation for our example could look like this (pseudocode):
<?php
namespace Your\Package\GraphQL\Resolver;
use Neos\Flow\Annotations as Flow;
use t3n\GraphQL\ResolverInterface;
class QueryResolver implements ResolverInterface
{
protected $someServiceToFetchProducts;
public function products($_, $variables): array
{
// return an array with products
return $this->someServiceToFetchProducts->findAll();
}
public function product($_, $variables): ?Product
{
$id = $variables['id'];
return $this->someServiceToFetchProducts->getProductById($id);
}
}
<?php
namespace Your\Package\GraphQL\Resolver\Type;
use Neos\Flow\Annotations as Flow;
use t3n\GraphQL\ResolverInterface;
class ProductResolver implements ResolverInterface
{
public function name(Product $product): array
{
// this is just an overload example
return $product->getName();
}
}
An example query like:
query {
products {
id
name
price
}
}
would invoke the QueryResolver in first place and call the products()
method. This method
returns an array with Product objects. For each of the objects the ProductResolver is used.
To fetch the actual value there is a DefaultFieldResolver. If you do not configure a method
named as the requests property it will be used to fetch the value. The DefaultFieldResolver
will try to fetch the data itself via ObjectAccess::getProperty($source, $fieldName)
.
So if your Product Object has a getName()
it will be used. You can still overload the
implementation just like in the example.
All resolver methods share the same signature:
method($source, $args, $context, $info)
When working with interfaces, you need Resolvers for your interfaces as well. Given this schema:
interface Person {
firstName: String!
lastName: String!
}
type Customer implements Person {
firstName: String!
lastName: String!
email: String!
}
type Supplier implements Person {
firstName: String!
lastName: String!
phone: String
}
You need to configure a Person
Resolver as well as Resolvers for the concrete implementations.
While the concrete Type Resolvers do not need any special attention, the Person
Resolver implements the
__resolveType
function returning the type names:
<?php
namespace Your\Package\GraphQL\Resolver\Type;
use t3n\GraphQL\ResolverInterface;
class PersonResolver implements ResolverInterface
{
public function __resolveType($source): ?string
{
if ($source instanceof Customer) {
return 'Customer';
} elseif ($source instanceof Supplier) {
return 'Supplier';
}
return null;
}
}
Unions are resolved the same way as interfaces. For the example above, the corresponding Schema looks like this:
union Person = Customer | Supplier
It is resolved again with a PersonResolver
implementing the __resolveType
function.
Enums are resolved automatically. The Resolver method simply returns a string matching the Enum value. For the Schema:
enum Status {
ACTIVE
INACTIVE
}
type Customer {
status: Status!
}
The CustomerResolver
method returns the value as string:
public function status(Customer $customer): string
{
return $customer->isActive() ? 'ACTIVE' : 'INACTIVE';
}
Types at the leaves of a Json tree are defined by Scalars. Own Scalars are implemented by a Resolver
implementing the functions serialize()
, parseLiteral()
and parseValue()
.
This example shows the implementation of a DateTime
Scalar Type. For the given Schema definition:
scalar DateTime
The DateTimeResolver
looks the following when working with Unix timestamps:
<?php
namespace Your\Package\GraphQL\Resolver\Type;
use DateTime;
use GraphQL\Language\AST\IntValueNode;
use GraphQL\Language\AST\Node;
use t3n\GraphQL\ResolverInterface;
class DateTimeResolver implements ResolverInterface
{
public function serialize(DateTime $value): ?int
{
return $value->getTimestamp();
}
public function parseLiteral(Node $ast): ?DateTime
{
if ($ast instanceof IntValueNode) {
$dateTime = new DateTime();
$dateTime->setTimestamp((int)$ast->value);
return $dateTime;
}
return null;
}
public function parseValue(int $value): DateTime
{
$dateTime = new DateTime();
$dateTime->setTimestamp($value);
return $dateTime;
}
}
You have to make the DateTimeResolver
available again through one of the configuration options in the Settings.yaml.
The third argument in your Resolver method signature is the Context.
By Default it's set to t3n\GraphQContext
which exposes the current request.
It's easy to set your very own Context per endpoint. This might be handy to share some Code or Objects
between all your Resolver implementations. Make sure to extend t3n\GraphQContext
Let's say we have an graphql endpoint for a shopping basket (simplified):
type Query {
basket: Basket
}
type Mutation {
addItem(item: BasketItemInput): Basket
}
type Basket {
items: [BasketItem]
amount: Float!
}
type BasketItem {
id: ID!
name: String!
price: Float!
}
input BasketItemInput {
name: String!
price: Float!
}
First of all configure your context for your shopping endpoint:
t3n:
GraphQL:
endpoints:
'shop':
context: 'Your\Package\GraphQL\ShoppingBasketContext'
schemas:
basket:
typeDefs: 'resource://Your.Package/Private/GraphQL/schema.graphql'
resolverPathPattern: 'Your\Package\GraphQL\Resolver\Type\{Type}Resolver'
resolvers:
Query: 'Your\Package\GraphQL\Resolver\QueryResolver'
Mutation: 'Your\Package\GraphQL\Resolver\MutationResolver'
A context for this scenario would inject the current basket (probably flow session scoped);
<?php
declare(strict_types=1);
namespace Your\Package\GraphQL;
use Neos\Flow\Annotations as Flow;
use Your\Package\Shop\Basket;
use t3n\GraphQL\Context as BaseContext;
class ShoppingBasketContext extends BaseContext
{
/**
* @Flow\Inject
*
* @var Basket
*/
protected $basket;
public function getBasket()
{
return $this->basket;
}
}
And the corresponding resolver classes:
<?php
namespace Your\Package\GraphQL\Resolver;
use Neos\Flow\Annotations as Flow;
use t3n\GraphQL\ResolverInterface;
class QueryResolver implements ResolverInterface
{
protected $someServiceToFetchProducts;
// Note the resolver method signature. The context is available as third param
public function basket($_, $variables, ShoppingBasketContext $context): array
{
return $context->getBasket();
}
}
<?php
namespace Your\Package\GraphQL\Resolver;
use Neos\Flow\Annotations as Flow;
use t3n\GraphQL\ResolverInterface;
class MutationResolver implements ResolverInterface
{
protected $someServiceToFetchProducts;
public function addItem($_, $variables, ShoppingBasketContext $context): Basket
{
// construct your item with the input (simplified, don't forget validation etc.)
$item = new BasketItem();
$item->setName($variables['name']);
$item->setPrice($variables['price']);
$basket = $context->getBasket();
$basket->addItem($item);
return $basket;
}
}
You can enable logging of incoming requests per endpoint:
t3n:
GraphQL:
endpoints:
'your-endpoint':
logRequests: true
Once activated all incoming requests will be logged to Data/Logs/GraphQLRequests.log
. Each log entry
will contain the endpoint, query and variables.
To secure your api endpoints you have several options. The easiest way is to just configure some privilege for your Resolver:
privilegeTargets:
'Neos\Flow\Security\Authorization\Privilege\Method\MethodPrivilege':
'Your.Package:Queries':
matcher: 'method(public Your\Package\GraphQL\Resolver\QueryResolver->.*())'
'Your.Package:Mutations':
matcher: 'method(public Your\Package\GraphQL\Resolver\MutationResolver->.*())'
roles:
'Your.Package:SomeRole':
privileges:
- privilegeTarget: 'Your.Package:Queries'
permission: GRANT
- privilegeTarget: 'Your.Package:Mutations'
permission: GRANT
You could also use a custom Context to access the current logged in user.
By default this package provides three directives:
- AuthDirective
- CachedDirective
- CostDirective
To enable those Directives add this configuration to your endpoint:
t3n:
GraphQL:
endpoints:
'your-endpoint':
schemas:
root: # use any key you like here
typeDefs: 'resource://t3n.GraphQL/Private/GraphQL/schema.root.graphql'
schemaDirectives:
auth: 't3n\GraphQL\Directive\AuthDirective'
cached: 't3n\GraphQL\Directive\CachedDirective'
cost: 't3n\GraphQL\Directive\CostDirective'
The AuthDirective will check the security context for current authenticated roles. This enables you to protect objects or fields to user with given roles.
Use it like this to allow Editors to update a product but restrict the removal to Admins only:
type Mutation {
updateProduct(): Product @auth(required: "Neos.Neos:Editor")
removeProduct(): Boolean @auth(required: "Neos.Neos:Administrator")
}
Caching is always a thing. Some queries might be expensive to resolve and it's worthy to cache the result. Therefore you should use the CachedDirective:
type Query {
getProduct(id: ID!): Product @cached(maxAge: 100, tags: ["some-tag", "another-tag"])
}
The CachedDirective will use a flow cache t3n_GraphQL_Resolve
as a backend. The directive accepts a maxAge
argument as well as tags. Check the flow documentation about caching to learn about them!
The cache entry identifier will respect all arguments (id in this example) as well as the query path.
The CostDirective will add a complexity function to your fields and objects which is used by some validation rules. Each type and children has a default complexity of 1. It allows you to annotate cost values and multipliers just like this:
type Product @cost(complexity: 5) {
name: String! @cost(complexity: 3)
price: Float!
}
type Query {
products(limit: Int!): [Product!]! @cost(multipliers: ["limit"])
}
If you query produts(limit: 3) { name, price }
the query would have a cost of:
9 per product (5 for the product itself and 3 for fetching the name and 1 for the price (default complexity)) multiplied with 3 cause we defined the limit value as an multiplier. So the query would have a total complexity of 27.
There are several Validation rules you can enable per endpoint. The most common are the QueryDepth as well as the QueryComplexity rule. Configure your endpoint to enable those rules:
t3n:
GraphQL:
endpoints:
'some-endpoint':
validationRules:
depth:
className: 'GraphQL\Validator\Rules\QueryDepth'
arguments:
maxDepth: 11
complexity:
className: 'GraphQL\Validator\Rules\QueryComplexity'
arguments:
maxQueryComplexity: 1000
The maxQueryComplexitiy
is calculated via the CostDirective.