🚀 Improving GraphQL - How the PoP API achieves new heights
— 30 minute read
I had originally started implementing the GraphQL spec using server-side components, providing yet another implementation of GraphQL on PHP (such as those based on the popular graphql-php library), but attempting to also support those features that GraphQL fails at supporting, such as HTTP caching.
I can now claim my attempt was a success: The implementation satisfies the GraphQL spec (except for the syntax... more on this below), adding native support for HTTP caching was straightforward, and I could even add new features that the typical GraphQL implementation out there does not support.
So, I'd say this is a good time to introduce this API to the world, and hope that the world will notice it: Please be introduced to the brand-new PoP API, an iteration and improvement over GraphQL.
Because it is generated from code, different schemas can be created for different use cases, from a single source of truth. And the schema is natively decentralized or federated, enabling different teams to operate on their own source code.
To visualize it, we use the standard introspection field __schema:
All operators and functions provided by the language (PHP) can be made available as standard fields, and any custom “helper” functionality can be easily implemented too:
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
To tell if a field argument must be considered a field or a string, if it contains () it is a field, otherwise it is a string (eg: posts() is a field, and posts is a string)
Nested fields with operators and helpers permalink
Operators and helpers are standard fields, so they can be employed for nested fields. This makes available composable elements to the query, which removes the need to implement custom code in the resolvers, or to fetch raw data that is then processed in the application in the client-side. Instead, logic can be provided in the query itself.
/? format=Y-m-d& query= posts. if ( has-comments(), sprintf( "This post has %s comment(s) and title '%s'", [ comments-count(), title() ] ), sprintf( "This post was created on %s and has no comments", [ date(format:if(not(empty($format)), $format, d/m/Y)) ] ) )@postDesc
This solves one issue with GraphQL: That it transfers the REST way of creating multiple endpoints to satisfy different needs (such as /posts-1st-format/ and /posts-2nd-format/) into the data model. For instance, exploring the live demo to demonstrate GraphiQL with the DevTools' network tab, we see that the schema contains fields fileName_not, fileName_in, fileName_not_in, etc:
Directives can be nested, unlimited levels deep, enabling to create complex logic such as iterating over array elements and applying a function on them, changing the context under which a directive must execute, and others.
In the example below, directive <forEach> iterates all the elements from an array, and passes each of them to directive <applyFunction> which executes field arrayJoin on them:
An expression, defined through symbols %...%, is a variable used by directives to pass values to each other. An expression can be pre-defined by the directive or created on-the-fly in the query itself.
In the example below, an array contains strings to translate and the language to translate the string to. The array element is passed from directive <forEach> to directive <advancePointerInArray> through pre-defined expression %value%, and the language code is passed from directive <advancePointerInArray> to directive <translate> through variable %toLang%, which is defined only in the query:
/?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 ) > > >
Cache the response from the query using standard HTTP caching.
The response will contain Cache-Control header with the max-age value set at the time (in seconds) to cache the request, or no-store if the request must not be cached. Each field in the schema can configure its own max-age value, and the response's max-age is calculated as the lowest max-age among all requested fields (including nested fields).
//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
Fields can be made available only if user is logged-in, or has a specific role. When the validation fails, the schema can be set, by configuration, to either show an error message or hide the field, as to behave in public or private mode, depending on the user.
For instance, the following query will give an error message, since you, dear reader, are not logged-in:
Directives receive all their affected objects and fields together, for a single execution.
In the examples below, the Google Translate API is called the minimum possible amount of times to execute multiple translations:
// 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
Example calling the Google Translate API from the back-end, as coded within directive <translate>:
//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, performing all required logic in a single query permalink
The last query from the examples below accesses, extracts and manipulates data from an external API, and then uses this result to accesse yet another external API:
//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
Query sections of any size and shape can be stored in the server. It is like the persisted queries mechanism provided by GraphQL, but more granular: different persisted fragments can be added to the query, or a single fragment can itself be the query.
The example below demonstrates, once again, the same logic from the example above, but coded and stored as persisted fields:
// 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