ShExpose#

ShExpose generates a REST API from ShEx shapes through a LinkML → ShEx → LDO → Express pipeline: every shape becomes a set of CRUD routes, and the SPARQL is derived from the shape instead of being written by hand. It binds one endpoint and addresses a resource by its RDF subject IRI, so the lookup here uses OMID instead of DOI. The shape below describes a bibliographic resource as a fabio:Expression carrying its title and its authors’ names.

from urllib.parse import quote

from helper import call

omid = "https://w3id.org/oc/meta/br/061202127149"
uri = quote(omid, safe="")

A simple request#

ShExpose addresses a resource by its URL-encoded subject IRI. Looking up the OMID returns the attributes named in the shape: the title from OpenCitations Meta together with the author surnames and given names, each value wrapped in a {language, value} object.

call(f"http://localhost:8090/expression/{uri}/")
curl http://localhost:8090/expression/https%3A%2F%2Fw3id.org%2Foc%2Fmeta%2Fbr%2F061202127149/ 

# 200 OK

{
  "title": {
    "language": "@none",
    "value": "Identifying And Correcting Invalid Citations Due To DOI Errors In Crossref Data"
  },
  "familyName": [
    {
      "language": "@none",
      "value": "Peroni"
    },
    {
      "language": "@none",
      "value": "Moretti"
    },
    {
      "language": "@none",
      "value": "Massari"
    },
    {
      "language": "@none",
      "value": "Coppini"
    },
    {
      "language": "@none",
      "value": "Shahidzadeh"
    },
    {
      "language": "@none",
      "value": "Cioffi"
    },
    {
      "language": "@none",
      "value": "Santini"
    }
  ],
  "givenName": [
    {
      "language": "@none",
      "value": "Silvio"
    },
... (26 more lines)

The join#

Not supported.

Output#

JSON only, with no content negotiation.

call(f"http://localhost:8090/expression/{uri}/title")
curl http://localhost:8090/expression/https%3A%2F%2Fw3id.org%2Foc%2Fmeta%2Fbr%2F061202127149/title 

# 200 OK

{
  "language": "@none",
  "value": "Identifying And Correcting Invalid Citations Due To DOI Errors In Crossref Data"
}

Pagination#

Not supported.

Versioning#

Not supported.

API description#

OpenAPI 3.0, generated from the shapes with zod-to-openapi. ShExpose serves a Swagger UI at http://localhost:8090/docs and the spec at http://localhost:8090/openapi.json.

import requests

from helper import embed_swagger

spec = requests.get("http://localhost:8090/openapi.json", timeout=120).json()
embed_swagger(spec, base_url="http://localhost:8090/")

Consumer authentication#

Not supported for consumers: the generated API is open. For the upstream endpoint, ShExpose can send HTTP Basic credentials on reads and a QLever bearer token on writes.

Endpoint authentication#

HTTP Basic on queries, from rdf.auth in the configuration.

from pathlib import Path

print(Path("shexpose/config/config.yaml").read_text())
call(f"http://localhost:8090/expression/{uri}/title")
app:
  port: 3000

rdf:
  sparql_endpoint: http://meta-basic:3030/sparql
  auth:
    username: demo
    password: demo

data:
  base_uri: https://w3id.org/oc/meta/

debug:
  do_sparql_update: false

curl http://localhost:8090/expression/https%3A%2F%2Fw3id.org%2Foc%2Fmeta%2Fbr%2F061202127149/title 

# 200 OK

{
  "language": "@none",
  "value": "Identifying And Correcting Invalid Citations Due To DOI Errors In Crossref Data"
}

Operations#

GET, POST, PUT, and DELETE on every shape and on each of its attributes. A second server is bound to a copy of OpenCitations Meta that accepts updates, with do_sparql_update switched on. A POST creates a resource under a generated IRI.

import requests

created = requests.post("http://localhost:8091/expression/", json={"title": {"value": "A title"}}, timeout=120)
print(created.status_code, created.json())
new_uri = quote(created.json()["uri"], safe="")
call(f"http://localhost:8091/expression/{new_uri}/title")
201 {'uri': 'https://w3id.org/oc/meta/expression-5e7cfddb-8fce-42ac-a6c0-830524d84821'}
curl http://localhost:8091/expression/https%3A%2F%2Fw3id.org%2Foc%2Fmeta%2Fexpression-5e7cfddb-8fce-42ac-a6c0-830524d84821/title 
# 200 OK

{
  "language": "@none",
  "value": "A title"
}

A PUT replaces the title.

call(f"http://localhost:8091/expression/{new_uri}/title", method="PUT", data={"value": "Another title"})
call(f"http://localhost:8091/expression/{new_uri}/title")
curl -X PUT -H 'Content-type: application/json' --data '{"value": "Another title"}' http://localhost:8091/expression/https%3A%2F%2Fw3id.org%2Foc%2Fmeta%2Fexpression-5e7cfddb-8fce-42ac-a6c0-830524d84821/title 
# 200 OK

{
  "success": true
}
curl http://localhost:8091/expression/https%3A%2F%2Fw3id.org%2Foc%2Fmeta%2Fexpression-5e7cfddb-8fce-42ac-a6c0-830524d84821/title 
# 200 OK
{
  "language": "@none",
  "value": "Another title"
}

A DELETE removes the resource.

call(f"http://localhost:8091/expression/{new_uri}/", method="DELETE")
call(f"http://localhost:8091/expression/{new_uri}/")
curl -X DELETE http://localhost:8091/expression/https%3A%2F%2Fw3id.org%2Foc%2Fmeta%2Fexpression-5e7cfddb-8fce-42ac-a6c0-830524d84821/ 
# 204 No Content


curl http://localhost:8091/expression/https%3A%2F%2Fw3id.org%2Foc%2Fmeta%2Fexpression-5e7cfddb-8fce-42ac-a6c0-830524d84821/ 

# 404 Not Found

{
  "error": "Not found",
  "message": "Resource not found"
}

Caching#

Not supported. The server with the write routes reaches its store through a proxy that records every request it forwards, and two identical calls send the same SPARQL request twice.

from helper import count_endpoint_requests

count_endpoint_requests(f"http://localhost:8091/expression/{uri}/title", control="http://localhost:7003")
First call: 200 OK, SPARQL requests that reached the endpoint: 1
Second call: 200 OK, SPARQL requests that reached the endpoint: 1

Control over JSON#

Not supported. The keys of the JSON are the names of the slots in the LinkML model, as the first request shows, but the shape is fixed: every value comes wrapped in a {language, value} object, and the attributes of nested shapes are lifted to the top level.