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"
}

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, ready for PROD

Documentation: not much yet

Test units: pretty much nothing yet

With your involvement, version 1.0 can be released sooner

Become involved!

Monetary sponsors

πŸŽ…πŸΌ

πŸ‘©πŸΌβ€πŸ’»

Code/Unit tests contributors

πŸ‘¨πŸ»β€πŸ«

Documentation contributors

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

Spread the word!

πŸ™

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

It's time to go play!

Thanks!

πŸ‘‹

Leonardo Losoviz