Skip to content
Convert the PoP application into an all-purpose API
PHP
Branch: master
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
config
src
.editorconfig
.gitattributes
.gitignore
.scrutinizer.yml
.styleci.yml
.travis.yml
CHANGELOG.md
CODE_OF_CONDUCT.md
CONTRIBUTING.md
ISSUE_TEMPLATE.md
LICENSE.md
PULL_REQUEST_TEMPLATE.md
README.md
composer.json
initialize.php
phpcs.xml.dist
phpunit.xml.dist

README.md

PoP API

Convert the application into a powerful API. Install the GraphQL API package to convert it into a GraphQL server, and the REST API package to enable adding REST endpoints.

Install

Installing a fully-working API:

Follow the instructions under Bootstrap a PoP API for WordPress (currently, the API is available for WordPress only).

Installing this library:

Via Composer

$ composer require getpop/api dev-master

Note: Your composer.json file must have the configuration below to accept minimum stability "dev" (there are no releases for PoP yet, and the code is installed directly from the master branch):

{
    ...
    "minimum-stability": "dev",
    "prefer-stable": true,
    ...
}

Enable pretty permalinks

Add the following code in the .htaccess file to enable API endpoint /api/:

<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /

# Rewrite from /some-url/api/ to /some-url/?scheme=api
RewriteCond %{SCRIPT_FILENAME} !-d
RewriteCond %{SCRIPT_FILENAME} !-f
RewriteRule ^(.*)/api/?$ /$1/?scheme=api [L,P,QSA]

# Rewrite from api/ to /?scheme=api
RewriteCond %{SCRIPT_FILENAME} !-d
RewriteCond %{SCRIPT_FILENAME} !-f
RewriteRule ^api/?$ /?scheme=api [L,P,QSA]
</IfModule>

Note:
Add similar code to add corresponding API endpoints /api/graphql/, /api/rest, /api/xml, etc

Usage

Note:
To enable GraphQL and/or REST endpoints, the corresponding package must be installed: GraphQL package, REST package

  1. Transform any URL into an API endpoint by adding:

    .../api/ (PoP native format)
    .../api/graphql/ (GraphQL)
    .../api/rest/ (REST)

  2. Add your query under URL parameter query, following this syntax

Features

Note:
The features are described from under the perspective of the GraphQL package.

Queries are URL-based

Structure of the request:

/?query=query&variable=value&fragment=fragmentQuery

Structure of the query:

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

This syntax:

  • 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

This syntax can be expressed in multiple lines:

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

Advantages:

  • It is easy to read and write as a URL param (it doesn't make use of { and } like GraphQL)
  • Copy/pasting in Firefox works straight!

Example:

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

View query results

The syntax has the following elements:

  • (key:value) : Arguments
  • [key:value] or [value] : Array
  • $ : Variable
  • @ : Alias
  • . : Advance relationship
  • | : Fetch multiple fields
  • <...> : Directive
  • -- : Fragment

Example:

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

View query results

Dynamic schema

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:

/?query=__schema

View query results

Skip argument names

Field and directive argument names can be deduced from the schema.

This query...

// Query 1
/?
postId=1&
query=
  post($postId).
    date(d/m/Y)|
    title<
      skip(false)
    >

...is equivalent to this query:

// Query 2
/?
postId=1&
query=
  post(id:$postId).
    date(format:d/m/Y)|
    title<
      skip(if:false)
    >

View query results #1

View query results #2

Operators and Helpers

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

View query results #1

View query results #2

View query results #3

View query results #4

View query results #5

View query results #6

View query results #7

View query results #8

Nested fields

The value from a field can be the input to another field, and there is no limit how many levels deep it can be.

In the example below, field post is injected, in its field argument id, the value from field arrayItem applied to field posts:

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

View query results

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

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

View query results

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:

GraphiQL “RESTy” data model

Nested fields in directive arguments

Through nested fields, the directive can be evaluated against the object, granting it a dynamic behavior.

The example below implements the standard GraphQL skip directive, however it is able to decide if to skip the field or not based on a condition from the object itself:

/?query=
  posts.
    title|
    featuredimage<
      skip(if:isNull(featuredimage()))
    >.
      src

View query results

Skip output if null

Exactly the same result above (<skip(if(isNull(...)))>) can be accomplished using the ? operator: Adding it after a field, it skips the output of its value if it is null.

/?query=
  posts.
    title|
    featuredimage?.
      src

View query results

Nested directives

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:

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

View query results

Directive expressions

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

View query results

HTTP Caching

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

Examples:

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

View query results #1

View query results #2

View query results #3

View query results #4

View query results #5

View query results #6

Many resolvers per field

Fields can be satisfied by many resolvers.

In the example below, field excerpt does not normally support field arg length, however a new resolver adds support for this field arg, and it is enabled by passing field arg branch:experimental:

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

View query results #1

View query results #2

View query results #3

Advantages:

  • The data model can be customized for client/project
  • Teams become autonoumous, and can avoid the bureaucracy of communicating/planning/coordinating changes to the schema
  • Rapid iteration, such as allowing a selected group of testers to try out new features in production
  • Quick bug fixing, such as fixing a bug specifically for a client, without worrying about breaking changes for other clients
  • Field-based versioning

Validate user state/roles

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:

/?query=
  me.
    name

View query results

Linear time complexity to resolve queries (O(n), where n is #types)

The “N+1 problem” is completely avoided already by architectural design. It doesn't matter how many levels deep the graph is, it will resolve fast.

Example of a deeply-nested query:

/?query=
  posts.
     author.
       posts.
         comments.
           author.
             id|
             name|
             posts.
               id|
               title|
               url|
               tags.
                 id|
                 slug

View query results

Efficient directive calls

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

View query results #1

View query results #2

Interact with APIs from the back-end

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

View query results #1

View query results #2

View query results #3

Interact with APIs from the client-side

Example accessing an external API from the query itself:

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

View query results

Interact with APIs, performing all required logic in a single query

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

View query results #1

View query results #2

View query results #3

Create your content or service mesh

The example below defines and accesses a list of all services required by the application:

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

View query results

One-graph ready

Use custom fields to expose your data and create a single, comprehensive, unified graph.

The example below implements the same logic as the case above, however coding the logic through fields (instead of through the 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

View query results #1

View query results #2

View query results #3

View query results #4

Persisted fragments

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

View query results #1

View query results #2

View query results #3

View query results #4

Combine with REST

Get the best from both GraphQL and REST: query resources based on endpoint, with no under/overfetching.

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

View query results #1

View query results #2

Output in many formats

Replace "/graphql" from the URL to output the data in a different format: XML or as properties, or any custom one (implementation takes very few lines of code).

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

View query results #1

View query results #2

Normalize data for client

Just by removing the "/graphql" bit from the URL, the response is normalized, making its output size greatly reduced when a same field is fetched multiple times.

/api/?query=
  posts.
     author.
       posts.
         comments.
           author.
             id|
             name|
             posts.
               id|
               title|
               url

Compare the output of the query in PoP native format:

View query results

...with the same output in GraphQL format:

View query results

Handle issues by severity

Issues are handled differently depending on their severity:

  • Informative, such as Deprecated fields and directives: to indicate they must be replaced with a substitute
  • Non-blocking issues, such as Schema/Database warnings: when an issue happens on a non-mandatory field
  • Blocking issues, such as Query/Schema/Database errors: when they use a wrong syntax, declare non-existing fields or directives, or produce an 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
    )

View query results #1

View query results #2

View query results #3

View query results #4

View query results #5

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=
  posts(limit:3.5).
    title

View query results

Issues bubble upwards

If a field or directive fails and it is input to another field, this one may also fail.

/?query=
  post(divide(a,4)).
    title

View query results

Path to the issue

Issues contain the path to the nested field or directive were it was produced.

/?query=
  echo([hola,chau])<
    forEach<
      translate(notexisting:prop)
    >
  >

View query results

Log information

Any informative piece of information can be logged (enabled/disabled through configuration).

/?
actions[]=show-logs&
postId=1&
query=
  post($postId).
    title|
    date(d/m/Y)

View query results

Features coming next

Mutations

The query will be able to place mutations anywhere (not only on the root) and these will be integrated to the graph: The mutation result can, itself, be input to another field, be added to a nested subquery, and so on.

/?query=
  addPost($title, $content).
    addComment($comment1)|
    addComment($comment2).
      author<sendConfirmationByEmail>.
        followers<notifyByEmail, notifyBySlack>

Example using the API

Use case to implement:

Create an automated email-sending service using data from 3 sources:

  1. A REST API to fetch the recipients (list of rows with columns email and lang)
  2. A REST API to fetch client data (a list of rows with columns email and name)
  3. Blog posts published in your website

The email sent to the recipient must be customized:

  1. Greeting the person by name
  2. Translating the blog post's content to the user's preferred language

Solution:

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

View query results

Step-by-step description of the solution:

leoloso.com/posts/demonstrating-pop-api-graphql-on-steroids/

Change log

Please see CHANGELOG for more information on what has changed recently.

Testing

$ composer test

Contributing

Please see CONTRIBUTING and CODE_OF_CONDUCT for details.

Security

If you discover any security related issues, please email leo@getpop.org instead of using the issue tracker.

Credits

License

The MIT License (MIT). Please see License File for more information.

You can’t perform that action at this time.