JSON-LD framing

A graph has no beginning. A REST API response does. This tutorial closes that gap: you take a SPARQL construct query and turn its output into predictable, tree-shaped JSON that any application can consume — without writing a service in between.

It assumes SPARQL: the basics and construct queries.

Why framing is needed

SPARQL Construct and SPARQL Describe queries can return results in the JSON-LD format. Here is an example:

[
  {
    "@id": "john",
    "livesIn": { "@id": "amsterdam" }
  },
  {
    "@id": "jane",
    "livesIn": { "@id": "berlin" }
  },
  {
    "@id": "tim",
    "livesIn": { "@id": "berlin" }
  }
]

JSON-LD is one of the serialization formats for RDF, and encodes a graph structure. For example, the JSON-LD snippet above encodes the following graph:

graph TB Tim -- livesIn --> Berlin John -- livesIn --> Amsterdam Jane -- livesIn --> Berlin

The triples in a graph do not have any specific order. In the picture above, the triple about Tim is mentioned first, but that is arbitrary: a graph is a set of triples, so there is no "first" or "last" one. There is no "primary" or "secondary" element either. Persons happen to be drawn on the left and cities on the right, but exactly the same information is expressed by this graph:

graph TB Berlin -- hasInhabitant --> Tim Berlin -- hasInhabitant --> Jane Amsterdam -- hasInhabitant --> John

Most RESTful APIs, on the other hand, return data with a specific, often tree-shaped structure. For example:

{
  "amsterdam": {
    "inhabitants": [
      "john"
    ]
  },
  "berlin": {
    "inhabitants": [
      "jane",
      "tim"
    ]
  }
}

JSON-LD framing is the standard that gets you from the first to the second. A frame is a deterministic translation from a graph — an unordered set of triples, with no node that is first or special — into a tree, with ordered branches and exactly one root. It is what makes it possible to serve a SPARQL query as a REST API.

You need two things: a construct query and a frame. TriplyDB's saved query API carries a JSON-LD profiler that applies the frame to the query result and returns plain JSON.

Before you start

You need a dataset you can save queries on — the one from Get started with TriplyDB will do — and an API token for Step 4. See Create an API token.

Step 1: Write the construct query

The query has to produce a graph that stands on its own: self-contained, and carrying the vocabulary and data the response needs. This one goes further and carries its data in the construct template itself, under an empty where clause, so it returns the same 18 triples whichever dataset it is saved on: two objects, a head office with two addresses and a depot with one, each labelled in English and Dutch.

prefix ex: <https://triply.cc/example/>
construct {
  ex:office a ex:Object;
    ex:label "Head office"@en, "Hoofdkantoor"@nl;
    ex:address ex:address-1, ex:address-2.
  ex:depot a ex:Object;
    ex:label "Depot"@en, "Depot"@nl;
    ex:address ex:address-3.
  ex:address-1 a ex:Address; ex:street "Keizersgracht"; ex:number 391.
  ex:address-2 a ex:Address; ex:street "Prinsengracht"; ex:number 263.
  ex:address-3 a ex:Address; ex:street "Westerdoksdijk"; ex:number 40.
}
where {}

Open your dataset, click SPARQL in the left-hand sidebar, paste the query in and click the save button. Name the query json-ld-frame. Saving gives it an API URL, starting with api and ending in run, which the query page shows next to the keyword API:

https://api.triplydb.com/queries/ACCOUNT/json-ld-frame/run

ACCOUNT is the account that owns the dataset, and the host is your own instance's API host if you are not on triplydb.com.

A query over real data has a where clause that matches it, and can take API variables, appended to the URL as ?[queryVariable]=[value]. Three behaviours of such queries catch people out, and each is easier to recognise than to debug:

  • optional and API variables interact badly. An API variable used inside an optional does not filter the results, so the query returns false positives.
  • union can split the result set. The engine exhausts the first branch before starting the second, so the two halves may be disconnected. With a small limit, the result ends up split across two JSON-LD documents and data goes missing from the response.
  • A page may hold fewer results than pageSize. The limit applies to the where clause, not the construct clause, so two rows of the where result can condense into a single result. The response size can differ from the page size even when the next page is not empty.

Step 2: Write the frame

A SPARQL query alone cannot express the syntactic shape a JSON consumer needs, which is what the frame adds. A frame has two parts: a @context and a structure. The full specification is at w3c.github.io/json-ld-framing.

The @context maps linked data to JSON names. Each key is the name the IRI will take in the response; each value is the IRI itself. Usually that is a one-to-one mapping to a string. When the value is an object it must contain at least @id, and may carry modifiers — @type to set the datatype of the value, or @container to say what kind of container it lands in. The context can also hold prefixes and vocabulary references.

The structure defines the shape of the response. It normally starts with @type, which is the most direct way to choose the root node, and is built outwards from there. An empty pair of braces marks a leaf. To nest a node, name the property that points to another IRI, then describe that node's properties inside it.

{
  "@context": {
    "addresses": "ex:address",
    "Address": "ex:Address",
    "Object": "ex:Object",
    "street": "ex:street",
    "number": {
      "@id": "ex:number",
      "@type": "xsd:integer"
    },
    "labels": {
      "@id": "ex:label",
      "@container": "@set"
    },
    "ex": "https://triply.cc/example/",
    "xsd": "http://www.w3.org/2001/XMLSchema#"
  },
  "@type": "Object",
  "labels": {},
  "addresses": {
    "street": {},
    "number": {}
  }
}

Step 3: Try the frame on the query page

Framing applies to the RDF that a CONSTRUCT query returns, so the LD-Frame editor is offered on the saved query's own page, not in the SPARQL IDE. Open the saved query, run it, and choose "LDFrame" from the list of visualizations.

The list of visualizations on a saved query

Paste the frame from Step 2 into the JSON-LD Frame editor. The framed output appears below it, under "Framed query results":

The LD-Frame editor with the frame pasted in

Scroll through the results. Each object is now a node of its own, with its addresses nested inside it:

The framed query results

Step 4: Call the API

Send the same frame as the body of a request to the saved query's API URL. Replace ACCOUNT with your account name and TOKEN with your API token:

curl -X POST https://api.triplydb.com/queries/ACCOUNT/json-ld-frame/run \
  -H 'Accept: application/ld+json;profile=http://www.w3.org/ns/json-ld#framed' \
  -H 'Content-type: application/json' \
  -H 'Authorization: Bearer TOKEN' \
  -d '{
    "@context": {
      "addresses": "ex:address",
      "Address": "ex:Address",
      "Object": "ex:Object",
      "street": "ex:street",
      "number": {
        "@id": "ex:number",
        "@type": "xsd:integer"
      },
      "labels": {
        "@id": "ex:label",
        "@container": "@set"
      },
      "ex": "https://triply.cc/example/",
      "xsd": "http://www.w3.org/2001/XMLSchema#"
    },
    "@type": "Object",
    "labels": {},
    "addresses": {
      "street": {},
      "number": {}
    }
  }'

Four details matter, and each one produces a different failure when it is wrong:

  • It must be a POST. Only a POST carries a frame in its body.
  • The Accept header needs both the content type and the framing profile: application/ld+json;profile=http://www.w3.org/ns/json-ld#framed.
  • The Content-type describes the body, which is the frame, so it is application/json.
  • The Authorization: Bearer [TOKEN] header is required for internal or private queries. See Create an API token.

The response is the frame's @context followed by an @graph holding one object per ex:Object:

{
  "@context": { … },
  "@graph": [
    {
      "@id": "ex:depot",
      "@type": "Object",
      "addresses": {
        "@id": "ex:address-3",
        "@type": "Address",
        "number": "40",
        "street": "Westerdoksdijk"
      },
      "labels": [
        { "@language": "en", "@value": "Depot" },
        { "@language": "nl", "@value": "Depot" }
      ]
    },
    {
      "@id": "ex:office",
      "@type": "Object",
      "addresses": [
        {
          "@id": "ex:address-1",
          "@type": "Address",
          "number": "391",
          "street": "Keizersgracht"
        },
        {
          "@id": "ex:address-2",
          "@type": "Address",
          "number": "263",
          "street": "Prinsengracht"
        }
      ],
      "labels": [
        { "@language": "en", "@value": "Head office" },
        { "@language": "nl", "@value": "Hoofdkantoor" }
      ]
    }
  ]
}

Notice the difference between labels and addresses. labels is declared with "@container": "@set", so it is an array even where there is one value. addresses is not, so the depot's single address comes back as an object and the office's two as an array — the kind of irregularity a client has to code around, and which one more @container in the context removes.

What you now have

A SPARQL query that behaves like a REST endpoint: a stable URL, plain JSON out, and a shape that will not change because the underlying triples came back in a different order.