Build a RESTful API

Applications want a REST API: a path, some query parameters, and JSON back. Your data is a graph. This tutorial connects the two using nothing but saved SPARQL queries and a JSON-LD frame — no service layer, and no code.

The example throughout is an API path for Pokémon, built on the public Pokémon dataset that Triply publishes on triplydb.com. It assumes SPARQL: the basics, construct queries and JSON-LD framing.

The design has two queries. The iterator decides which things the API returns. The generator decides what is returned about each of them.

Before you start

You need an account on a TriplyDB instance and an API token. Create a dataset named pokemon in your account, click Import from URL on its Graphs page, and import:

https://api.triplydb.com/datasets/academy/pokemon/download.trig.gz

When the import finishes, the dataset holds two graphs with 28,773 statements between them. See Add data if any of that is unfamiliar.

Write the Iterator

The iterator is a SPARQL Select query that returns a sequence of bindings that adheres to the query parameters supported for the REST API path.

The REST API path for Pokémon returns bindings for individual Pokémon, and has the following query parameters:

  • The name of the Pokémon (API variable $name)
  • Its number in the regional Hoenn Pokédex (API variable $hoennNumber)

Configuration in case no query parameter is specified

The first part of the query string returns the sequence of bindings in case no query parameter is specified. It names the class that corresponds to the REST API path, vocab:Pokémon:

prefix vocab: <https://triplydb.com/academy/pokemon/vocab/>
select $this {
  $this
    a vocab:Pokémon.
}

Configuration for query parameters that map onto required properties

For every query parameter, determine whether it maps onto a property that is required or optional.

Query parameter name maps onto property rdfs:label, which every Pokémon has. For each required property, add a Basic Graph Pattern:

prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>
prefix vocab: <https://triplydb.com/academy/pokemon/vocab/>
select $this {
  $this
    a vocab:Pokémon;
    rdfs:label $name.
}

Configuration for query parameters that map onto optional properties

Query parameter hoennNumber maps onto property vocab:hoennNumber, which 107 of the 493 Pokémon do not have. A basic graph pattern would drop every Pokémon without one, so an optional property needs three lines instead:

bind($hoennNumber as ?hoennNumber1)
optional { $this vocab:hoennNumber ?hoennNumber2. }
filter(!bound(?hoennNumber1) || ?hoennNumber1 = ?hoennNumber2)

Each line does one thing:

  1. The Bind clause makes sure that ?hoennNumber1 is only bound if hoennNumber is specified through a query parameter.
  2. The Optional clause matches the optional property, if it is present.
  3. The Filter clause lets the query succeed if either the query parameter was not set, or it was set to the value of the current binding for $this.

For another optional parameter, repeat the three lines with its own API variable and property.

Full example

The following query is the complete Iterator, with both query parameters:

prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>
prefix vocab: <https://triplydb.com/academy/pokemon/vocab/>
select $this {
  $this
    a vocab:Pokémon;
    rdfs:label $name.
  bind($hoennNumber as ?hoennNumber1)
  optional { $this vocab:hoennNumber ?hoennNumber2. }
  filter(!bound(?hoennNumber1) || ?hoennNumber1 = ?hoennNumber2)
}

Write the Generator

The Generator is a SPARQL Construct query that returns one record for each binding of the $this variable in the Iterator. Since we are working with linked data graphs, there is no real notion of a 'record'. This requires us to configure which data items we want to include, and which we want to exclude.

The basic Construct query

Since the Generator is a Construct query, start with the following basic query, which returns triples from the graph:

construct {
  $this ?p1 ?o1.
} where {
  $this ?p1 ?o1.
}

It uses the same variable name $this as the Iterator, but that is merely a naming convention. The next step connects the two.

Integrate the Iterator into the Generator

In SPARQL, any Select query can be placed inside another query as a Sub-Select clause. That connects the bindings for $this that come out of the Iterator to the basic template in the Generator:

prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>
prefix vocab: <https://triplydb.com/academy/pokemon/vocab/>
construct {
  $this ?p1 ?o1.
} where {
  {
    select $this {
      $this
        a vocab:Pokémon;
        rdfs:label $name.
    }
  }
  $this ?p1 ?o1.
}

With a value for name, this returns every triple that describes the Pokémon with that name — at least 46 of them for every Pokémon, from base statistics to links into other datasets.

Choose the properties to return

An API returns what its consumers need, not everything the graph holds. A whitelist of properties, written with the operator in, keeps the rest out:

  $this ?p1 ?o1.
  filter(?p1 in (rdf:type,
                 rdfs:label,
                 vocab:colour,
                 vocab:baseHP,
                 vocab:hoennNumber,
                 sdo:audio))

Extend the Generator with nested information

So far the Generator only returns the triples that directly describe $this. Some relevant information sits further away. Each Pokémon links to its sound recordings with sdo:audio, and the address and format of each recording are on the recording, two hops away from $this.

To include such nested information, add another Basic Triple Pattern. Since only some properties lead to nested information, enclose the deeper hop inside an Optional clause, together with a whitelist of the properties to follow:

  optional {
    ?o1 ?p2 ?o2.
    filter(?p1 in (sdo:audio))
  }

Full example

The following query is the complete Generator, with the full Iterator from the previous full example as its Sub-Select:

prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>
prefix sdo: <http://schema.org/>
prefix vocab: <https://triplydb.com/academy/pokemon/vocab/>
construct {
  $this ?p1 ?o1.
  ?o1 ?p2 ?o2.
} where {
  {
    select $this {
      $this
        a vocab:Pokémon;
        rdfs:label $name.
      bind($hoennNumber as ?hoennNumber1)
      optional { $this vocab:hoennNumber ?hoennNumber2. }
      filter(!bound(?hoennNumber1) || ?hoennNumber1 = ?hoennNumber2)
    }
  }
  $this ?p1 ?o1.
  filter(?p1 in (rdf:type,
                 rdfs:label,
                 vocab:colour,
                 vocab:baseHP,
                 vocab:hoennNumber,
                 sdo:audio))
  optional {
    ?o1 ?p2 ?o2.
    filter(?p1 in (sdo:audio))
  }
}

Save the Generator

Open the SPARQL IDE of your pokemon dataset, paste the full Generator in, and save it as pokemon. On the saved query's page, click Add variable twice, to declare name and hoennNumber as its API variables. The saved query now has an API URL of the form https://api.triplydb.com/queries/ACCOUNT/pokemon/run, where ACCOUNT is your account name. See Work with saved queries for the save itself.

Shape the JSON with a frame

From a content perspective, the Generator already functions like a REST API path: it takes zero or more query parameters, and returns the information that conforms to its configuration. It returns JSON-LD, but a JSON-LD graph has no root and no fixed shape, so the body is correct but messy. A JSON-LD frame turns it into a predictable tree. How a frame works, and how to try one out on the query page, is the subject of JSON-LD framing; this section only gives the frame for Pokémon.

Save the following as frame.json:

{
  "@context": {
    "@base": "https://triplydb.com/academy/pokemon/",
    "@version": 1.1,
    "@vocab": "https://triplydb.com/academy/pokemon/vocab/",
    "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
    "sdo": "http://schema.org/",
    "xsd": "http://www.w3.org/2001/XMLSchema#",
    "id": "@id",
    "type": "@type",
    "name": "rdfs:label",
    "audio": "sdo:audio",
    "baseHP": { "@id": "baseHP", "@type": "xsd:integer" },
    "contentUrl": { "@id": "sdo:contentUrl", "@type": "xsd:anyURI" },
    "encodingFormat": "sdo:encodingFormat",
    "AudioObject": "sdo:AudioObject"
  },
  "type": "Pokémon"
}

Each part removes one kind of clutter from the response:

  • "type": "Pokémon" makes every top-level object a Pokémon. It resolves against @vocab, so it stands for vocab:Pokémon.
  • @base and @vocab shorten the IRIs of the instances and of the dataset's own vocabulary, so vocab:colour comes back as colour.
  • The prefix declarations (rdfs, sdo, xsd) shorten every other IRI.
  • The key names (name, audio, encodingFormat, …) replace prefixed IRIs with plain names, and id and type replace the @id and @type keys.
  • baseHP and contentUrl declare their datatypes, so they come back as plain strings rather than as objects with @type and @value.

Call the API

Send the frame to the saved query, with the query parameter in the URL. Replace ACCOUNT with your account name and TOKEN with your API token:

curl -X POST 'https://api.triplydb.com/queries/ACCOUNT/pokemon/run?name=Pikachu' \
  -H 'Accept: application/ld+json;profile=http://www.w3.org/ns/json-ld#framed' \
  -H 'Content-type: application/json' \
  -H 'Authorization: Bearer TOKEN' \
  -d @frame.json

The response holds the frame's @context, followed by Pikachu:

{
  "id": "id/pokemon/pikachu",
  "type": "Pokémon",
  "audio": [
    {
      "id": ".well-known/genid/28ae2a933c01ba95bb18f7933a1ad931",
      "type": "AudioObject",
      "contentUrl": "https://api.triplydb.com/datasets/academy/pokemon/assets/download?fileName=pikachu.ogg",
      "encodingFormat": "audio/ogg",
      "name": "Audio of Pikachu in Ogg format."
    },
    {
      "id": ".well-known/genid/3516db27c9964b304b805c1220c04351",
      "type": "AudioObject",
      "contentUrl": "https://api.triplydb.com/datasets/academy/pokemon/assets/download?fileName=pikachu.mp3",
      "encodingFormat": "audio/mpeg",
      "name": "Audio of Pikachu in MP3 format."
    }
  ],
  "name": "Pikachu",
  "baseHP": "35",
  "colour": "yellow",
  "hoennNumber": "156"
}

Replace ?name=Pikachu with ?hoennNumber=39 and the same request returns Abra. Leave the parameters out and it returns every Pokémon, a page at a time.

The same account also publishes an OpenAPI specification describing all of its saved queries: request https://api.triplydb.com/queries/ACCOUNT/ with the API token and the header Accept: text/vnd.yaml, and load the result into any client that reads OpenAPI.

What you now have

A REST API backed entirely by saved SPARQL queries: parameters that behave like query parameters, JSON that looks hand-designed, and an OpenAPI specification any client can import. Changing what the API returns is editing a query, not deploying code.