GraphQL Composer Plugin

Route plugins: GraphQL Composer
Warning

this feature is EXPERIMENTAL and might not work as expected.
If you encounter any bugs, please fill an issue, it will help us a lot :)

GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data. GraphQL provides a complete and understandable description of the data in your API, gives clients the power to ask for exactly what they need and nothing more, makes it easier to evolve APIs over time, and enables powerful developer tools. Official GraphQL website

APIs RESTful and GraphQL development has become one of the most popular activities for companies as well as users in recent times. In fast scaling companies, the multiplication of clients can cause the number of API needs to grow at scale.

Otoroshi comes with a solution to create and meet your customers’ needs without constantly creating and recreating APIs: the GraphQL composer plugin. The GraphQL Composer is an useful plugin to build an GraphQL API from multiples differents sources. These sources can be REST apis, GraphQL api, raw JSON or WASM binary or anything that supports the HTTP protocol.

Tutorial

Let’s set up the plugin to align with the specified models:

A user model with attributes for name and password. A country model with attributes for name and its associated users.

The plugin provides custom directives to compose your API. A directive decorates part of a GraphQL schema or operation with additional configuration. Directives are preceded by the @ character, like so:

  • rest : to call a http rest service with dynamic path params
  • permission : to restrict the access to the sensitive field
  • graphql : to call a graphQL service by passing a url and the associated query

The GraphQL schema of our tutorial should look like this

type Country {
  name: String
  users: [User] @rest(url: "http://localhost:5000/countries/${item.name}/users")
}

type User {
  name: String
  password: String @password(value: "ADMIN")
}

type Query {
  users: [User] @rest(url: "http://localhost:5000/users", paginate: true)
  user(id: String): User @rest(url: "http://localhost:5000/users/${params.id}")
  countries: [Country] @graphql(url: "https://countries.trevorblades.com", query: "{ countries { name }}", paginate: true)
}

Now that we’ve covered the fundamentals of GraphQL Composer, let’s proceed with setting it up in our project :

  • set up a route containing the GraphQL Composer plugin
  • configure the plugin with the appropriate schema
  • conduct a test to ensure everything is functioning correctly

Setup your environment

If you already have an up and running otoroshi instance, you can skip the following instructions

Set up an Otoroshi

Let’s start by downloading the latest Otoroshi.

curl -L -o otoroshi.jar 'https://github.com/MAIF/otoroshi/releases/download/v16.17.0/otoroshi.jar'

then you can run start Otoroshi :

java -Dotoroshi.adminPassword=password -jar otoroshi.jar 

Now you can log into Otoroshi at http://otoroshi.oto.tools:8080 with admin@otoroshi.io/password

Create a new route, exposed on http://myservice.oto.tools:8080, which will forward all requests to the mirror https://mirror.otoroshi.io. Each call to this service will returned the body and the headers received by the mirror.

curl -X POST 'http://otoroshi-api.oto.tools:8080/api/routes' \
-H "Content-type: application/json" \
-u admin-api-apikey-id:admin-api-apikey-secret \
-d @- <<'EOF'
{
  "name": "my-service",
  "frontend": {
    "domains": ["myservice.oto.tools"]
  },
  "backend": {
    "targets": [
      {
        "hostname": "mirror.otoroshi.io",
        "port": 443,
        "tls": true
      }
    ]
  }
}
EOF

Create our countries API

Let’s create the route with few informations :

  • name: My countries API
  • frontend: countries-api.oto.tools
  • plugins: GraphQL composer plugin

Let’s make a request call through the Otoroshi Admin API (with the default apikey), like the example below

curl -X POST 'http://otoroshi-api.oto.tools:8080/api/routes' \
  -d '{
  "id": "countries-api",
  "name": "My countries API",
  "frontend": {
    "domains": ["countries.oto.tools"]
  },
  "backend": {
    "targets": [
      {
        "hostname": "mirror.otoroshi.io",
        "port": 443,
        "tls": true
      }
    ],
    "load_balancing": {
      "type": "RoundRobin"
    }
  },
  "plugins": [
    {
      "plugin": "cp:otoroshi.next.plugins.GraphQLBackend"
    }
  ]
}' \
  -H "Content-type: application/json" \
  -u admin-api-apikey-id:admin-api-apikey-secret

Configure our countries API

Let’s continue our API by updating the configuration of the GraphQL plugin with the initial schema.

curl -X PUT 'http://otoroshi-api.oto.tools:8080/api/routes/countries-api' \
  -d '{
    "id": "countries-api",
    "name": "My countries API",
    "frontend": {
        "domains": [
            "countries.oto.tools"
        ]
    },
    "backend": {
        "targets": [
            {
                "hostname": "mirror.otoroshi.io",
                "port": 443,
                "tls": true
            }
        ],
        "load_balancing": {
            "type": "RoundRobin"
        }
    },
    "plugins": [
        {
            "enabled": true,
            "plugin": "cp:otoroshi.next.plugins.GraphQLBackend",
            "config": {
                "schema": "type Country {\n  name: String\n  users: [User] @rest(url: \"http://localhost:8181/countries/${item.name}/users\", headers: \"{}\")\n}\n\ntype Query {\n  users: [User] @rest(url: \"http://localhost:8181/users\", paginate: true, headers: \"{}\")\n  user(id: String): User @rest(url: \"http://localhost:8181/users/${params.id}\")\n  countries: [Country] @graphql(url: \"https://countries.trevorblades.com\", query: \"{ countries { name }}\", paginate: true)\ntype User {\n  name: String\n  password: String }\n"
            }
        }
    ]
}' \
  -H "Content-type: application/json" \
  -u admin-api-apikey-id:admin-api-apikey-secret

Our created route expects an API, exposed on the localhost:8181, designed to retrieve users based on their countries.

Let’s develop this API using NodeJS. The API uses express as http server.

const express = require('express')

const app = express()

const users = [
  {
    name: 'Joe',
    password: 'password'
  },
  {
    name: 'John',
    password: 'password2'
  }
]

const countries = [
  {
    name: 'Andorra',
    users: [users[0]]
  },
  {
    name: 'United Arab Emirates',
    users: [users[1]]
  }
]

app.get('/users', (_, res) => {
  return res.json(users)
})

app.get(`/users/:name`, (req, res) => {
  res.json(users.find(u => u.name === req.params.name))
})

app.get('/countries/:id/users', (req, res) => {
  const country = countries.find(c => c.name === req.params.id)

  if (country) 
    return res.json(country.users)
  else 
    return res.json([])
})

app.listen(8181, () => {
  console.log(`Listening on 8181`)
});

Let’s initiate our first request to the countries API.

curl 'countries.oto.tools:9999/' \
--header 'Content-Type: application/json' \
--data-binary @- << EOF
{
    "query": "{\n    countries {\n        name\n        users {\n            name\n   }\n    }\n}"
}
EOF

You should see the following content in your terminal.

{
  "data": { 
    "countries": [
      { 
        "name":"Andorra",
        "users": [
          { "name":"Joe" }
        ]
      }
    ]
  }
}

The call graph should looks like

  1. Initiate a request to https://countries.trevorblades.com
  2. For each country retrieved:

You may have noticed that we appended an argument called pagniate to the end of the graphql directive. This argument enables pagination for the client, allowing it to accept parameters such as limit and offset. The plugin utilizes these parameters to filter and streamline the content.

Let’s initiate a new call that doesn’t specify any country.

curl 'countries.oto.tools:9999/' \
--header 'Content-Type: application/json' \
--data-binary @- << EOF
{
    "query": "{\n    countries(limit: 0) {\n        name\n        users {\n            name\n   }\n    }\n}"
}
EOF

You should see the following content in your terminal.

{
  "data": { 
    "countries": []
  }
}

Let’s move on to the next section to secure sensitive field of our API.

Basics of permissions

The permission directives have been established to safeguard the fields within the GraphQL schema. The validation process commences by generating a context for all incoming requests, derived from the list of paths specified in the permissions field of the plugin. These permissions paths can reference various components of the request data (such as URL, headers, etc.), user credentials (such as API key, etc.), and details regarding the matched route. Subsequently, the process verifies that the specified value or values are present within the context.

Permission

Arguments : value and unauthorized_value

The permission directive is designed to to secure a field on one value. The directive checks that a specific value is present in the context.

Two arguments are available : value which is mandatory and specifies the expected value. Optionally, unauthorized_value, which can be utilized to indicate the rejection message in the outgoing response.

Example

type User {
    id: String @permission(
        value: "FOO", 
        unauthorized_value: "You're not authorized to get this field")
}
All permissions

Arguments : values and unauthorized_value

This directive is presumably the same as the previous one except that it takes a list of values.

Example

type User {
    id: String @allpermissions(
        values: ["FOO", "BAR"], 
        unauthorized_value: "FOO and BAR could not be found")
}
One permissions of

Arguments : values and unauthorized_value

This directive takes a list of values and validate that one of them is in the context.

Example

type User {
    id: String @onePermissionsOf(
        values: ["FOO", "BAR"], 
        unauthorized_value: "FOO or BAR could not be found")
}
Authorize

Arguments : path, value and unauthorized_value

The authorize directive has one more required argument, named path, which indicates the path to value, in the context. Unlike the last three directives, the authorize directive doesn’t search in the entire context but at the specified path.

Example

type User {
    id: String @authorize(
        path: "$.raw_request.headers.foo", 
        value: "BAR", 
        unauthorized_value: "Bar could not be found in the foo header")
}

Let’s restrict the password field to the users that comes with a role header of the value ADMIN.

  1. Patch the configuration of the API by adding the permissions in the configuration of the plugin.

    ...
     "permissions": ["$.raw_request.headers.role"]
    ...
    

  2. Add an directive on the password field in the schema

    type User {
      name: String
      password: String @permission(value: "ADMIN")
    }
    

Let’s make a call with the role header

curl 'countries.oto.tools:9999/' \
--header 'Content-Type: application/json' \
--header 'role: ADMIN'
--data-binary @- << EOF
{
    "query": "{\n    countries(limit: 0) {\n name\n  users {\n name\n password\n   }\n    }\n}"
}
EOF

Now try to change the value of the role header

curl 'countries.oto.tools:9999/' \
--header 'Content-Type: application/json' \
--header 'role: USER'
--data-binary @- << EOF
{
    "query": "{\n    countries(limit: 0) {\n name\n  users {\n name\n password\n   }\n    }\n}"
}
EOF

The error message should look like

{
  "errors": [
    {
      "message": "You're not authorized",
      "path": [
        "countries",
        0,
        "users",
        0,
        "password"
      ],
      ...
    }
  ]
}

Glossary

Directives

Rest

Arguments : url, method, headers, timeout, data, response_path, response_filter, limit, offset, paginate

The rest directive is used to expose servers that communicate using the http protocol. The only required argument is the url.

Example

type Query {
    users(limit: Int, offset: Int): [User] @rest(url: "http://foo.oto.tools/users", method: "GET")
}

It can be placed on the field of a query and type. To custom your url queries, you can use the path parameter and another field with respectively, params and item variables.

Example

type Country {
  name: String
  phone: String
  users: [User] @rest(url: "http://foo.oto.tools/users/${item.name}")
}

type Query {
  user(id: String): User @rest(url: "http://foo.oto.tools/users/${params.id}")
}
GraphQL

Arguments : url, method, headers, timeout, query, data, response_path, response_filter, limit, offset, paginate

The rest directive is used to call an other graphql server.

The required argument are the url and the query.

Example

type Query {
    countries: [Country] @graphql(url: "https://countries.trevorblades.com/", query: "{ countries { name phone }}")
}

type Country {
    name: String
    phone: String
}
Soap

Arguments: all following arguments

The soap directive is used to call a soap service.

type Query {
    randomNumber: String @soap(
        jq_response_filter: ".[\"soap:Envelope\"] | .[\"soap:Body\"] | .[\"m:NumberToWordsResponse\"] | .[\"m:NumberToWordsResult\"]", 
        url: "https://www.dataaccess.com/webservicesserver/numberconversion.wso", 
        envelope: "<?xml version=\"1.0\" encoding=\"utf-8\"?> \n  <soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">   \n  <soap:Body>     \n    <NumberToWords xmlns=\"http://www.dataaccess.com/webservicesserver/\">       \n      <ubiNum>12</ubiNum>     \n    </NumberToWords>   \n  </soap:Body> \n</soap:Envelope>")
}
Specific arguments
Argument Type Optional Default value
envelope STRING Required
url STRING x
action STRING x
preserve_query BOOLEAN Required true
charset STRING x
convert_request_body_to_xml BOOLEAN Required true
jq_request_filter STRING x
jq_response_filter STRING x
JSON

Arguments: path, json, paginate

The json directive can be used to expose static data or mocked data. The first usage is to defined a raw stringify JSON in the data argument. The second usage is to set data in the predefined field of the GraphQL plugin composer and to specify a path in the path argument.

Example

type Query {
    users_from_raw_data: [User] @json(data: "[{\"firstname\":\"Foo\",\"name\":\"Bar\"}]")
    users_from_predefined_data: [User] @json(path: "users")
}
Mock

Arguments: url

The mock directive is to used with the Mock Responses Plugin, also named Charlatan. This directive can be interesting to mock your schema and start to use your Otoroshi route before starting to develop the underlying service.

Example

type Query {
    users: @mock(url: "/users")
}

This example supposes that the Mock Responses plugin is set on the route’s feed, and that an endpoint /users is available.

List of directive arguments

Argument Type Optional Default value
url STRING
method STRING x GET
headers STRING x
timeout INT x 5000
data STRING x
path STRING x (only for json directive)
query STRING x
response_path STRING x
response_filter STRING x
limit INT x
offset INT x
value STRING
values LIST of STRING
path STRING
paginate BOOLEAN x
unauthorized_value STRING x (only for permissions directive)