Zero-code model-driven endpoints for linked data resources.
@metreeca/gate serves linked data resources over HTTP straight from the resource models that describe them:
@metreeca/gate is part of the @metreeca/qest integrated ecosystem for rapid development of linked data applications.
npm install @metreeca/gate express
TypeScript consumers must use "moduleResolution": "nodenext"/"node16"/"bundler" in tsconfig.json.
The legacy "node" resolver is not supported.
This section introduces essential concepts; for complete coverage, see the API reference:
| Module | Description |
|---|---|
| @metreeca/gate | HTTP server for Express applications |
| @metreeca/gate/driver | Model-driven resource drivers |
| @metreeca/gate/client | Browser client hosting |
| @metreeca/gate/worker | Worker hosting |
server() creates an Express application that already takes care of request logging, error responses and graceful
shutdown. Define its routes as usual, then serve it over HTTP with listen(), passing the server options:
import { server } from "@metreeca/gate";
await server()
.get("/", (request, response) => response.send("hello"))
.listen({ port: 8080 }); // settles once the server is closed
The server runs until it receives SIGINT or SIGTERM, or until the signal option is aborted. Requests that match
no route get a bodiless 404, and unhandled errors get a bodiless response with no internal detail.
Behind a proxy, declare the hops to trust, so that routes see the address of the caller rather than that of the proxy. Keep the shutdown within the grace period the hosting platform allows before a forced kill:
await server()
.get("/", (request, response) => response.send(`hello ${request.ip}`)) // the caller, not the proxy
.listen({
port: Number(process.env.PORT ?? 8080),
proxies: 1, // one front-end proxy, as on Cloud Run
grace: 8_000 // below the 10 seconds Cloud Run waits before killing the container
});
Drivers expose the resources a shape describes as a REST/JSON API. Pass the store holding the resources to server(),
then mount a driver on each route, enabling only the HTTP methods the route supports:
import { server } from "@metreeca/gate";
import { driver } from "@metreeca/gate/driver";
import { number } from "@metreeca/blue/number";
import { reference } from "@metreeca/blue/reference";
import { id, multiple, required, resource } from "@metreeca/blue/resource";
import { string } from "@metreeca/blue/string";
import { store } from "./store.js"; // any @metreeca/keep store
await server(store)
.use("/products/", driver(Products).lookup().create())
.use("/products/:id", driver(Product).lookup().update().delete())
.listen({
port: 8080,
base: "https://data.example.com/"
});
function Products() {
return resource({
id: id(),
items: multiple(reference(Product))
});
}
function Product() {
return resource({
id: id(),
name: required(string()),
price: required(number({ minInclusive: 0 }))
});
}
The base option names the canonical IRI of the service, so stored resources keep the same identifiers wherever the
server runs. Without it, resources are identified by the scheme and host each request was received on.
Each enabled method follows the payload conventions of @metreeca/qest:
| Method | Enabled by | Request body | Response |
|---|---|---|---|
| GET, HEAD | lookup() |
none | 200 OK with the resource, 404 Not Found |
| POST | create() |
resource | 201 Created with the Location of the new item, 409 Conflict |
| PUT | update() |
resource | 204 No Content, 404 Not Found |
| DELETE | delete() |
none | 204 No Content, 404 Not Found |
| OPTIONS | any of the above | none | 204 No Content with an Allow header |
Methods not enabled are answered with 405 Method Not Allowed. Templates and request bodies are validated against the
shape, and a violation is answered with 422 Unprocessable Content and a trace of every violation found.
A bare GET returns the default view of the resource, as the shape describes it. Linked resources are returned as
references, and IRIs sharing the scheme and authority of the base are exchanged in root-relative form:
GET https://data.example.com/products/
{
"id": "/products/",
"items": [
"/products/123",
"/products/456"
]
}
Clients may state a template in the query string to choose what to retrieve, or filter the collection with plain form data, as an HTML form would submit it:
GET https://data.example.com/products/?~name=widget&price>=50
{
"items": [
"/products/123"
]
}
Hooks adapt each driver to the needs of its route:
lookup({ model }): bounds what clients may retrieve with a server template, possibly computed for each request,
as when scoping a collection to the resources the current user or tenant is allowed to seelookup({ review }): reviews each retrieved resource before it is served, and may hide it behind a 404create({ decode }), update({ decode }): prepare request bodies before validation, for instance filling in
defaults or stamping the resource with its author, so that clients never have to state themDriver endpoints follow the protocol of the
@metreeca/keep-rest connector, so remote
applications may access the published resources through the standard @metreeca/keep store API. Drivers support the
lookup(), create(), update() and delete() store methods. They ignore the Prefer: handling=lenient header,
so insert() succeeds only on resources that already exist, while remove() behaves as usual.
Drivers set no limit on the size of request bodies, so untrusted clients may send arbitrarily large ones. Mount
express.json({ limit }) ahead of the drivers to bound them: drivers then reuse the body that parser reads.
To deliver a browser client from the same origin as the API, mount client() ahead of the API routes. Pass it the
folder holding the client build, or the path of its entry page when it is not named index.html:
import { server } from "@metreeca/gate";
import { client } from "@metreeca/gate/client";
await server()
.use(client("./dist/client")) // serves index.html to navigation requests, static assets otherwise
.get("/items", (request, response) => response.json([]))
.listen({ port: 8080 });
Navigation requests from the browser receive the entry page whatever their path, so client-side addresses survive a reload. Requests for API resources at the same address fall through to the API routes.
To run a background job on request, for instance from a scheduler, mount worker() on a dedicated route:
import { server } from "@metreeca/gate";
import { worker } from "@metreeca/gate/worker";
await server()
.post("/jobs/sync", worker(async () => sync())) // answers 200 OK once sync() completes
.listen({ port: 8080 });
The caller learns the outcome from the status code alone. A completed run is acknowledged with an empty 200 OK, and
the error middleware of the application answers a failed one. The worker performs no authentication of its own, so
access control is left to the surrounding routes, for instance to the IAM checks of the hosting platform.
This project is licensed under the Apache 2.0 License – see LICENSE file for details.