Iterating and improving GraphQL with PoP API

Leonardo Losoviz

GraphQL is awesome!

But...

No under/over fetching of data

Single endpoint

Query mirrors structure of components (React, Vue)

Easy for client-side developers!

GraphQL is terrible!

No HTTP/Server-side caching

Dependant on tooling (eg: GraphiQL)

Federation?

Difficult for back-end developers!

Why!?

GraphQL is the schema

β€œWith GraphQL, you model your business domain as a graph by defining a schema; within your schema, you define different types of nodes and how they connect/relate to one another."

type Starship {
  id: ID!
  name: String!
  length(unit: LengthUnit = METER): Float
}

GraphQL is the schema

πŸ‘

πŸ‘Ž

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, bureaucracy

Versioning, documentation, type validation

Verbose, limited, RESTy, reinvents the wheel

Incremental adoption

Non-comprehensive

GraphQL is the schema

The schema transfers the REST way of creating endpoints into the data model!

GraphQL is the schema

Let's stop for a moment, and think:

What is the problem here?

The schema? Or the SDL*?

*The GraphQL Schema Definition Language

GraphQL is the schema

The schema is a concept

The SDL is an implementation

type Starship {
  id: ID!
  name: String!
  length(unit: LengthUnit = METER): Float
}

This is schema

This is SDL

GraphQL is the schema

But the schema and the SDL should* be decoupled

Keep the schema

Ditch the SDL

*This is a standard GraphQL strategy: avoid defining implementations, only define concepts

πŸ‘©πŸ»β€πŸ”§

How can we improve GraphQL?

Introducing the PoP API

An iteration and improvement over GraphQL*, implemented without SDL

*Because the PoP API uses a modified syntax, it is not 100% compliant of the GraphQL spec

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:

Schema generation

Schema definitions for a type appear in code

type Human {
  name: String
  appearsIn: [Episode]
  starships: [Starship]
}

enum Episode {
  NEWHOPE
  EMPIRE
  JEDI
}

type Starship {
  name: String
}
class HumanFieldValueResolver implements FieldValueResolver
{
  const NEWHOPE = 'newhope';
  const EMPIRE = 'empire';
  const JEDI = 'jedi';

  public function resolveValue(FieldResolverInterface $fieldResolver, $resultItem, string $fieldName, array $fieldArgs = [])
  {
    // 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 null;
  }

  public function getQueryResolver(FieldResolverInterface $fieldResolver, string $fieldName, array $fieldArgs = []): ?string
  {
    switch ($fieldName) {
      case 'starship':
        // It returns the QueryResolver that handles a Starship
        return StarshipQueryResolver::class;
    }

    return null;
  }

  public function getSchema(FieldResolverInterface $fieldResolver, string $fieldName): ?string
  {
    switch ($fieldName) {
      case 'name':
        return [
          'type' => 'string',
        ];
      case 'appearsIn':
        return [
          'type' => 'enum',
          'values' => [
            self::NEWHOPE,
            self::EMPIRE,
            self::JEDI,
          ],
        ];
      case 'starship':
        return [
          'type' => 'id',
        ];
    }

    return null;
  }
}

class StarshipQueryResolver implements QueryResolver
{   
  public function resolveIDsFromQuery(array $query): array
  {
    // Given a query, make it return IDs
    $query['fields'] = 'ID';
    return getStarships($query);
  }

  public function resolveObjectsFromIDs(array $ids): array
  {
    // Given an array of IDs, return the corresponding objects
    return getStarshipsById($ids);
  }

  public function getId($starship)
  {
    // Return the ID of the object
    return $starship->ID;
  }

  public function getFieldValueResolver(): string
  {
    // Return the resolver to handle the fields in a Starship
    return StarshipFieldValueResolver::class;
  }
}

class StarshipFieldValueResolver implements FieldValueResolver
{
  public function resolveValue(FieldResolverInterface $fieldResolver, $resultItem, string $fieldName, array $fieldArgs = [])
  {
    $starship = $resultItem;
    switch ($fieldName) {
      case 'name':
        return $starship->name;
    }

    return null;
  }

  public function getSchema(FieldResolverInterface $fieldResolver, string $fieldName): ?string
  {
    switch ($fieldName) {
      case 'name':
        return [
          'type' => 'string',
        ];
    }

    return null;
  }
}

The overall schema is automatically generated by traversing the relationships among all types

Resolvers

Query Resolvers

Mutation Resolvers

Field Resolvers

Directive Resolvers

Field-Value Resolvers

Query Resolver

class PostQueryResolver extends AbstractQueryResolver
{
    public function resolveQueryIds($query): array
    {
        $query['fields'] = 'ids';
        return get_posts($query);
    }

    public function resolveQueryData($query)
    {
        return get_posts($query);
    }
}

Field Resolver

class PostFieldResolver extends AbstractFieldResolver
{
    public function getId($post)
    {
        return $post->ID;
    }

    public function getIdFieldQueryResolverClass()
    {
        return PostQueryResolver::class;
    }
}

Field-Value Resolver

class PostFieldValueResolver extends AbstractFieldValueResolver
{
    public static function getFieldNamesToResolve(): array
    {
        return [
            'posts',
        ];
    }

    public function getSchemaFieldType(FieldResolverInterface $fieldResolver, string $fieldName): ?string
    {
        $types = [
            'posts' => TypeCastingHelpers::combineTypes(SchemaDefinition::TYPE_ARRAY, SchemaDefinition::TYPE_ID),
        ];
        return $types[$fieldName];
    }

    public function getSchemaFieldDescription(FieldResolverInterface $fieldResolver, string $fieldName): ?string
    {
        $descriptions = [
            'posts' => __('IDs of the posts'),
        ];
        return $descriptions[$fieldName];
    }

    public function getSchemaFieldArgs(FieldResolverInterface $fieldResolver, string $fieldName): array
    {
        switch ($fieldName) {
            case 'posts':
                return [
                    [
                        'name' => 'limit',
                        'type' => 'int',
                        'description' => __('Limit how many posts are retrieved'),
                    ],
                    [
                        'name' => 'search',
                        'type' => 'string',
                        'description' => __('Retrieve results that contain this string'),
                    ]
                ];
        }
        
        return parent::getSchemaFieldArgs($fieldResolver, $fieldName);
    }

    public function resolveValue(FieldResolverInterface $fieldResolver, $resultItem, string $fieldName, array $fieldArgs = [])
    {
        switch ($fieldName) {
            case 'posts':
                $query = [
                    'fields' => 'ids',
                    'limit' => $fieldArgs['limit'],
                    'search' => $fieldArgs['search'],
                ];
                return get_posts($query);
        }

        return parent::resolveValue($fieldResolver, $resultItem, $fieldName, $fieldArgs);
    }

    public function resolveFieldDefaultQueryResolverClass(FieldResolverInterface $fieldResolver, string $fieldName, array $fieldArgs = []): ?string
    {
        switch ($fieldName) {
            case 'posts':
                return PostQueryResolver::class;
        }

        return parent::resolveFieldDefaultQueryResolverClass($fieldResolver, $fieldName, $fieldArgs);
    }
}

Directive Resolver

class SkipDirectiveResolver extends AbstractDirectiveResolver
{
    const DIRECTIVE_NAME = 'skip';
    
    public static function getDirectiveName(): string {
        return self::DIRECTIVE_NAME;
    }

    public function resolveDirective(DataloaderInterface $dataloader, FieldResolverInterface $fieldResolver, array &$resultIDItems, array &$idsDataFields, array &$dbItems, array &$previousDBItems, array &$variables, array &$messages, array &$dbErrors, array &$dbWarnings, array &$schemaErrors, array &$schemaWarnings, array &$schemaDeprecations)
    {
        // Check the condition field. If it is satisfied, then skip those fields
        $idsSatisfyingCondition = [];
        foreach (array_keys($idsDataFields) as $id) {
            // Validate directive args for the resultItem
            $expressions = $this->getExpressionsForResultItem($id, $variables, $messages);
            $resultItem = $resultIDItems[$id];
            list(
                $resultItemValidDirective,
                $resultItemDirectiveName,
                $resultItemDirectiveArgs
            ) = $this->dissectAndValidateDirectiveForResultItem($fieldResolver, $resultItem, $variables, $expressions, $dbErrors, $dbWarnings);
            // Check that the directive is valid. If it is not, $dbErrors will have the error already added
            if (is_null($resultItemValidDirective)) {
                continue;
            }
            // $resultItemDirectiveArgs has all the right directiveArgs values. Now we can evaluate on it
            if ($resultItemDirectiveArgs['if']) {
                $idsSatisfyingCondition[] = $id;
            }
        }
        foreach ($idsSatisfyingCondition as $id) {
            $idsDataFields[$id]['direct'] = [];
            $idsDataFields[$id]['conditional'] = [];
        }
    }
    public function getSchemaDirectiveDescription(FieldResolverInterface $fieldResolver): ?string
    {
        $translationAPI = TranslationAPIFacade::getInstance();
        return $translationAPI->__('Include the field value in the output only if the argument \'if\' evals to `false`', 'api');
    }
    public function getSchemaDirectiveArgs(FieldResolverInterface $fieldResolver): array
    {
        $translationAPI = TranslationAPIFacade::getInstance();
        return [
            [
                SchemaDefinition::ARGNAME_NAME => 'if',
                SchemaDefinition::ARGNAME_TYPE => SchemaDefinition::TYPE_BOOL,
                SchemaDefinition::ARGNAME_DESCRIPTION => $translationAPI->__('Argument that must evaluate to `false` to include the field value in the output', 'api'),
                SchemaDefinition::ARGNAME_MANDATORY => true,
            ],
        ];
    }
}

Mutation Resolver

Coming soon...

πŸš€

Let's explore the Features

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

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)>

Queries are URL-based

/?
query=
  field(
    args
  )@alias<
    directive(
      args
    )
  > 

Syntax: Easy to read and write as a URL param

Multi-line: Copy/pasting in Firefox works straight!

Queries are URL-based

/?
query=
  posts(
    limit: 5
  )@posts.
    id|
    date(format: d/m/Y)|
    title<
      skip(if: false)
    >
/?
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

Queries are URL-based

Dynamic schema

/?query=__schema

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.

/?
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)
    >

=

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

/?query=
  post(
    id: arrayItem(
      posts(
        limit: 1,
        order: date|DESC
      ), 
    0)
  )@latestPost.
    id|
    title|
    date

Nested 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)

...with Operators

No more RESTy data model

Composable elements: No need for custom code in resolvers

/?
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
/?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

/?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

/?query=
  echo([
    [banana, apple],
    [strawberry, grape, melon]
  ])@fruitJoin<
    forEach<
      applyFunction(
        function: arrayJoin,
        addArguments: [
          array: %value%,
          separator: "---"
        ]
      )
    >
  >

Nested 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

/?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

//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

//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...

/?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

// 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

// 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,
  id.
    post($postId)@post<
      copyRelationalResults(
        [content, date],
        [postContent, postDate]
      )
    >|
    id.
      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)
                ]
              )
            ]
          )
        >
      >|
      id.
        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
              )
            >
          >
        >|
        id.
          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

Main repo

Many repos

MIT license

Open source code

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/engine-wp": "dev-master",
  "getpop/comments-wp": "dev-master",
  "getpop/pages-wp": "dev-master",
  "getpop/posts-wp": "dev-master",
  "getpop/postmedia-wp": "dev-master",
  "getpop/taxonomies-wp": "dev-master",
  "getpop/users-wp": "dev-master",
  "getpop/api-graphql": "dev-master",
  "getpop/api-rest": "dev-master"
}

1. Add to composer.json

2. Run composer update

3. Run composer install

It relies on Composer for installation

⏳

Is it ready yet?

Project status

Code: stable for DEV, not yet for PROD

Documentation: not much ready, and not well organized (it is all spread in the many repos' READMEs, there is no devoted site yet)

Test units: like, pretty much nothing... (well, you know, there has been only 1 person working on this project to date...)

Project status

So it is not fully ready yet...

...but it is getting there!

...and with a little bit of help, it will get there faster... Contributors are welcome! πŸ˜‰

If you like it, share it with your friends and colleagues

Spread the word!

πŸ™

Let's make it ready for PROD!

β›ΉπŸ»β€β™€οΈ

It's time to go play!

Thanks!

πŸ‘‹

Leonardo Losoviz