We Switched To GraphQL. This Is What We’ve Learned
Introduction
There are many data exchange specifications: XML, JSON, GraphQL, and so on.
At Aristek, we’ve been using and evolving the JSON:API specification for many years. But now we’re gradually moving to GraphQL. In this article, we’ll discuss our GraphQL experience, the difficulties we’ve met, and the instruments we’ve used.
The Background
JSON:API was developed by Yehuda Katz in May 2013. The idea was to eliminate the need for ad-hoc code per application to communicate with servers that communicate in a well-defined way.
{
"links": {
"self": "http://example.com/articles",
"next": "http://example.com/articles?page[offset]=2",
"last": "http://example.com/articles?page[offset]=10"
},
"data": [{
"type": "articles",
"id": "1",
"attributes": {
"title": "JSON:API paints my bikeshed!"
},
"relationships": {
"author": {
"links": {
"self": "http://example.com/articles/1/relationships/author",
"related": "http://example.com/articles/1/author"
},
"data": { "type": "people", "id": "9" }
},
"comments": {
"links": {
"self": "http://example.com/articles/1/relationships/comments",
"related": "http://example.com/articles/1/comments"
},
"data": [
{ "type": "comments", "id": "5" },
{ "type": "comments", "id": "12" }
]
}
},
"links": {
"self": "http://example.com/articles/1"
}
}],
"included": [{
"type": "people",
"id": "9",
"attributes": {
"firstName": "Dan",
"lastName": "Gebhardt",
"twitter": "dgeb"
},
"links": {
"self": "http://example.com/people/9"
}
}, {
"type": "comments",
"id": "5",
"attributes": {
"body": "First!"
},
"relationships": {
"author": {
"data": { "type": "people", "id": "2" }
}
},
"links": {
"self": "http://example.com/comments/5"
}
}, {
"type": "comments",
"id": "12",
"attributes": {
"body": "I like XML better"
},
"relationships": {
"author": {
"data": { "type": "people", "id": "9" }
}
},
"links": {
"self": "http://example.com/comments/12"
}
}]
}
Why GraphQL?
GraphQL was created in 2012 by Facebook (now Meta) for their mobile apps. Later, in 2015 GraphQL was open-sourced.
We started using GraphQL last year. The first thing we’ve noticed was the way GraphQL used the features of the HTTP protocol. It only used one URL address (typically “/graphql”) and only one HTTP method (typically POST). Meanwhile, JSON:API required using several HTTP methods and creating multiple addresses to access our recourses.
GraphQL’s flexibility gives us more options. For example, now we can create more flexible and convenient error reporting logic. Instead of using the limited list of pre-written HTTP replies, we can tailor the error system to our client’s business needs.
Here’s another difference. With GraphQL we can set up the response structure we want. But we can also adjust the query and data changing syntax.
In contrast to that, JSON:API has a rigid data structure. Unlike GraphQL, JSON:API has a set of required and optional fields. The query parameters are also prewritten.
{
"data": {
"articles": [
{
"id": 1,
"title": "Article title",
"comments": [
{
"id": 1,
"text": "Good article!!!"
}
],
"people": [
{
"id": 1,
"name": "Boris"
}
]
}
]
}
}
Another feature of GraphQL is that it supports type inheritance and multiple-type inheritance. You can use it instead of the 404 error from JSON:API. If the client app requests a Course resource that doesn’t exist, the server will return CourseNotFound. It will be the child object of Course and NotFound types. The client app can expect that the NotFound type can return, so it can request only the fields needed to display the error message.
These and many other differences lead us to use GraphQL for its simplicity and flexibility.
How Did We Begin With GraphQL?
For starters, we needed a library. We looked for one that would:
- Support GraphQL
- Have a large community
- Be highly customizable
- Be compatible with Symfony
After researching the libraries, we found out that the lowest level and the most popular one was “webonyx/graphql-php”. It’s perfectly customizable and compatible with most frameworks. The problem was that its basic functionality was very small.
This lead us to “api-platform/core”. Like “webonyx/graphql-php”, it was also customizable and had a good community that constantly improved the framework. Finally, it was compatible with Symfony.
API Platform
It takes a minute to install API Platform with GraphQL support. As the result, we got a fully functioning server that supported GraphQL.
API Platform supports many GraphQL features, but not all of them. Let’s take a look at the features that it supports out of the box.
Schemas & Types
GraphQL schema keeps the description of available queries, mutations, types, etc. Out of the box, API Platform automatically builds the schema based on the entities that are tagged with the attribute “#[Api\ApiResource]”. These can be:
- Public fields
- Get and set methods
- Special attributes
#[Api\ApiResource]
class User
{
public string $name;
private string $email;
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
}
type user implements Node {
email: String!
name: String!
}
This is where we met the current GraphQL limitations. Right now, API Platform can’t automatically create or work with Union and Interfaces types.
API Platform compares all the queries that have the schema. This lets us quickly find the ones with incorrect structure or unsupported data types.
We realized that we could use the schema for code testing. We wrote a command that would generate a human-readable schema and save it in the project. This way we could see how the schema reacted to the changes made by the developer. Even at the coding stage, we would see if the changes worked as planned. Also, at code review, the reviewer can right away see how the schema evolves. Now we have fewer errors when working with new instruments.
Queries & Mutations
In GraphQL, there’re only two ways to engage with the server. That is via queries and mutations. API Platform generates queries automatically for collections and individual items. It also generates mutations for CRUD operations. For example, this is what was automatically generated for our Course entity:
Query.courses
Query.course
Mutation.createCourse
Mutation.updateCourse
Mutation.deleteCourse
mutation {
createCourse(input: {
name: "PHP for beginners",
contentUnits: [{
type: quiz
free: true
}]
}) {
course {
id
title
contentUnits {
collection {
id
free
type
}
}
}
}
}
Customization Options
The first thing you run into when trying to customize queries and mutations is the Resolver. There are several stages to processing input data. Each stage can be customized. Below we have a diagram that describes the stages for each resolver type.
To make a custom query or mutation, you need to create a class that would implement one of the resolver interfaces. This will help to send logs when the resolver is called, or to change field inputs, etc.
Each stage is presented by a single service. This helps change the work so that it suits the customer’s needs.
Because we already had some modules written for JSON:API, we created mutations to reuse them. This way we could reuse our Security module that handled registration, login, user management, and ACL security.
One of the first issues we had was that we couldn’t rely on the HTTP response status anymore. Meanwhile, our current error system wasn’t unified enough.
To fix it, we developed our own error handlers that would change the structure and looks of the error messages. This way, it was much easier for the frontend team to differentiate between error types. It also helped figure out to which resource or action the errors belong. Our ErrorHandler would transform all errors by changing the error messages, codes, and traces.
final class ErrorHandler implements ErrorHandlerInterface
{
public function __construct(
private readonly ErrorHandlerInterface $defaultErrorHandler,
private readonly ValidationErrorFactory $validationErrorFactory,
) {
}
public function __invoke(array $errors, callable $formatter): array
{
$extractedErrors = array_reduce(
$errors,
fn(array $carry, Error $error) => [...$carry, ...$this->getExtractedErrors($error)],
[],
);
return ($this->defaultErrorHandler)($extractedErrors, $formatter);
}
/**
* @return Error[]
*/
private function getExtractedErrors(Error $error): array
{
$previousException = $error->getPrevious();
if (!$previousException instanceof ValidationException) {
return [$error];
}
return array_map(
fn(ConstraintViolationInterface $violation) => $this->validationErrorFactory->createByErrorAndViolation(
$error,
$violation,
),
iterator_to_array($previousException->getConstraintViolationList()),
);
}
}
Conclusion
GraphQL is great for flexibility. It is still new to us, but we’ll continue using it on our PHP projects. We’ll go on exploring API Platform to build the products that serve our clients.
I hope, our case can help you switch to GraphQL. Take care!