Skip to content

@lde/fastify-rdf ​

Fastify plugin for serving RDF data with automatic content negotiation.

Installation ​

bash
npm install @lde/fastify-rdf

Features ​

  • Content negotiation for all RDF serialisation formats supported by rdf-serialize
  • Handles Accept headers to serve data in the requested RDF format (Turtle, N-Triples, JSON-LD, etc.)
  • Defaults to Turtle when no Accept header is provided
  • Provides a reply.sendRdf() decorator for explicit RDF responses
  • Optional overrideSend mode to automatically serialise all responses as RDF

Usage ​

Basic Setup ​

typescript
import fastify from 'fastify';
import fastifyRdf from '@lde/fastify-rdf';

const app = fastify();
await app.register(fastifyRdf);

Explicit RDF Responses with reply.sendRdf() ​

Use reply.sendRdf() to send RDF data with content negotiation:

typescript
import { Store, DataFactory } from 'n3';

const { namedNode, literal, quad } = DataFactory;

app.get('/resource', async (request, reply) => {
  const store = new Store();
  store.add(
    quad(
      namedNode('http://example.org/subject'),
      namedNode('http://example.org/predicate'),
      literal('object'),
    ),
  );
  return reply.sendRdf(store);
});

Automatic Serialisation with overrideSend ​

Enable overrideSend to automatically serialise all responses as RDF. This is useful for APIs that exclusively serve RDF data:

typescript
await app.register(fastifyRdf, {
  overrideSend: true,
});

// Simply return DatasetCore or Stream - they will be serialised automatically
app.get('/resource', async () => {
  const store = new Store();
  store.add(
    quad(
      namedNode('http://example.org/subject'),
      namedNode('http://example.org/predicate'),
      literal('object'),
    ),
  );
  return store;
});

overrideSend is implemented with preSerialization and onSend hooks that treat every non-string payload as RDF – including error payloads, so make sure custom error handlers produce RDF (e.g. via reply.sendHydraError()) or strings.

Parsing RDF Request Bodies ​

The plugin registers content type parsers for all RDF formats supported by rdf-parse. Individual routes opt in via config: { parseRdf: true } – the body is then parsed into a DatasetCore:

typescript
app.post('/data', { config: { parseRdf: true } }, async (request) => {
  const dataset = request.body as DatasetCore; // N3 Store
  console.log(`Received ${dataset.size} quads`);
});

To parse RDF bodies on all routes, enable parseRdf at the plugin level:

typescript
await app.register(fastifyRdf, { parseRdf: true });

Routes without per-route or plugin-level parseRdf get JSON fallback for application/ld+json (parsed as plain JSON) and 415 Unsupported Media Type for other RDF content types.

Remote JSON-LD contexts ​

A JSON-LD body that references a remote @context, such as https://schema.org/, makes the parser fetch that context from the network. The live context can change under you: when schema.org dropped its @type: @id coercions, IRIs in request bodies started parsing as literals. Pass a fetch to control how such documents load, for example to answer them from a bundled copy:

typescript
await app.register(fastifyRdf, {
  fetch: async (input, init) =>
    String(input) === 'https://schema.org/'
      ? Response.json(bundledSchemaOrgContext, {
          headers: { 'content-type': 'application/ld+json' },
        })
      : fetch(input, init),
});

Request body errors ​

Request bodyResponse
Malformed JSON in an application/ld+json body400, from the JSON parser
Body that fails to parse as RDF, on a parseRdf route400 Invalid RDF body (the parse error as its cause)
Non-JSON-LD RDF content type, on a route without parseRdf415 Unsupported Media Type

Hydra Error Responses with reply.sendHydraError() ​

Send Hydra error responses with content negotiation. This maps error.message to hydra:title and error.cause (when it is a string) to hydra:description:

typescript
app.get('/resource', async (request, reply) => {
  const error = new Error('Not Found', {
    cause: 'The requested dataset was not found',
  }) as Error & { statusCode: number };
  error.statusCode = 404;
  return reply.sendHydraError(error);
});

The status code is taken from error.statusCode (standard in Fastify and http-errors), defaulting to 500.

For Accept: application/ld+json, the response is compact JSON-LD – no jsonld dependency needed:

json
{
  "@context": "http://www.w3.org/ns/hydra/core#",
  "@type": "Error",
  "title": "Not Found",
  "description": "The requested dataset was not found"
}

For all other RDF formats (Turtle, N-Triples, etc.), the error is serialised through the standard RDF pipeline.

Custom Default Content Type ​

By default, the plugin uses text/turtle when no Accept header is provided. You can change this:

typescript
await app.register(fastifyRdf, {
  defaultContentType: 'application/n-triples',
});

Content Negotiation ​

The plugin supports all content types provided by rdf-serialize, including:

  • text/turtle (Turtle)
  • application/n-triples (N-Triples)
  • application/n-quads (N-Quads)
  • application/ld+json (JSON-LD)
  • application/rdf+xml (RDF/XML)
  • And more...

Clients can request their preferred format via the Accept header:

bash
# Request Turtle
curl -H "Accept: text/turtle" http://localhost:3000/resource

# Request N-Triples
curl -H "Accept: application/n-triples" http://localhost:3000/resource

# No Accept header - defaults to Turtle
curl http://localhost:3000/resource

An Accept header that matches none of the supported types falls back to defaultContentType – the plugin never responds with 406 Not Acceptable.

SHACL Compact Syntax (text/shaclc*) output types are excluded from negotiation: serialising them requires a base IRI the plugin cannot provide generically.

API ​

Plugin Options ​

typescript
interface FastifyRdfOptions {
  /**
   * Default content type when no Accept header is provided.
   * @default 'text/turtle'
   */
  defaultContentType?: string;

  /**
   * Override reply.send() to serialise all responses as RDF.
   * When enabled, all payloads returned from route handlers will be
   * serialised as RDF without type checking.
   * @default false
   */
  overrideSend?: boolean;

  /**
   * Parse RDF request bodies into a DatasetCore on all routes.
   * @default false
   */
  parseRdf?: boolean;

  /**
   * Fetch used to load remote documents, such as JSON-LD contexts, while
   * parsing RDF request bodies.
   * @default globalThis.fetch
   */
  fetch?: typeof globalThis.fetch;
}

Supported Data Types ​

The plugin accepts the following RDF data types:

  • DatasetCore: RDF.js dataset (e.g., from n3 Store)
  • Stream: RDF.js quad stream (e.g., from parsers)
typescript
import type { RdfData, FastifyRdfOptions } from '@lde/fastify-rdf';

Other exports ​

  • createHydraErrorDataset(title, description?) – build the N3 Store holding a Hydra error’s triples (hydra:Error with hydra:title and optional hydra:description), for use outside reply.sendHydraError().
  • DEFAULT_CONTENT_TYPE – the built-in default content type, 'text/turtle'.

TypeScript ​

The plugin includes full TypeScript support with type augmentation for Fastify's reply object:

typescript
// reply.sendRdf() is automatically typed
app.get('/data', async (request, reply) => {
  return reply.sendRdf(dataset);
});

Released under the MIT License.