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.
Require the composer package in your composer.json
composer require silverstripe/graphql
- Usage
- Examples
- Configuration
- Scaffolding DataObjects into the schema
- Our example
- Scaffolding through the config layer
- Scaffolding through procedural code
- Exposing a DataObject to GraphQL
- Available operations
- Scaffolding search params
- Setting field and operation descriptions
- Setting field descriptions
- Wildcarding and whitelisting fields
- Adding arguments
- Argument definition shorthand
- Adding more definition to arguments
- Using a custom resolver
- Configuring pagination and sorting
- Adding related objects
- Adding arbitrary queries and mutations
- Dealing with inheritance
- Querying types that have descendants
- Customising the names of types and operations
- Versioned content
- Define interfaces
- Define input types
- Extending
- Testing/debugging queries and mutations
- Authentication
- CSRF tokens (required for mutations)
- Cross-Origin Resource Sharing (CORS)
- Persisting Queries
- Schema introspection
- Setting up a new GraphQL endpoint
- Strict HTTP Method Checking
- TODO
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'
Code examples can be found in the examples/
folder (built out from the
configuration docs below).
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.
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'
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
}
}
}
}
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
}
}
}
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 maximumlimit
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
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
}
}
}
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 areFilter
andExclude
. If you want to use different names, usesetFilterKey()
andsetExcludeKey()
.
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;
}
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 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.
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 |
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
}
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.
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.
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
];
}
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
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
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.
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();
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.
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;
}
}
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()
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
}
}
}
}
You can make your scaffolding delcaration a bit more expressive by using argument shorthand.
String!
: A required stringInt!(50)
: A required integer with a default value of 50Boolean(0)
: A boolean defaulting to falseString
: An optional string, defaulting to null
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();
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();
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
}
}
}
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
}
}
}
}
}
}
}
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.
)
);
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
}
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
}
}
}
}
}
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
.
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:
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
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:
|
ArchiveDate | The date, in YYYY-MM-DD format to use when in ARCHIVE mode. |
[Status] |
A list of statuses that records must match. Options:
|
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
}
Types that use the Versioned
extension will also benefit from two new fields:
Version:Int
The version number of the recordVersions:[<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
, andPublished:Boolean
.
GraphQL
readBlogPosts {
Title
Version
Versions(limit: 5) {
Title
Author {
FirstName
}
Publisher {
Email
}
Published
}
}
TODO
TODO
Many of the scaffolding classes use the Extensible
trait, allowing you to influence the scaffolding process
with custom needs.
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');
}
}
}
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;
}
}
}
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
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.
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)
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.
The MemberAuthenticator
class is configured as the default option for authentication,
and will attempt to use the current CMS Member
session for authentication context.
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.
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=
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
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
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.
- 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'
- 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.
- 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'
- 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
- 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'
## 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.
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
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 default
schema.
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}}"}
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}}"}
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.
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.
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);
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..
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
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
- 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