Skip to content

kinglozzer/silverstripe-graphql

 
 

Repository files navigation

SilverStripe GraphQL Server

Build Status codecov Scrutinizer Code Quality SilverStripe supported module

This modules serves SilverStripe data as GraphQL representations, with helpers to generate schemas based on SilverStripe model introspection. It layers a pluggable schema registration system on top of the graphql-php library. The APIs are very similar.

Installation

Require the composer package in your composer.json

composer require silverstripe/graphql

Table of contents

Usage

GraphQL is used through a single route, typically /graphql. You need to define Types and Queries to expose your data via this endpoint. While this recommended route is left open for you to configure on your own, the modules contained in the CMS recipe, (e.g. asset-admin, campaign-admin) run off a separate GraphQL server with its own endpoint (admin/graphql) with its own permissions and schema.

These separate endpoints have their own identifiers. default refers to the GraphQL server in the user space (e.g. /graphql) while admin refers to the GraphQL server used by CMS modules (admin/graphql). You can also set up a new schema if you wish.

By default, this module does not route any GraphQL servers. To activate the default, public-facing GraphQL server that ships with the module, just add a rule to Director.

SilverStripe\Control\Director:
  rules:
    'graphql': '%$SilverStripe\GraphQL\Controller.default'

Examples

Code examples can be found in the examples/ folder (built out from the configuration docs below).

Configuration

The manager

The primary class used to define your schema is SilverStripe\GraphQL\Manager, which is a container for types and queries which get negotiated and transformed into a schema on a just-in-time basis for every request. All types, queries, mutations, interfaces, and fragments must be registered in a Manager instance.

Define types

Types describe your data. While your data could be any arbitrary structure, in a SilverStripe project a GraphQL type usually relates to a DataObject. GraphQL uses this information to validate queries and allow GraphQL clients to introspect your API capabilities. The GraphQL type system is hierarchical, so the fields() definition declares object properties as scalar types within your complex type. Refer to the graphql-php type definitions for available types.

<?php

namespace MyProject\GraphQL;

use GraphQL\Type\Definition\Type;
use SilverStripe\GraphQL\TypeCreator;
use SilverStripe\GraphQL\Pagination\Connection;

class MemberTypeCreator extends TypeCreator
{
    public function attributes()
    {
        return [
            'name' => 'member'
        ];
    }

    public function fields()
    {
        return [
            'ID' => ['type' => Type::nonNull(Type::id())],
            'Email' => ['type' => Type::string()],
            'FirstName' => ['type' => Type::string()],
            'Surname' => ['type' => Type::string()],
        ];
    }
}

Each type class needs to be registered with a unique name against the schema through YAML configuration:

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      types:  
        member: 'MyProject\GraphQL\MemberTypeCreator'

Define queries

Types can be exposed via "queries". These queries are in charge of retrieving data through the SilverStripe ORM. The response itself is handled by the underlying GraphQL PHP library, which loops through the resulting DataList and accesses fields based on the referred "type" definition.

Note: This will return ALL records. See below for a paginated example.

<?php

namespace MyProject\GraphQL;

use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use SilverStripe\Security\Member;
use SilverStripe\GraphQL\OperationResolver;
use SilverStripe\GraphQL\QueryCreator;

class ReadMembersQueryCreator extends QueryCreator implements OperationResolver
{
    public function attributes()
    {
        return [
            'name' => 'readMembers'
        ];
    }

    public function args()
    {
        return [
            'Email' => ['type' => Type::string()]
        ];
    }

    public function type()
    {
        return Type::listOf($this->manager->getType('member'));
    }

    public function resolve($object, array $args, $context, ResolveInfo $info)
    {
        $member = Member::singleton();
        if (!$member->canView($context['currentUser'])) {
            throw new \InvalidArgumentException(sprintf(
                '%s view access not permitted',
                Member::class
            ));
        }
        $list = Member::get();

        // Optional filtering by properties
        if (isset($args['Email'])) {
            $list = $list->filter('Email', $args['Email']);
        }

        return $list;
    }
}

We'll register the query with a unique name through YAML configuration:

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      queries:
        readMembers: 'MyProject\GraphQL\ReadMembersQueryCreator'

You can query data with the following URL:

/graphql/?query={readMembers{ID+FirstName+Email}}

The query contained in the query parameter can be reformatted as follows:

{
  readMembers {
    edges {
      node {
        ID
        FirstName
        Email
      }
    }
  }
}

You can apply the Email filter in the above example like so:

query ($Email: String) {
  readMembers(Email: $Email) {
    edges {
      node {
        ID
        FirstName
        Email
      }
    }
  }
}

And add a query variable:

{
  "Email": "[email protected]"
}

You could express this query inline as a single query as below:

{
  readMembers(Email: "[email protected]") {
    edges {
      node {
        ID
        FirstName
        Email
      }
    }
  }
}

Pagination

The GraphQL module also provides a wrapper to return paginated and sorted records using offset based pagination.

This module currently does not support Relay (cursor based) pagination. This blog post describes the differences.

To have a Query return a page-able list of records queries should extend the PaginatedQueryCreator class and return a Connection instance.

<?php

namespace MyProject\GraphQL;

use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use SilverStripe\Security\Member;
use SilverStripe\GraphQL\Pagination\Connection;
use SilverStripe\GraphQL\Pagination\PaginatedQueryCreator;

class PaginatedReadMembersQueryCreator extends PaginatedQueryCreator
{
    public function createConnection()
    {
        return Connection::create('paginatedReadMembers')
            ->setConnectionType($this->manager->getType('member'))
            ->setArgs([
                'Email' => [
                    'type' => Type::string()
                ]
            ])
            ->setSortableFields(['ID', 'FirstName', 'Email'])
            ->setConnectionResolver(function ($object, array $args, $context, ResolveInfo $info) {
                $member = Member::singleton();
                if (!$member->canView($context['currentUser'])) {
                    throw new \InvalidArgumentException(sprintf(
                        '%s view access not permitted',
                        Member::class
                    ));
                }
                $list = Member::get();

                // Optional filtering by properties
                if (isset($args['Email'])) {
                    $list = $list->filter('Email', $args['Email']);
                }

                return $list;
            });
    }
}

You will need to add a new unique query alias to your configuration:

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      queries:
        paginatedReadMembers: 'MyProject\GraphQL\PaginatedReadMembersQueryCreator'

Using a Connection the GraphQL server will return the results wrapped under the edges result type. Connection supports the following arguments:

  • limit
  • offset
  • sortBy

Additional arguments can be added by providing the setArgs function (such as Email in the previous example). Each argument must be given a specific type.

Pagination information is provided under the pageInfo type. This object type supports the following fields:

  • totalCount returns the total number of items in the list,
  • hasNextPage returns whether more records are available.
  • hasPreviousPage returns whether more records are available by decreasing the offset.

You can query paginated data with the following URL:

/graphql/?query=query+Members{paginatedReadMembers(limit:1,offset:0){edges{node{ID+FirstName+Email}}pageInfo{hasNextPage+hasPreviousPage+totalCount}}}

The query contained in the query parameter can be reformatted as follows:

query Members {
  paginatedReadMembers(limit: 1, offset: 0) {
    edges {
      node {
        ID
        FirstName
        Email
      }
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
      totalCount
    }
  }
}

Setting pagination and sorting options

To limit the ability for users to perform searching and ordering as they wish, Collection instances can define their own limits and defaults.

  • setSortableFields an array of allowed sort columns.
  • setDefaultLimit integer for the default page length (default 100)
  • setMaximumLimit integer for the maximum limit records per page to prevent excessive load trying to load millions of records (default 100)
return Connection::create('paginatedReadMembers')
    // ...
    ->setDefaultLimit(10)
    ->setMaximumLimit(100); // prevents users requesting more than 100 records

Nested connections

Connection can be used to return related objects such as has_many and many_many models.

<?php

namespace MyProject\GraphQL;

use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use SilverStripe\GraphQL\TypeCreator;
use SilverStripe\GraphQL\Pagination\Connection;

class MemberTypeCreator extends TypeCreator
{
    public function attributes()
    {
        return [
            'name' => 'member'
        ];
    }

    public function fields()
    {
        $groupsConnection = Connection::create('Groups')
            ->setConnectionType($this->manager->getType('group'))
            ->setDescription('A list of the users groups')
            ->setSortableFields(['ID', 'Title']);

        return [
            'ID' => ['type' => Type::nonNull(Type::id())],
            'Email' => ['type' => Type::string()],
            'FirstName' => ['type' => Type::string()],
            'Surname' => ['type' => Type::string()],
            'Groups' => [
                'type' => $groupsConnection->toType(),
                'args' => $groupsConnection->args(),
                'resolve' => function($object, array $args, $context, ResolveInfo $info) use ($groupsConnection) {
                    return $groupsConnection->resolveList(
                        $object->Groups(),
                        $args,
                        $context
                    );
                }
            ]
        ];
    }
}
query Members {
  paginatedReadMembers(limit: 10) {
    edges {
      node {
        ID
        FirstName
        Email
        Groups(sortBy: [{field: Title, direction: DESC}]) {
          edges {
            node {
              ID
              Title
              Description
            }
          }
          pageInfo {
            hasNextPage
            hasPreviousPage
            totalCount
          }
        }
      }
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
      totalCount
    }
  }
}

Adding search params

You can add search parameters your query to filter results using the DataObjectQueryFilter class. Much like pagination, this is done using a reusable service that wraps your existing queries.

The end result will allow you to do something like this:

query readBlogs(
  Filter: {
    Title__contains: "food",
    CommentCount__gt: 5,
    Categories__Title__in: ["Recipes", "Cooking tips"]
  },
  Exclude: {
    Hidden__eq: true
  }
) {
  ID
  Title
}

So how do we do it? If you're using QueryCreator classes, a good approach is to add an instance of the query filter in your constructor.

$this->queryFilter = DataObjectQueryFilter::create(MyDataObject::class);

You can then add filters to fields of the dataobject.

$this->queryFilter
    ->addFilteredField('Title', 'contains')
    ->addFilteredField('CommentCount', 'gt')
    ->addFilteredField('Categories__Title', 'in')
    ->addFilteredField('Hidden', 'eq');

Don't worry about the filter keys (contains, gt, eq, etc) for now. That will be explained further down.

Now that we have composed a DataObjectQueryFilter, we can now use it to create input types.

public function args()
{
    return [
        'Filter' => $this->queryFilter->getInputType('MyDataObjectFilterInputType'),
        'Exclude' => $this->queryFilter->getInputType('MyDataObjectExcludeInputType'),
    ];
}

Make sure your argument names match the names configured in DataObjectQueryFilter. By default, they are Filter and Exclude. If you want to use different names, use setFilterKey() and setExcludeKey().

Lastly, let's update the resolver to apply the filters.

public function resolve($obj, $args = [], $context = [], ResolveInfo $info)
{
    $list = MyDataObject::get();
    $list = $this->queryFilter->applyArgsToList($list, $args);
    
    return $list;
}

Shortcuts (for the 80% case)

All SilverStripe\ORM\DBField instances are configured to have a set of "default" filters (see filters.yml). For instance, most string and text fields just use eq, contains, and in, and omit integer-specific filters like gt and lt.

To make things easier, you can simply add the default filters for each field.

$this->queryFilter->addDefaultFilters('MyTextField');
$this->queryFilter->addDefaultFilters('MyInt');

Even better, if you want all fields on your object filterable, just use addAllFilters to add all the default filters for each field (including inherited) on your model.

$this->queryFilter->addAllFilters();

The filter registry

The reason why we're able to use contains, gt, etc. as filters is because they have been added to the filter registry, a singleton that is composed through the config yaml. (See filters.yml).

SilverStripe\Core\Injector\Injector:
  SilverStripe\GraphQL\QueryFilter\FilterRegistryInterface:
    class: SilverStripe\GraphQL\QueryFilter\FieldFilterRegistry
    constructor:
      contains: '%$SilverStripe\GraphQL\QueryFilter\Filters\ContainsFilter'
      eq: '%$SilverStripe\GraphQL\QueryFilter\Filters\EqualToFilter'
      # etc...

You can add your own filters. You just need to implement the SilverStripe\GraphQL\QueryFilter\FieldFilterInterface interface, which requires methods for applyInclusion() and applyExclusion(). It also must declare a unique identifier (e.g. contains). Once you've defined a class, just add it to the registry via config.

If you want your filter to accept an array of values, implement SilverStripe\GraphQL\QueryFilter\ListFieldFilterInterface instead.

Default filters

Identifier ORM Mapping Classname
eq ExactMatch SilverStripe\GraphQL\QueryFilter\Filters\EqualToFilter
contains PartialMatch SilverStripe\GraphQL\QueryFilter\Filters\ContainsFilter
gt GreaterThan SilverStripe\GraphQL\QueryFilter\Filters\GreaterThanFilter
lt LessThan SilverStripe\GraphQL\QueryFilter\Filters\LessThanFilter
gte GreaterThanOrEqual SilverStripe\GraphQL\QueryFilter\Filters\GreaterThanOrEqualFilter
lte LessThanOrEqual SilverStripe\GraphQL\QueryFilter\Filters\LessThanOrEqualFilter
startswith StartsWith SilverStripe\GraphQL\QueryFilter\Filters\LessThanFilter
endswith EndsWith SilverStripe\GraphQL\QueryFilter\Filters\LessThanFilter
in ExactMatch (array) SilverStripe\GraphQL\QueryFilter\Filters\InFilter

Custom filters

For some queries, you may want to use a default filter identifier (e.g. eq) but with a custom implementation of its filtering mechanism. For this, you can use addFieldFilter method.

One example might be searching by date, where the provided date does not have to be an exact match on the full timestamp to satisfy the filter.

$this->queryFilter->addFieldFilter('PublishedDate', new FuzzyDateFilter());

Where FuzzyDateFilter is an implementation of `FieldFilterInterface.

class MyCustomFieldFilter implements FieldFilterInterface
{
    public function getIdentifier()
    {
        return 'eq';
    }
    
    public function applyInclusion(DataList $list, $fieldName, $value)
    {
        return $list->addWhere([
            'DATE(PublishedDate) = ?' => $value
        ]);
    }
}

You can now query all posts for a given day with:

query readMyPosts(Filter: {
  PublishedDate__eq: "2018-01-29"
}) {
  Title
}

Define mutations

A "mutation" is a specialised GraphQL query which has side effects on your data, such as create, update or delete. Each of these operations would be expressed as its own mutation class. Returning an object from the resolve() method will automatically include it in the response.

<?php
namespace MyProject\GraphQL;

use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use SilverStripe\GraphQL\MutationCreator;
use SilverStripe\GraphQL\OperationResolver;
use SilverStripe\Security\Member;

class CreateMemberMutationCreator extends MutationCreator implements OperationResolver
{
    public function attributes()
    {
        return [
            'name' => 'createMember',
            'description' => 'Creates a member without permissions or group assignments'
        ];
    }

    public function type()
    {
        return $this->manager->getType('member');
    }

    public function args()
    {
        return [
            'Email' => ['type' => Type::nonNull(Type::string())],
            'FirstName' => ['type' => Type::string()],
            'LastName' => ['type' => Type::string()],
        ];
    }

    public function resolve($object, array $args, $context, ResolveInfo $info)
    {
        if (!singleton(Member::class)->canCreate($context['currentUser'])) {
            throw new \InvalidArgumentException('Member creation not allowed');
        }

        return (new Member($args))->write();
    }
}

We'll register this mutation through YAML configuration:

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      mutations:
        createMember: 'MyProject\GraphQL\CreateMemberMutationCreator'

You can run a mutation with the following query:

mutation ($Email: String!) {
  createMember(Email: $Email) {
    ID
  }
}

This will create a new member with an email address, which you can pass in as query variables: {"Email": "[email protected]"}. It'll return the new ID property of the created member.

Scaffolding DataObjects into the schema

Making a DataObject accessible through the GraphQL API involves quite a bit of boilerplate. In the above example, we can see that creating endpoints for a query and a mutation requires creating three new classes, along with an update to the configuration, and we haven't even dealt with data relations yet. For applications that require a lot of business logic and specific functionality, an architecture like this affords the developer a lot of control, but for developers who just want to make a given model accessible through GraphQL with some basic create, read, update, and delete operations, scaffolding them can save a lot of time and reduce the clutter in your project.

Scaffolding DataObjects can be achieved in two non-exclusive ways:

  • Via executable code (procedurally)
  • Via the config layer (declaratively)

Examples of both methods will be demonstrated below.

Our example

For these examples, we'll imagine we have the following model:

namespace MyProject;

use MyProject\Comment;
use SilverStripe\Assets\File;
use SilverStripe\Security\Member;

class Post extends DataObject
{
    private static $db = [
        'Title' => 'Varchar',
        'Content' => 'HTMLText'
    ];

    private static $has_one = [
        'Author' => Member::class
    ];

    private static $has_many = [
        'Comments' => Comment::class
    ];

    private static $many_many = [
        'Files' => File::class
    ];
}

Scaffolding through the Config layer

Many of the declarations you make through procedural code can be done via YAML. If you don't have any logic in your scaffolding, using YAML is a simple approach to adding scaffolding.

We'll need to define a scaffolding node in the SilverStripe\GraphQL\Manager.schemas.mySchemaKey setting.

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      scaffolding:
      ## scaffolding will go here

Scaffolding through procedural code

Alternatively, for more complex requirements, you can create the scaffolding with code. The GraphQL Manager class will bootstrap itself with any scaffolders that are registered in its config. These scaffolders must implement the ScaffoldingProvider interface. A logical place to add this code may be in your DataObject, but it could be anywhere.

As a ScaffoldingProvider, the class must now offer the provideGraphQLScaffolding() method.

namespace MyProject;

use SilverStripe\GraphQL\Scaffolding\Interfaces\ScaffoldingProvider;
use SilverStripe\GraphQL\Scaffolding\Scaffolders\SchemaScaffolder;
use SilverStripe\ORM\DataObject;

class Post extends DataObject implements ScaffoldingProvider
{
    //...
    public function provideGraphQLScaffolding(SchemaScaffolder $scaffolder)
    {
        // update the scaffolder here
    }
}

In order to register the scaffolding provider with the manager, we'll need to make an update to the config.

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      scaffolding_providers:
        - MyProject\Post

Exposing a DataObject to GraphQL

Let's now expose the Post type. We'll choose the fields we want to offer, along with a simple query and mutation. To resolve queries and mutations, we'll need to specify the name of a resolver class. This class must implement the SilverStripe\GraphQL\OperationResolver. (More on this below).

Via YAML:

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      scaffolding:
        types:
          MyProject\Post:
            fields: [ID, Title, Content]
            operations:
              read: true
              create: true

...Or with code:

namespace MyProject;

use SilverStripe\GraphQL\Scaffolding\Interfaces\ScaffoldingProvider;
use SilverStripe\GraphQL\Scaffolding\Scaffolders\SchemaScaffolder;
use SilverStripe\ORM\DataObject;

class Post extends DataObject implements ScaffoldingProvider
{
    //...
    public function provideGraphQLScaffolding(SchemaScaffolder $scaffolder)
    {
        $scaffolder
            ->type(Post::class)
                ->addFields(['ID', 'Title', 'Content'])
                ->operation(SchemaScaffolder::READ)
                    ->end()
                ->operation(SchemaScaffolder::UPDATE)
                    ->end()
                ->end();

        return $scaffolder;
    }
}

By declaring these two operations, we have automatically added a new query and mutation to the GraphQL schema, using naming conventions derived from the operation type and the singular_name or plural_name of the DataObject.

query {
  readPosts {
    edges {
      node {
        Title
        Content
      }
    }
  }
}
mutation CreatePost($Input: PostCreateInputType!) {
  createPost(Input: $Input) {
    Title
  }
}
{
  "Input": {
    "Title": "My Title"
  }
}

Permission constraints (in this case canView() and canCreate()) are enforced by the operation resolvers.

Available operations

For each type, all the basic CRUD operations are afforded to you by default (create, read, update, delete), plus an operation for readoOne, which retrieves a record by ID. Each operation can be activated by setting their identifier to true in YAML.

...
  operations:
    read: true
    update: true
    create: true
    delete: true
    readOne: true

To add configuration to these operations, define a map rather than assigning a boolean.

...
  operations:
    read:
      paginate: false

Alternatively, when using procedural code, just call opertation($identifier), where $identifier is a constant on the SchemaScaffolder class definition.

$scaffolder->type(MyDataObject::class)
  ->operation(SchemaScaffolder::READ)
    ->setUsePagination(false)
  ->end();

Adding search params (read operations only)

You can add all default filters for every field on your dataobject with filters: '*'.

read:
  filters: '*'

Note: "every field" means every field exposed by searchable_fields on the dataobject -- not just those exposed on its GraphQL type.

To be more granular, break it up into a list of specific fields.

read:
  filters:
    MyField: true # All default filters for this field type
    MyInt:
      gt: true # Greater than
      gte: true # Greater than or equal

Or with procedural code...

$scaffolder->type(MyDataObject::class)
  ->operation(SchemaScaffolder::READ)
    ->queryFilter()
      ->addDefaultFields('MyField')
      ->addFieldFilter('MyInt', 'gt')
      ->addFieldFilter('MyInt', 'gte')
    ->end()
  ->end();

These filter options are also available onreadOne operations, but be aware that they are mutually exclusive with its ID parameter.

Setting field and operation descriptions

Adding field descriptions is a great way to maintain a well-documented API. To do this, use a map of FieldName: 'Your description' instead of an enumerated list of field names.

Via YAML:

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      scaffolding:
        types:
          MyProject\Post:
            fields:
              ID: The unique identifier of the post
              Title: The title of the post
              Content: The main body of the post (HTML)
            operations:
              read:
                description: Reads all posts
              create: true

...Or with code:

namespace MyProject;

use SilverStripe\GraphQL\Scaffolding\Interfaces\ScaffoldingProvider;
use SilverStripe\GraphQL\Scaffolding\Scaffolders\SchemaScaffolder;
use SilverStripe\ORM\DataObject;

class Post extends DataObject implements ScaffoldingProvider
{
    //...
    public function provideGraphQLScaffolding(SchemaScaffolder $scaffolder)
    {
        $scaffolder
            ->type(Post::class)
                ->addFields([
                    'ID' => 'The unique identifier of the post',
                    'Title' => 'The title of the post',
                    'Content' => 'The main body of the post (HTML)'
                ])
                ->operation(SchemaScaffolder::READ)
                    ->setDescription('Reads all posts')
                    ->end()
                ->operation(SchemaScaffolder::UPDATE)
                    ->end()
                ->end();

            return $scaffolder;
    }
}

Wildcarding and whitelisting fields

If you have a type you want to be fairly well exposed, it can be tedious to add each field piecemeal. As a shortcut, you can use addAllFields() (code) or fields: * (YAML). If you have specific fields you want omitted from that list, you can use addAllFieldsExcept() (code) or excludeFields (YAML).

Via YAML:

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      scaffolding:
        types:
          MyProject\Post:
            fields: *
            excludeFields: [SecretThing]

... Or with code:

$scaffolder
    ->type(Post::class)
        ->addAllFieldsExcept(['SecretThing'])
    ->end()

Adding arguments

You can add arguments to basic crud operations, but keep in mind you'll need to use your own resolver, as the default resolvers will not be aware of any custom arguments you've allowed.

Using YAML, simply use a map of options instead of true.

Via YAML

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      scaffolding:
        types:
          MyProject\Post:
            fields: [ID, Title, Content]
            operations:
              read:
                args:
                  Title: String
                resolver: MyProject\ReadPostResolver
              create: true

... Or with code

$scaffolder
    ->type(Post::class)
        ->addFields(['ID', 'Title', 'Content'])
        ->operation(SchemaScaffolder::READ)
            ->addArgs([
                'Title' => 'String'
            ])
            ->setResolver(function($object, array $args, $context, ResolveInfo $info) {
                if (!singleton(Post::class)->canView($context['currentUser'])) {
                    throw new \Exception('Cannot view Post');
                }
                $list = Post::get();
                if (isset($args['Title'])) {
                    $list = $list->filter('Title:PartialMatch', $args['Title']);
                }

                return $list;
                })
            ->end()
        ->operation(SchemaScaffolder::UPDATE)
            ->end()
        ->end();

GraphQL

query {
  readPosts(Title: "Barcelona") {
    edges {
      node {
        Title
        Content
      }
    }
  }
}

Argument definition shorthand

You can make your scaffolding delcaration a bit more expressive by using argument shorthand.

  • String!: A required string
  • Int!(50): A required integer with a default value of 50
  • Boolean(0): A boolean defaulting to false
  • String: An optional string, defaulting to null

Adding more definition to arguments

To add descriptions, and to use a more granular level of control over your arguments, you can use a more long-form syntax.

Via YAML

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      scaffolding:
        types:
          MyProject\Post:
            fields: [ID, Title, Content]
            operations:
              read:
                args:
                  Title: String!
                  MinimumCommentCount:
                    type: Int
                    default: 5
                    description: 'Use this parameter to specify the minimum number of comments per post'
                resolver: MyProject\ReadPostResolver
              create: true

... Or with code

$scaffolder
    ->type(Post::class)
        ->addFields(['ID', 'Title', 'Content'])
        ->operation(SchemaScaffolder::READ)
            ->addArgs([
                'Title' => 'String!',
                'MinimumCommentCount' => 'Int'
            ])
            ->setArgDefaults([
                'MinimumCommentCount' => 5
            ])
            ->setArgDescriptions([
                'MinimumCommentCount' => 'Use this parameter to specify the minimum number of comments per post'
            ])
            ->setResolver(function($object, array $args, $context, ResolveInfo $info) {
                if (!singleton(Post::class)->canView($context['currentUser'])) {
                    throw new \Exception('Cannot view Post');
                }
                $list = Post::get();
                if (isset($args['Title'])) {
                    $list = $list->filter('Title:PartialMatch', $args['Title']);
                }

                return $list;
            })
            ->end()
        ->operation(SchemaScaffolder::UPDATE)
            ->end()
        ->end();

Using a custom resolver

As seen in the code example above, the simplest way to add a resolver is via an anonymous function via the setResolver() method. In YAML, you can't define such functions, so resolvers be names or instances of classes that implement the OperationResolver.

When using the YAML approach, custom resolver classes are compulsory, since you can't define closures in YAML.

namespace MyProject\GraphQL;

use GraphQL\Type\Definition\ResolveInfo;
use SilverStripe\GraphQL\OperationResolver;

class MyResolver implements OperationResolver
{
    public function resolve($object, array $args, $context, ResolveInfo $info)
    {
        $post = Post::get()->byID($args['ID']);
        $post->Title = $args['NewTitle'];
        $post->write();
    }
}

This resolver class may now be assigned as either an instance, or a string to the query or mutation definition.

$scaffolder
    ->type(Post::class)
        ->operation(SchemaScaffolder::UPDATE)
            ->setResolver(MyResolver::class)
            /* Or...
            ->setResolver(new MyResolver())
            */
            ->end();

Configuring pagination and sorting

By default, all queries are paginated and have no sortable fields. Both of these settings are configurable.

Via YAML

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      scaffolding:
        types:
          MyProject\Post:
            fields: [ID, Title, Content]
            operations:
              read:
                args:
                  Title: String
                resolver: MyProject\ReadPostResolver
                sortableFields: [Title]
              create: true
          MyProject\Comment:
            fields: [Comment, Author]
            operations:
              read:
                paginate: false

... Or with code

$scaffolder
    ->type(Post::class)
        ->addFields(['ID', 'Title', 'Content'])
        ->operation(SchemaScaffolder::READ)
            ->addArgs([
                'Title' => 'String'
            ])
            ->setResolver(function ($object, array $args, $context, ResolveInfo $info) {
                if (!singleton(Post::class)->canView($context['currentUser'])) {
                    throw new \Exception('Cannot view Post');
                }
                $list = Post::get();
                if (isset($args['Title'])) {
                    $list = $list->filter('Title:PartialMatch', $args['Title']);
                }

                return $list;
            })
            ->addSortableFields(['Title'])
            ->end()
        ->operation(SchemaScaffolder::UPDATE)
            ->end()
        ->end()
    ->type(Comment::class)
        ->addFields(['Comment', 'Author'])
        ->operation(SchemaScaffolder::READ)
            ->setUsePagination(false)
            ->end();

GraphQL

query readPosts(Title: "Japan", sortBy: [{field:Title, direction:DESC}]) {
  edges {
    node {
      Title
    }
  }
}
query readComments {
  edges {
    node {
      Author
      Comment
    }
  }
}

Adding related objects

The Post type we're using has a $has_one relation to Author (Member), and plural relationships to File and Comment. Let's expose both of those to the query.

For the $has_one, the relationship can simply be declared as a field. For $has_many, $many_many, and any custom getter that returns a DataList, we can set up a nested query.

Via YAML:

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      scaffolding:
        types:
          MyProject\Post:
            fields: [ID, Title, Content, Author]
            operations:
              read:
                args:
                  Title: String
                resolver: MyProject\ReadPostResolver
                sortableFields: [Title]
              create: true
            nestedQueries:
              Comments: true
              Files: true
          MyProject\Comment:
            fields: [Comment, Author]
            operations:
              read:
                paginate: false

... Or with code:

$scaffolder
    ->type(Post::class)
        ->addFields(['ID', 'Title', 'Content', 'Author'])
        ->operation(SchemaScaffolder::READ)
            ->addArgs([
                'Title' => 'String'
            ])
            ->setResolver(function ($object, array $args, $context, ResolveInfo $info) {
                if (!singleton(Post::class)->canView($context['currentUser'])) {
                    throw new \Exception('Cannot view Post');
                }
                $list = Post::get();
                if (isset($args['Title'])) {
                    $list = $list->filter('Title:PartialMatch', $args['Title']);
                }

                return $list;
            })
            ->addSortableFields(['Title'])
            ->end()
        ->operation(SchemaScaffolder::UPDATE)
            ->end()
        ->nestedQuery('Comments')
            ->end()
        ->nestedQuery('Files')
            ->end()
        ->end()
    ->type(Comment::class)
        ->addFields(['Comment', 'Author'])
        ->operation(SchemaScaffolder::READ)
            ->setUsePagination(false)
            ->end();

GraphQL

query {
  readPosts(Title: "Texas") {
    edges {
      node {
        Title
        Content
        Date
        Author {
          ID
        }
        Comments {
          edges {
            node {
              Comment
            }
          }
        }
        Files(limit: 2) {
          edges {
            node {
              ID
            }
          }
        }
      }
    }
  }
}

Notice that we can only query the ID field of Files and Author, our new related fields. This is because the types are implicitly created by the configuration, but only to the point that they exist in the schema. They won't eagerly add fields. That's still up to you. By default, you'll only get the ID field, as configured in SilverStripe\GraphQL\Scaffolding\Scaffolders\DataObjectScaffolder.default_fields.

Let's add some more fields.

*Via YAML:

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      scaffolding:
        types:
          MyProject\Post:
            ## ...
          MyProject\Comment:
            ## ...
          SilverStripe\Security\Member:
            fields: [FirstName, Surname, Name, Email]
          SilverStripe\Assets\File:
            fields: [Filename, URL]

... Or with code

$scaffolder
    ->type(Post::class)
    //...
    ->type(Member::class)
        ->addFields(['FirstName', 'Surname', 'Name', 'Email'])
        ->end()
    ->type(File::class)
        ->addFields(['Filename', 'URL'])
        ->end();

Notice that we can freely use the custom getter Name on the Member record. Fields and $db are not one-to-one.

Nested queries can be configured just like operations.

*Via YAML:

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      scaffolding:
        types:
          MyProject\Post:
            ## ...
            nestedQueries:
              Comments:
                args:
                  OnlyToday: Boolean
                resolver: MyProject\CommentResolver
            ##...

... Or with code

$scaffolder
    ->type(Post::class)
        ->nestedQuery('Comments')
            ->addArgs([
                'OnlyToday' => 'Boolean'
            ])
            ->setResolver(function($object, array $args, $context, ResolveInfo $info) {
                if (!singleton(Comment::class)->canView($context['currentUser'])) {
                    throw new \Exception('Cannot view Comment');
                }
                $comments = $object->Comments();
                if (isset($args['OnlyToday']) && $args['OnlyToday']) {
                    $comments = $comments->where('DATE(Created) = DATE(NOW())');
                }

                return $comments;
            })
        ->end()
        //...
    //...

GraphQL

query {
  readPosts(Title: "Sydney") {
    edges {
      node {
        Title
        Content
        Date
        Author {
          Name
          Email
        }
        Comments(OnlyToday: true) {
          edges {
            node {
              Comment
            }
          }
        }
        Files(limit: 2) {
          edges {
            node {
              Filename
              URL
            }
          }
        }
      }
    }
  }
}

Adding arbitrary queries and mutations

Not every operation maps to simple CRUD. For this, you can define custom queries and mutations in your schema, so long as they map to an existing type.

Via YAML

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      scaffolding:
        types:
          MyProject\Post:
            ##...
        mutations:
          updatePostTitle:
            type: MyProject\Post
            args:
              ID: ID!
              NewTitle: String!
            resolver: MyProject\UpdatePostResolver
        queries:
          latestPost:
            type: MyProject\Post
            paginate: false
            resolver: MyProject\LatestPostResolver

... Or with code:

$scaffolder
    ->type(Post::class)
        //...
    ->mutation('updatePostTitle', Post::class)
        ->addArgs([
            'ID' => 'ID!',
            'NewTitle' => 'String!'
        ])
        ->setResolver(function ($object, array $args, $context, ResolveInfo $info) {
            $post = Post::get()->byID($args['ID']);
            if ($post->canEdit($context['currentUser'])) {
                $post->Title = $args['NewTitle'];
                $post->write();
            }

            return $post;
        })
        ->end()
    ->query('latestPost', Post::class)
        ->setUsePagination(false)
        ->setResolver(function ($object, array $args, $context, ResolveInfo $info) {
            if (singleton(Post::class)->canView($context['currentUser'])) {
                return Post::get()->sort('Date', 'DESC')->first();
            }
        })
        ->end()

GraphQL

mutation updatePostTitle($ID: 123, $NewTitle: 'Foo') {
  Title
}
query latestPost {
  Title
}

Alternatively, if you want to customise a nested query, you can specify QueryScaffolder subclass, which will allow you to write your own resolver and build your own set of arguments. This is particularly useful if your nested query does not return a DataList, from which a dataobject class can be inferred.

class MyCustomListQueryScaffolder extends ListQueryScaffolder
{
    public function resolve ($object, array $args, $context, ResolveInfo $info)
    {
        // .. custom query code
    }

    protected function createArgs(Manager $manager)
    {
        return [
            'SpecialArg' => [
                'type' => $manager->getType('SpecialInputType')
            ]
        ];
    }
}

Via YAML:

...
  nestedQueries:
    MyCustomList: My\Project\Scaffolders\MyCustomListQueryScaffolder

... Or with code:

 $scaffolder->type(MyObject::class)
    ->nestedQuery(
        'MyNestedField',  // the name of the field on the parent object
        new MyCustomListQueryScaffolder(
          'customOperation', // The name of the operation. Must be unique.
          'MyCustomType' // The type the query will return. Make sure it's been registered.
        )
    );

Dealing with inheritance

Adding any given type will implicitly add all of its ancestors, all the way back to DataObject. Any fields you've listed on a descendant that are available on those ancestors will be exposed on the ancestors as well. For CRUD operations, each ancestor gets its own set of operations and input types.

When reading types that have exposed descendants (e.g. reading Page, when RedirectorPage is also exposed), the return type is a union of the base type and all exposed descendants. This union type takes on the name {BaseType}WithDescendants.

Via YAML:

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      scaffolding:
        types:
          MyProject\Post:
            ##...
          SilverStripe\CMS\Model\RedirectorPage:
            fields: [ID, ExternalURL, Content]
            operations:
              read: true
              create: true
          Page:
            fields: [MyCustomField]

... Or with code:

$scaffolder
    ->type('SilverStripe\CMS\Model\RedirectorPage')
        ->addFields(['ID', 'ExternalURL', 'Content'])
        ->operation(SchemaScaffolder::READ)
            ->setName('readRedirectors')
            ->end()
        ->operation(SchemaScaffolder::CREATE)
            ->setName('createRedirector')
            ->end()
        ->end()
    ->type('Page')
        ->addFields(['MyCustomField'])
        ->end();

We now have the following added to our schema:

type RedirectorPage {
  ID: Int
  ExternalURL: String
  Content: String
  MyCustomField: String
}

type Page {
  ID: Int
  Content: String
  MyCustomField: String
}

type SiteTree {
  ID: Int
  Content: String
}

type PageWithDescendants {
  Page | RedirectorPage
}

type SiteTreeWithDescendants {
  SiteTree | Page | RedirectorPage
}

input RedirectorPageCreateInputType {
  ExternalURL: String
  RedirectionType: String
  MyCustomField: String
  Content: String
  # all other fields from RedirectorPage, Page and SiteTree
}

input PageCreateInputType {
  MyCustomField: String
  Content: String
  # all other fields from Page and SiteTree
}

input SiteTreeCreateInputType {
  # all fields from SiteTree
}

query readRedirectors {
  RedirectorPage
}

query readPages {
  PageWithDescendants
}

query readSiteTrees {
  SiteTreeWithDescendants
}

mutation createRedirector {
  RedirectorPageCreateInputType
}

mutation createPage {
  PageCreateInputType
}

mutation createSiteTree {
  SiteTreeCreateInputType
}

Querying types that have descendants

Keep in mind that when querying a base class that has descendant types exposed (e.g. querying Page when RedirectorPage is also exposed), a union is returned, and you will need to resolve it with the ...on {type} GraphQL syntax.

query readSiteTrees {
  readSiteTrees {
    edges {
      node {
        __typename
        ...on Page {
          ID
        }
        ...on RedirectorPage {
          RedirectionType
        }
      }
    }
  }
}

Customising the names of types and operations

By default, the scaffolder will generate a type name for you based on the dataobject's $table_name setting and the output of its singular_name() method. Often times, these are poor proxies for a canonical name, e.g. readMy_Really_Long_NameSpaced_BlogPosts. To customise the type name, simply map a name to it in the SilverStripe\GraphQL\Scaffolding\Schema class.

SilverStripe\GraphQL\Manager:
  schemas:
    default:
      typeNames:
        My\Really\Long\Namespaced\BlogPost: Blog

Note that typeNames is the mapping of dataobjects to the graphql types, whereas the types config is the list of type creators for non-scaffolded types, backed by php classes. typeNames is also used (and required by) scaffolding, whether via PHP or YML.

Operations names are expressed using the type name of the dataobject they serve. That type name may be customised or computed automatically, as described above. For a deeper level of control, you can name the operation using the name property.

...
  operations:
    read:
      name: currentBlogs

The name of the operation has been fully customised to currentBlogs, returning the type Blog.

...
  operations:
    read: true

Otherwise, the name of the read operation, given the Schema config above, will be readBlogs.

Versioned content

If the silversrtripe/versioned module is installed in your project (as it is with a default CMS install), a series of schema updates specific to versioning will be provided to all types that use the Versioned extension. These include:

Version-specific operations

  • publish<MyType>(ID: Int!)
  • unpublish<MyType>(ID: Int!)
  • copy<MyType>ToStage(Input: CopyToStageInputType { ID: Int, FromStage: String, ToStage: String, Version: Int })
...
  operations:
    publish: true
    unpublish: true
    copyToStage: true

Version-specific arguments

Types that use the Versioned extension will also have their read operations extended to accept a Versioning parameter which allows you define very specifically what versioning filters to apply to the result set.

The "Versioning" input

Field Description
Mode

One of:

  • ARCHIVE (Read from a specific date in the archive)
  • LIVE (Read from the live stage)
  • DRAFT (Read from the draft stage)
  • LATEST (Read the latest version from each record)
  • STATUS (Filter records by their status. Must supply a `Status` parameter)
ArchiveDate The date, in YYYY-MM-DD format to use when in ARCHIVE mode.
[Status]

A list of statuses that records must match. Options:

  • PUBLISHED (Include published records)
  • DRAFT (Include draft records)
  • MODIFIED (Include records that have draft changes)
  • ARCHIVED (Include records that have been deleted from stage)

GraphQL

readBlogPosts(Versioning: {
  Mode: "DRAFT"
}) {
  Title
}

readBlogPosts(Versioning: {
  Mode: "ARCHIVE",
  ArchiveDate: "2016-11-08"
}) {
  Title
}

readBlogPosts(Versioning: {
  Mode: "STATUS",
  Status: [DRAFT, MODIFIED]
}) {
  Title
}

readOne operations also allow a Version parameter, which allows you to read a specific version.

GraphQL

readBlogPosts(Version: 5, ID: 100) {
  Title
}

Version-specific fields

Types that use the Versioned extension will also benefit from two new fields:

  • Version:Int The version number of the record
  • Versions:[<MyTypeName>Version] A paginated list of all the previous versions of this record. The type returned by this query contains a few additional fields: Author:Member, Publisher:Member, and Published:Boolean.

GraphQL

readBlogPosts {
  Title
  Version
  Versions(limit: 5) {
    Title
    Author {
      FirstName
    }
    Publisher {
      Email
    }
    Published
  }
}

Define interfaces

TODO

Define input types

TODO

Extending

Many of the scaffolding classes use the Extensible trait, allowing you to influence the scaffolding process with custom needs.

Adding/removing fields from thirdparty code

Suppose you have a module that adds new fields to dataobjects that use your extension. You can write and extension for DataObjectScaffolder to update the scaffolding before it is sent to the Manager.

class MyDataObjectScaffolderExtension extends Extension
{

  public function onBeforeAddToManager(Manager $manager)
  {
    if ($this->owner->getDataObjectInstance()->hasExtension(MyExtension::class)) {
      $this->owner->addField('MyField');
    }
  }
}

Updating the core operations

The basic CRUD operations that come with the module are all extensible withupdateArgs and augmentMutation (or updateList for read operations).

class MyCreateExtension extends Extension
{

  public function updateArgs(&$args, Manager $manager)
  {
    $args['SendEmail'] = ['type' => Type::bool()];
  }


  public function augmentMutation($object, array $args, $context, ResolveInfo $info)
  {
    if ($args['SendEmail']) {
      MyService::inst()->sendEmail();
      $object->EmailSent = true;
    }
  }
}

Adding new operations

If you have a custom operation, in addition to read, update, delete, etc., that you want available to some or all types, you can write one and register it with the scaffolder. Let's suppose we have an ecommerce module that wants to offer an addToCart mutation to any dataobject that implements the Product interface.

class AddToCartOperation extends MutationScaffolder
{

   public function __construct($dataObjectClass)
   {
      parent::__construct($this->createOperationName(), $dataObjectClass);
      if (!$this->getDataObjectInstance() instanceof ProductInterace) {
        throw new InvalidArgumentException(
            'addToCart operation is only for implementors of ProductInterface'
        );
      }
      $this->setResolver(function($object, array $args, $context, ResolveInfo $info) {
        $record = DataObject::get_by_id($this->dataObjectClass, $args['ID']);
        if (!$record) {
          throw new Exception('ID not found');
        }

        $record->addToCart();

        return $record;
      });
   }


   protected function createArgs(Manager $manager)
   {
      return [
        'ID' => ['type' => Type::nonNull(Type::id())]
      ];
   }


   protected function createOperationName()
   {
        return 'add' . ucfirst($this->typeName()) . 'ToCart';
   }

}

Now, register it as an opt-in operation.

SilverStripe\GraphQL\Scaffolding\Scaffolders\OperationScaffolder:
  operations:
    addToCart: My\Project\AddToCartOperation

Changing behaviour with Middleware

You can influence behaviour based on query data or passed-in parameters. This can be useful for features which would be too cumbersome on a resolver level, for example logging and caching.

Note this is separate from HTTP Middleware which are more suited to influence behaviour based on HTTP request introspection (for example, by enforcing authentication for any request matching /graphql).

Example middleware to log all mutations (but not queries):

<?php
use GraphQL\Type\Schema;
use SilverStripe\GraphQL\Middleware\QueryMiddleware;

class MyMutationLoggingMiddleware implements QueryMiddleware
{
    public function process(Schema $schema, $query, $context, $params, callable $next)
    {
        if (preg_match('/^mutation/', $query)) {
            $this->log('Executed mutation: ' . $query);
        }

        return $next($schema, $query, $context, $params);
    }

    protected function log($str)
    {
        // ...
    }
}
SilverStripe\Core\Injector\Injector:
  SilverStripe\GraphQL\Manager:
    properties:
      Middlewares:
        MyMutationLoggingMiddleware: '%$MyMutationLoggingMiddleware'

If you want to use middleware to cache responses, here's a more comprehensive caching middleware example.

Testing/debugging queries and mutations

An in-browser IDE for the GraphQL server is available via the silverstripe-graphql-devtools module.

As an alternative, a desktop version of this application is also available. (OSX only)

Authentication

Some SilverStripe resources have permission requirements to perform CRUD operations on, for example the Member object in the previous examples.

If you are logged into the CMS and performing a request from the same session then the same Member session is used to authenticate GraphQL requests, however if you are performing requests from an anonymous/external application you may need to authenticate before you can complete a request.

Please note that when implementing GraphQL resources it is the developer's responsibility to ensure that permission checks are implemented wherever resources are accessed.

Default authentication

The MemberAuthenticator class is configured as the default option for authentication, and will attempt to use the current CMS Member session for authentication context.

HTTP basic authentication

Silverstripe has built in support for HTTP basic authentication. There is a BasicAuthAuthenticator which is configured for GraphQL by default, but will only activate when required. It is kept separate from the SilverStripe CMS authenticator because GraphQL needs to use the successfully authenticated member for CMS permission filtering, whereas the global BasicAuth does not log the member in or use it for model security.

In GraphiQL

If you want to add basic authentication support to your GraphQL requests you can do so by adding a custom Authorization HTTP header to your GraphiQL requests.

If you are using the GraphiQL macOS app this can be done from "Edit HTTP Headers". The /dev/graphiql implementation does not support custom HTTP headers at this point.

Your custom header should follow the following format:

# Key: Value
Authorization: Basic aGVsbG86d29ybGQ=

Basic is followed by a base64 encoded combination of your username, colon and password. The above example is hello:world.

Note: Authentication credentials are transferred in plain text when using HTTP basic authentication. We strongly recommend using TLS for non-development use.

Example:

php -r 'echo base64_encode("hello:world");'
# aGVsbG86d29ybGQ=

Defining your own authenticators

You will need to define the class under SilverStripe\GraphQL\Auth\Handlers.authenticators. You can optionally provide a priority number if you want to control which Authenticator is used when multiple are defined (higher priority returns first).

Authenticator classes will need to implement the SilverStripe\GraphQL\Auth\AuthenticatorInterface interface, which requires you to define an authenticate method to return a Member, or false, and and isApplicable method which tells the Handler whether or not this authentication method is applicable in the current request context (provided as an argument).

Here's an example for implementing HTTP basic authentication (note that basic auth is enabled by default anyway):

SilverStripe\GraphQL\Auth\Handler:
  authenticators:
    - class: SilverStripe\GraphQL\Auth\BasicAuthAuthenticator
      priority: 10

CSRF tokens (required for mutations)

Even if your graphql endpoints are behind authentication, it is still possible for unauthorised users to access that endpoint through a CSRF exploitation. This involves forcing an already authenticated user to access an HTTP resource unknowingly (e.g. through a fake image), thereby hijacking the user's session.

In the absence of a token-based authentication system, like OAuth, the best countermeasure to this is the use of a CSRF token for any requests that destroy or mutate data.

By default, this module comes with a CSRFMiddleware implementation that forces all mutations to check for the presence of a CSRF token in the request. That token must be applied to a header named X-CSRF-TOKEN.

In SilverStripe, CSRF tokens are most commonly stored in the session as SecurityID, or accessed through the SecurityToken API, using SecurityToken::inst()->getValue().

Queries do not require CSRF tokens.

  SilverStripe\GraphQL\Manager:
    properties:
      Middlewares:
        CSRFMiddleware: false

Cross-Origin Resource Sharing (CORS)

By default CORS is disabled in the GraphQL Server. This can be easily enabled via YAML:

SilverStripe\GraphQL\Controller:
  cors:
    Enabled: true

Once you have enabled CORS you can then control four new headers in the HTTP Response.

  1. Access-Control-Allow-Origin.

This lets you define which domains are allowed to access your GraphQL API. There are 4 options:

  • Blank: Deny all domains (except localhost)
Allow-Origin:
  • '*': Allow requests from all domains.
Allow-Origin: '*'
  • Single Domain:

Allow requests from one specific external domain.

Allow-Origin: 'my.domain.com'
  • Multiple Domains:

Allow requests from multiple specified external domains.

Allow-Origin:
  - 'my.domain.com'
  - 'your.domain.org'
  1. Access-Control-Allow-Headers.

Access-Control-Allow-Headers is part of a CORS 'pre-flight' request to identify what headers a CORS request may include. By default, the GraphQL server enables the Authorization and Content-Type headers. You can add extra allowed headers that your GraphQL may need by adding them here. For example:

Allow-Headers: 'Authorization, Content-Type, Content-Language'

Note If you add extra headers to your GraphQL server, you will need to write a custom resolver function to handle the response.

  1. Access-Control-Allow-Methods.

This defines the HTTP request methods that the GraphQL server will handle. By default this is set to GET, PUT, OPTIONS. Again, if you need to support extra methods you will need to write a custom resolver to handle this. For example:

Allow-Methods: 'GET, PUT, DELETE, OPTIONS'
  1. Access-Control-Max-Age.

Sets the maximum cache age (in seconds) for the CORS pre-flight response. When the client makes a successful OPTIONS request, it will cache the response headers for this specified duration. If the time expires or the required headers are different for a new CORS request, the client will send a new OPTIONS pre-flight request to ensure it still has authorisation to make the request. This is set to 86400 seconds (24 hours) by default but can be changed in YAML as in this example:

Max-Age: 600
  1. Access-Control-Allow-Credentials.

When a request's credentials mode (Request.credentials) is "include", browsers will only expose the response to frontend JavaScript code if the Access-Control-Allow-Credentials value is true.

The Access-Control-Allow-Credentials header works in conjunction with the XMLHttpRequest.withCredentials property or with the credentials option in the Request() constructor of the Fetch API. For a CORS request with credentials, in order for browsers to expose the response to frontend JavaScript code, both the server (using the Access-Control-Allow-Credentials header) and the client (by setting the credentials mode for the XHR, Fetch, or Ajax request) must indicate that they’re opting in to including credentials.

This is set to empty by default but can be changed in YAML as in this example:

Allow-Credentials: 'true'

Sample Custom CORS Config

## CORS Config
SilverStripe\GraphQL\Controller:
  cors:
    Enabled: true
    Allow-Origin: 'silverstripe.org'
    Allow-Headers: 'Authorization, Content-Type'
    Allow-Methods:  'GET, POST, OPTIONS'
    Allow-Credentials: 'true'
    Max-Age:  600  # 600 seconds = 10 minutes.

Persisting queries

A common pattern in GraphQL APIs is to store queries on the server by an identifier. This helps save on bandwidth, as the client need not put a fully expressed query in the request body, but rather a simple identifier. Also, it allows you to whitelist only specific query IDs, and block all other ad-hoc, potentially malicious queries, which adds an extra layer of security to your API, particularly if it's public.

To implement persisted queries, you need an implementation of the SilverStripe\GraphQL\PersistedQuery\PersistedQueryMappingProvider interface. By default, three are provided, which cover most use cases:

  • FileProvider: Store your queries in a flat JSON file on the local filesystem.
  • HTTPProvider: Store your queries on a remote server and reference a JSON file by URL.
  • JSONStringProvider: Store your queries as hardcoded JSON

Configuring query mapping providers

All of these implementations can be configured through Injector. Note that each schema gets its own set of persisted queries. In these examples, we're using the defaultschema.

FileProvider

SilverStripe\Core\Injector\Injector:
  SilverStripe\GraphQL\PersistedQuery\PersistedQueryMappingProvider:
    class: SilverStripe\GraphQL\PersistedQuery\FileProvider:
      properties:
       schemaMapping:
         default: '/var/www/project/query-mapping.json'

A flat file in the path /var/www/project/query-mapping.json should contain something like:

{"someUniqueID":"query{validateToken{Valid Message Code}}"}

HTTPProvider

SilverStripe\Core\Injector\Injector:
  SilverStripe\GraphQL\PersistedQuery\PersistedQueryMappingProvider:
    class: SilverStripe\GraphQL\PersistedQuery\HTTPProvider:
      properties:
       schemaMapping:
         default: 'http://example.com/myqueries.json'

A flat file at the URL http://example.com/myqueries.json should contain something like:

{"someUniqueID":"query{readMembers{Name+Email}}"}

JSONStringProvider

SilverStripe\Core\Injector\Injector:
  SilverStripe\GraphQL\PersistedQuery\PersistedQueryMappingProvider:
    class: SilverStripe\GraphQL\PersistedQuery\HTTPProvider:
      properties:
       schemaMapping:
         default: '{"myMutation":"mutation{createComment($comment:String!){Comment}}"}'

The queries are hardcoded into the configuration.

Requesting queries by identifier

To access a persisted query, simply pass an id parameter in the request in lieu of query.

GET http://example.com/graphql?id=someID

Note that if you pass query along with id, an exception will be thrown.

Schema introspection

Some GraphQL clients such as Apollo require some level of introspection into the schema. While introspection is part of the GraphQL spec, this module provides a limited API for fetching it via non-graphql endpoints. By default, the graphql/ controller provides a types action that will return the type schema (serialised as JSON) dynamically.

GET http://example.com/graphql/types

{  
   "data":{  
      "__schema":{  
         "types":[  
            {  
               "kind":"OBJECT",
               "name":"Query",
               "possibleTypes":null
            }
            // etc ...
         ]
      }
   }

As your schema grows, introspecting it dynamically may have a performance hit. Alternatively, if you have the silverstripe/assets module installed (as it is in the default SilverStripe installation), GraphQL can cache your schema as a flat file in the assets/ directory. To enable this, simply set the cache_types_in_filesystem setting to true on SilverStripe\GraphQL\Controller. Once enabled, a types.graphql file will be written to your assets/ directory on flush.

When cache_types_in_filesystem is enabled, it is recommended that you remove the extension that provides the dynamic introspection endpoint.

use SilverStripe\GraphQL\Controller;
use SilverStripe\GraphQL\Extensions\IntrospectionProvider;

Controller::remove_extension(IntrospectionProvider::class);

Setting up a new GraphQL schema

In addition to the default /graphql endpoint provided by this module by default, along with the admin/graphql endpoint provided by the CMS modules (if they're installed), you may want to set up another GraphQL server running on the same installation of SilverStripe.

First, set up a new Manager implementation for your custom server.

app/_config/graphql.yml

SilverStripe\Core\Injector\Injector:
  SilverStripe\GraphQL\Manager.myApp:
    class: SilverStripe\GraphQL\Manager
    constructor:
      schemaKey: mySchema

The schemaKey setting is a bit of meta-configuration used to tell the Manager where to look in the SilverStripe\GraphQL\Manager.schemas config for the schema information.

Now let's setup a new controller to handle the requests. It will use our custom Manager instance.

SilverStripe\Core\Injector\Injector:
  # ...
  SilverStripe\GraphQL\Controller.myApp:
    class: SilverStripe\GraphQL\Controller
    constructor:
      manager: %$SilverStripe\GraphQL\Manager.myApp

Lastly, we'll need to route the controller.

SilverStripe\Control\Director:
  rules:
    'my-graphql': '%$SilverStripe\GraphQL\Controller.myApp'

Now, configure your schema.

SilverStripe\GraphQL\Manager:
  schemas:
    myApp:
      # Your config will go here..

Strict HTTP Method Checking

According to GraphQL best practices, mutations should be done over POST, while queries have the option to use either GET or POST. By default, this module enforces the POST request method for all mutations.

To disable that requirement, you can remove the HTTPMethodMiddleware from your Manager implementation.

  SilverStripe\GraphQL\Manager:
    properties:
      Middlewares:
        HTTPMethodMiddleware: false

Strict HTTP Method Checking

According to GraphQL best practices, mutations should be done over POST, while queries have the option to use either GET or POST. By default, this module enforces the POST request method for all mutations.

To disable that requirement, you can remove the HTTPMethodMiddleware from your Manager implementation.

  SilverStripe\GraphQL\Manager:
    properties:
      Middlewares:
        HTTPMethodMiddleware: false

TODO

  • Permission checks
  • Input/constraint validation on mutations (with third-party validator)
  • CSRF protection (or token-based auth)
  • Create Enum GraphQL types from DBEnum
  • Date casting
  • Schema serialisation/caching (performance)
  • Scaffolding description, deprecation attributes
  • Remove operations/fields via YAML
  • Refine CRUD operations

About

Serves SilverStripe data as GraphQL representations

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages

  • PHP 100.0%