GraphQL by PoP, a new GraphQL server in PHP
Leonardo Losoviz
GraphQL is great!
But...
No under/over fetching of data
Single endpoint
Query mirrors structure of components (React, Vue)
Easy for client-side developers!
GraphQL is still lacking!
No HTTP/Server-side caching
Dependant on tooling (eg: GraphiQL)
Federation?
Difficult for back-end developers!
General impressions
π
π
Declarative way to define the data model
Declarative way to duplicate the data model
Extensible: type, enum, interface, extend, directive, tooling
Extensible? single location, union, directive, tooling, bureaucratic
Versioning, documentation, type validation
Verbose, limited, RESTy, reinvents the wheel
Incremental adoption
Non-comprehensive
π©π»βπ§
How can we improve GraphQL?
Intro to GraphQL by PoP
An implementation of a GraphQL server in PHP
An implementation of an extended GraphQL spec, providing additional features
and...
Classic GraphQL
Extended GraphQL
π€
How does it work?
Components as architectural foundation
The relationships across entities in the data model are modeled NOT through a graph, but through components...
...but a graph results naturally from the component model
author
comments
author
post
Digging deeper
Detailed description of the architecture:
π
Let's explore the Features*
*βXTβ: it applies to Extended GraphQL.
Otherwise, it applies to both modes.
TL;DR: these features++
HTTP Caching
Federated/Decentralized
One-graph from multiple sources
Fast and efficient (no N+1 problem!)
Composable elements (no need for custom back-end coding)
Gateway: Integrate with APIs in back/front-end
100% spec compliant
No re-inventing anything
There is no creeping business logic
There is no creeping CMS logic (eg: authentication, CRUD operations, validations)
It works with standards (eg: HTTP caching)
Principled GraphQL
All ofΒ GraphQL's principles* are already part of the architecture
*A list of 10 best practices for creating, maintaining, and operating a data graph
Enables HTTP/Server-side caching
Simplifies visualization/execution of queries (straight in the browser, without any client)
GET when it's a GET, POST when it's a POST, pass variables through URL params
/?query=query&variable=value&fragment=fragmentQuery
/?query=field(args)@alias<directive(args)>
URL-based Queries
XT
/?
query=
field(
args
)@alias<
directive(
args
)
>
Syntax: Easy to read and write as a URL param
Multi-line: Copy/pasting in Firefox works straight!
/?
query=
posts(
limit: 5
)@posts.
id|
date(format: d/m/Y)|
title<
skip(if: false)
>
URL-based Queries
XT
/?
query=
posts(
ids: [1, 1499, 1178],
order: $order
)@posts.
id|
date(format: d/m/Y)|
title<
skip(if: false)
>|
--props&
order=title|ASC&
props=
url|
author.
name|
url
(key:value) : Arguments
[key:value] or [value] : Array
$ : Variable
@ : Alias
. : Advance relationship
| : Fetch multiple fields
<...> : Directive
-- : Fragment
URL-based Queries
XT
Dynamic schema
/?query=fullSchema
Because it is generated from code, different schemas can be created for different use cases, from a single source of truth
The schema is natively decentralized or federated, enabling different teams to operate on their own source code.
Schema generation
type Human {
name: String
appearsIn: [Episode]
starships: [Starship]
}
enum Episode {
NEWHOPE
EMPIRE
JEDI
}
type Starship {
name: String
}
class HumanTypeResolver extends AbstractTypeResolver
{
public function getTypeName(): string
{
return 'Human';
}
public function getSchemaTypeDescription(): ?string
{
return $this->translationAPI->__('Representation of a human');
}
public function getID($resultItem)
{
$human = $resultItem;
return $human->ID;
}
public function getTypeDataLoaderClass(): string
{
return HumanTypeDataLoader::class;
}
}
class HumanTypeDataLoader extends AbstractTypeQueryableDataLoader
{
public function getObjects(array $ids): array
{
$humanTypeAPI = HumanTypeAPIFacade::getInstance();
return $humanTypeAPI->getHumans(['include' => $ids]);
}
public function executeQuery($query, array $options = [])
{
$options['return-type'] = 'ids';
$humanTypeAPI = HumanTypeAPIFacade::getInstance();
return $humanTypeAPI->getHumans($query, $options);
}
}
class HumanFieldResolver extends AbstractDBDataFieldResolver
{
public const NEWHOPE = 'newhope';
public const EMPIRE = 'empire';
public const JEDI = 'jedi';
public static function getClassesToAttachTo(): array
{
return array(
HumanTypeResolver::class,
);
}
public static function getFieldNamesToResolve(): array
{
return [
'name',
'appearsIn',
'starship',
];
}
public function getSchemaFieldType(TypeResolverInterface $typeResolver, string $fieldName): ?string
{
$types = [
'name' => SchemaDefinition::TYPE_STRING,
'appearsIn' => SchemaDefinition::TYPE_ENUM,
'starship' => SchemaDefinition::TYPE_ID,
];
return $types[$fieldName] ?? parent::getSchemaFieldType($typeResolver, $fieldName);
}
public function addSchemaDefinitionForField(array &$schemaDefinition, TypeResolverInterface $typeResolver, string $fieldName): void
{
switch ($fieldName) {
case 'appearsIn':
$schemaDefinition[SchemaDefinition::ARGNAME_ENUMVALUES] = [
self::NEWHOPE,
self::EMPIRE,
self::JEDI,
];
break;
}
}
public function getSchemaFieldDescription(TypeResolverInterface $typeResolver, string $fieldName): ?string
{
$translationAPI = TranslationAPIFacade::getInstance();
$descriptions = [
'name' => $translationAPI->__('Name of the human'),
'appearsIn' => $translationAPI->__('In what film the human appears'),
'starship' => $translationAPI->__('Human\'s starship'),
];
return $descriptions[$fieldName] ?? parent::getSchemaFieldDescription($typeResolver, $fieldName);
}
public function resolveValue(TypeResolverInterface $typeResolver, $resultItem, string $fieldName, array $fieldArgs = [], ?array $variables = null, ?array $expressions = null, array $options = [])
{
// It receives a Human object
$human = $resultItem;
switch ($fieldName) {
case 'name':
return $human->name;
case 'appearsIn':
return $human->episode;
case 'starship':
// It returns a Starship ID, not an object!
return $human->starship;
}
return parent::resolveValue($typeResolver, $resultItem, $fieldName, $fieldArgs, $variables, $expressions, $options);
}
public function resolveFieldTypeResolverClass(TypeResolverInterface $typeResolver, string $fieldName, array $fieldArgs = []): ?string
{
switch ($fieldName) {
case 'starship':
return StarshipTypeResolver::class;
}
return parent::resolveFieldTypeResolverClass($typeResolver, $fieldName, $fieldArgs);
}
}
The schema is automatically generated by traversing the relationships among all types
Resolvers
Type Resolvers
Mutation Resolvers
Type DataLoaders
Directive Resolvers
Field Resolvers
/?
postId=1&
query=
post($postId).
date(d/m/Y)|
title<
skip(false)
>
Skip argument names
Have field and directive argument names deduced from the schema
/?
postId=1&
query=
post(id:$postId).
date(format:d/m/Y)|
title<
skip(if:false)
>
=
XT
1. /?query=not(true)
2. /?query=or([1,0])
3. /?query=and([1,0])
4. /?query=if(true, Show this text, Hide this text)
5. /?query=equals(first text, second text)
6. /?query=isNull(),isNull(something)
7. /?query=sprintf(%s API is %s, [PoP, cool])
8. /?query=context
Operators and Helpers
Availability of all operators and functions provided by the language (PHP) as fields
Can define any custom helper functionality
XT
/?query=
post(
id: arrayItem(
posts(
limit: 1,
order: date|DESC
),
0)
)@latestPost.
id|
title|
date
Composable fields...
The value from a field is the input to another one
No limit how many levels deep
If it contains (), it is a field (eg: posts() vs posts)
XT
...with Operators
No more RESTy data model
Composable elements: No need for custom code in resolvers
/?
format=Y-m-d&
query=
posts.
if (
hasComments(),
sprintf(
"This post has %s comment(s) and title '%s'", [
commentsCount(),
title()
]
),
sprintf(
"This post was created on %s and has no comments", [
date(format: if(not(empty($format)), $format, d/m/Y))
]
)
)@postDesc
XT
/?query=
posts.
title|
featuredImage<
skip(if:isNull(featuredImage()))
>.
src
...in directive arguments
The directive can be evaluated against the object, granting it a dynamic behavior
XT
/?
format=Y-m-d&
query=
posts.
sprintf(
"This post has %s comment(s) and title '%s'", [
comments-count(),
title()
]
)@postDesc<include(if:has-comments())>|
sprintf(
"This post was created on %s and has no comments", [
date(format: if(not(empty($format)), $format, d/m/Y))
]
)@postDesc<include(if:not(has-comments()))>
...in directive arguments
Retrieve data for an object only if it satisfies a certain condition, or customize its properties
XT
/?query=
posts.
title|
featuredImage?.
src
Skip output if null
Exactly same result as doing <skip(if(isNull(...)))>
? : Add after the field to skip its output if null
XT
/?query=
echo([
[banana, apple],
[strawberry, grape, melon]
])@fruitJoin<
forEach<
applyFunction(
function: arrayJoin,
addArguments: [
array: %value%,
separator: "---"
]
)
>
>
Composable directives
A directive can change/prepare the context for its nested directives
Unlimited levels deep
Directive <forEach> unpacks array items for its nested directive <applyFunction> to apply operator arrayJoin on each, then packs them into an array again
XT
/?query=
echo([
[
text: Hello my friends,
translateTo: fr
],
[
text: How do you like this software so far?,
translateTo: es
],
])@translated<
forEach<
advancePointerInArray(
path: text,
appendExpressions: [
toLang:extract(%value%,translateTo)
]
)<
translate(
from: en,
to: %toLang%,
oneLanguagePerField: true,
override: true
)
>
>
>
Directive expressions
%...% : Expression
Can be pre-defined (%value% in <forEach>)
Can define on-the-fly (%toLang%)
They are variables used by directives to communicate with each other
XT
//1. Operators have max-age 1 year
/?query=
echo(Hello world!)
//2. Most fields have max-age 1 hour
/?query=
echo(Hello world!)|
posts.
title
//3. Nested fields also supported
/?query=
echo(posts())
//4. "time" field has max-age 0
/?query=
time
//5. To not cache a response:
//a. Add field "time"
/?query=
time|
echo(Hello world!)|
posts.
title
//b. Add <cacheControl(maxAge:0)>
/?query=
echo(Hello world!)|
posts.
title<cacheControl(maxAge:0)>
HTTP Caching
Response contains Cache-Control header with max-age
Max-age values are configured field by field
Response max-age is the lowest max-age among all requested fields
//1. Standard behaviour
/?query=
posts.
excerpt
//2. New feature not yet available
/?query=
posts.
excerpt(length:30)
//3. New feature available under
// experimental branch
/?query=
posts.
excerpt(
length:30,
branch:experimental
)
Many resolvers per field
Customize data model for client/project
Autonomous teams, avoid bureaucracy
Rapid iteration, quick bug fixing
Field-based versioning
/?query=
me.
name
Validate user state/roles
Fields can be made available only if user is logged-in, or has a specific role
When validation fails, the schema can be set to either show an error or hide the field
The schema can be public/private at the same time, depending on the user
/?query=
posts.
author.
posts.
comments.
author.
id|
name|
posts.
id|
title|
url|
tags.
id|
slug
O(n) time complexity
To resolve the query graph (n: #nodes)
N+1 problem avoided by architectural design
Feasible to resolve deeply-nested graphs
// The Google Translate API is called once,
// containing 10 pieces of text to translate:
// 2 fields (title and excerpt) for 5 posts
/?query=
posts(limit:5).
--props|
--props@spanish<
translate(en,es)
>&
props=
title|
excerpt
// Here there are 3 calls to the API, one for
// every language (Spanish, French and German),
// 10 strings each, all calls are concurrent
/?query=
posts(limit:5).
--props|
--props@spanish<
translate(en,es)
>|
--props@french<
translate(en,fr)
>|
--props@german<
translate(en,de)
>&
props=
title|
excerpt
Efficient directive calls
Directives receive all their affected objects and fields together, for a single execution
//1. <translate> calls the Google Translate API
/?query=
posts(limit:5).
title|
title@spanish<
translate(en,es)
>
//2. Translate to Spanish and back to English
/?query=
posts(limit:5).
title|
title@translateAndBack<
translate(en,es),
translate(es,en)
>
//3. Change the provider through arguments
// (link gives error: Azure is not implemented)
/?query=
posts(limit:5).
title|
title@spanish<
translate(en,es,provider:azure)
>
Interact with APIs...
...from the back-end
/?query=
echo([
usd: [
bitcoin: extract(
getJSON("https://api.cryptonator.com/api/ticker/btc-usd"),
ticker.price
),
ethereum: extract(
getJSON("https://api.cryptonator.com/api/ticker/eth-usd"),
ticker.price
)
],
euro: [
bitcoin: extract(
getJSON("https://api.cryptonator.com/api/ticker/btc-eur"),
ticker.price
),
ethereum: extract(
getJSON("https://api.cryptonator.com/api/ticker/eth-eur"),
ticker.price
)
]
])@cryptoPrices
Interact with APIs...
...from the client-side
XT
//1. Get data from a REST endpoint
/?query=
getJSON("https://newapi.getpop.org/wp-json/newsletter/v1/subscriptions")@userEmailLangList
//2. Access and manipulate the data
/?query=
extract(
getJSON("https://newapi.getpop.org/wp-json/newsletter/v1/subscriptions"),
email
)@userEmailList
//3. Convert the data into an input to another system
/?query=
getJSON(
sprintf(
"https://newapi.getpop.org/users/api/rest/?query=name|email%26emails[]=%s",
[arrayJoin(
extract(
getJSON("https://newapi.getpop.org/wp-json/newsletter/v1/subscriptions"),
email
),
"%26emails[]="
)]
)
)@userNameEmailList
A single query can perform all required logic
Interact with APIs...
XT
/?query=
echo([
github: "https://api.github.com/repos/leoloso/PoP",
weather: "https://api.weather.gov/zones/forecast/MOZ028/forecast",
photos: "https://picsum.photos/v2/list"
])@meshServices|
getAsyncJSON(getSelfProp(%self%, meshServices))@meshServiceData|
echo([
weatherForecast: extract(
getSelfProp(%self%, meshServiceData),
weather.periods
),
photoGalleryURLs: extract(
getSelfProp(%self%, meshServiceData),
photos.url
),
githubMeta: echo([
description: extract(
getSelfProp(%self%, meshServiceData),
github.description
),
starCount: extract(
getSelfProp(%self%, meshServiceData),
github.stargazers_count
)
])
])@contentMesh
Create your content or service mesh
All services in one query
XT
// 1. Inspect services
/?query=
meshServices
// 2. Retrieve data
/?query=
meshServiceData
// 3. Process data
/?query=
contentMesh
// 4. Customize data
/?query=
contentMesh(
githubRepo: "getpop/api-graphql",
weatherZone: AKZ017,
photoPage: 3
)@contentMesh
Use custom fields to expose your data and create a single, comprehensive, unified graph
One-graph ready
// 1. Save services
/?query=
--meshServices
// 2. Retrieve data
/?query=
--meshServiceData
// 3. Process data
/?query=
--contentMesh
// 4. Customize data
/?
githubRepo=getpop/api-graphql&
weatherZone=AKZ017&
photoPage=3&
query=
--contentMesh
Query sections of any size and shape can be stored in the server
Persisted fragments
// 1. Access persisted query
/?query=
!contentMesh
// 2. Customize it with variables
/?
githubRepo=getpop/api-graphql&
weatherZone=AKZ017&
photoPage=3&
query=
!contentMesh
Queries can be stored in the server, allowing to disable the endpoint to increase the security
Persisted queries
! : To indicate it is a query name, not a query
Automatically namespace types and interfaces
Namespaces
Normal schema
Namespaced schema
Independent versioning for fields and directives, complementary to schema evolution
Field/directive versioning
Choose version using semantic versioning constraints, by field/directive argument
// Selecting version for fields
/?query=
userServiceURLs(versionConstraint:^0.1)|
userServiceURLs(versionConstraint:">0.1")|
userServiceURLs(versionConstraint:^0.2)
// Selecting version for directives
/?query=
post($postId).
title@titleCase<makeTitle(versionConstraint:^0.1)>|
title@upperCase<makeTitle(versionConstraint:^0.2)>
&postId=1
// Query data for a single resource
{single-post-url}/api/rest/?query=
id|
title|
author.
id|
name
// Query data for a set of resources
{post-list-url}/api/rest/?query=
id|
title|
author.
id|
name
Combine with REST
Get the best from both GraphQL and REST: query resources based on endpoint, with no under/overfetching
// Output as XML: Replace /graphql with /xml
/api/xml/?query=
posts.
id|
title|
author.
id|
name
// Output as props: Replace /graphql with /props
/api/props/?query=
posts.
id|
title|
excerpt
Output in many formats
Replace "/graphql" to output the data in a different format
/api/?query=
posts.
author.
posts.
comments.
author.
id|
name|
posts.
id|
title|
url
Normalize data for client
Reduced output size when a same field is fetched multiple times
Just remove the "/graphql" bit from the URL
//1. Deprecated fields
/?query=
posts.
title|
published
Handle issues by severity
Deprecated fields/directives (informative): To be replaced
Schema/Database warnings (non-blocking): issues on non-mandatory arguments
Query/Schema/Database errors (blocking): Use a wrong syntax, non-existing fields/directives, issues on mandatory arguments
//1. Deprecated fields
/?query=
posts.
title|
published
//2. Schema warning
/?query=
posts(limit:3.5).
title
//3. Database warning
/?query=
users.
posts(limit:name()).
title
//4. Query error
/?query=
posts.
id[book](key:value)
//5. Schema error
/?query=
posts.
non-existant-field|
is-status(
status:non-existant-value
)
//1. Deprecated fields
/?query=
posts.
title|
published
//2. Schema warning
/?query=
posts(limit:3.5).
title
//3. Database warning
/?query=
users.
posts(limit:name()).
title
/?query=
posts(limit:3.5).
title
Type casting/validation
When an argument has its type declared in the schema, its inputs will be casted to the type
If the input and the type are incompatible, it ignores setting the input and throws a warning
/?query=
post(divide(a,4)).
title
Issues bubble upwards
If a field or directive fails and it is input to another field, this one may also fail
/?query=
echo([hola,chau])<
forEach<
translate(notexisting:prop)
>
>
Path to the issue
Issues contain the path to the nested field or directive were it was produced
/?
actions[]=show-logs&
postId=1&
query=
post($postId).
title|
date(d/m/Y)
Log information
ππ»ββοΈ
Let's implement some use case
TL;DR: problems considered difficult become easy to solve
Use case to implement
Create an automated email-sending service. Data comes from 3 sources:
1. REST API to fetch the recipients (email, lang)
2. REST API to fetch client data (email, name)
3. Blog posts published in your website
The email needs to be customized:
1. Greeting the person by name
Β
2. Translating the content to their language
Solution for use case
/?
postId=1&
query=
post($postId)@post.
content|
date(d/m/Y)@date,
getJSON("https://newapi.getpop.org/wp-json/newsletter/v1/subscriptions")@userList|
arrayUnique(
extract(
getSelfProp(%self%, userList),
lang
)
)@userLangs|
extract(
getSelfProp(%self%, userList),
email
)@userEmails|
arrayFill(
getJSON(
sprintf(
"https://newapi.getpop.org/users/api/rest/?query=name|email%26emails[]=%s",
[arrayJoin(
getSelfProp(%self%, userEmails),
"%26emails[]="
)]
)
),
getSelfProp(%self%, userList),
email
)@userData,
self.
post($postId)@post<
copyRelationalResults(
[content, date],
[postContent, postDate]
)
>|
self.
getSelfProp(%self%, postContent)@postContent<
translate(
from: en,
to: arrayDiff([
getSelfProp(%self%, userLangs),
[en]
])
),
renameProperty(postContent-en)
>|
getSelfProp(%self%, userData)@userPostData<
forEach<
applyFunction(
function: arrayAddItem(
array: [],
value: ""
),
addArguments: [
key: postContent,
array: %value%,
value: getSelfProp(
%self%,
sprintf(
postContent-%s,
[extract(%value%, lang)]
)
)
]
),
applyFunction(
function: arrayAddItem(
array: [],
value: ""
),
addArguments: [
key: header,
array: %value%,
value: sprintf(
string: "<p>Hi %s, we published this post on %s, enjoy!</p>",
values: [
extract(%value%, name),
getSelfProp(%self%, postDate)
]
)
]
)
>
>|
self.
getSelfProp(%self%, userPostData)@translatedUserPostProps<
forEach(
if: not(
equals(
extract(%value%, lang),
en
)
)
)<
advancePointerInArray(
path: header,
appendExpressions: [
toLang: extract(%value%, lang)
]
)<
translate(
from: en,
to: %toLang%,
oneLanguagePerField: true,
override: true
)
>
>
>|
self.
getSelfProp(%self%,translatedUserPostProps)@emails<
forEach<
applyFunction(
function: arrayAddItem(
array: [],
value: []
),
addArguments: [
key: content,
array: %value%,
value: concat([
extract(%value%, header),
extract(%value%, postContent)
])
]
),
applyFunction(
function: arrayAddItem(
array: [],
value: []
),
addArguments: [
key: to,
array: %value%,
value: extract(%value%, email)
]
),
applyFunction(
function: arrayAddItem(
array: [],
value: []
),
addArguments: [
key: subject,
array: %value%,
value: "PoP API example :)"
]
),
sendByEmail
>
>
Digging deeper
Step-by-step description of the solution:
π«
What's coming next
Mutations
Can concatenate operations, nest results, add feedback loops, keep querying data
/?query=
addPost($title, $content).
addComment($comment1)|
addComment($comment2).
author<sendConfirmationByEmail>.
followers<notifyByEmail, notifyBySlack>
Can be added anywhere on the query, not only on the root
π©π»βπ§
Installation details
Open source code
License: MIT
Many repos
Technology stack
Implemented in PHP
It is CMS-agnostic: It can work with any CMS or framework (WordPress, Symfony, Laravel, Joomla, Drupal)
So far implemented contracts for WordPress
Installing for WordPress
"require": {
"getpop/graphql": "dev-master",
"getpop/engine-wp": "dev-master",
"getpop/posts-wp": "dev-master",
"getpop/users-wp": "dev-master",
"getpop/comments-wp": "dev-master",
"getpop/pages-wp": "dev-master",
"getpop/postmedia-wp": "dev-master",
"getpop/taxonomies-wp": "dev-master"
}