Skip to content
These docs describe arocapi v5, which is in beta. Install it with pnpm add arocapi@beta.

Your first API

With the database and index ready, you can register the plugin. This page builds a public repository that stores its RO-Crate metadata and files on local disk, then shows how to require a login for some of it.

arocapi needs seven options:

  • prisma: the client for your database (Data sources).
  • search: the search backend from the previous page (Search).
  • entityVisibilityFilter and searchVisibilityFilter: which entities exist for a request, as a Prisma condition and a condition over your declared search fields. Hidden entities never appear in any response (Visibility).
  • access: whether a request may read a visible entity’s full RO-Crate metadata and, for a file, its content (Access).
  • storage: where a file’s content and an entity’s RO-Crate metadata come from (Storage).

Every one of these functions gets the request context, { request, fastify }, so its answer can depend on who is asking. This first API answers the same for everyone: every entity is visible and every entity is accessible. Visibility vs Access explains how the two differ.

src/index.ts
import { PrismaPg } from '@prisma/adapter-pg';
import arocapi, { AllPublicAccess, AllVisibleEntityFilter, AllVisibleSearchFilter } from 'arocapi';
import { DirectoryStorage } from 'arocapi/storage/directory';
import Fastify from 'fastify';
import { PrismaClient } from './generated/prisma/client.ts';
import { search } from './search.ts';
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
const fastify = Fastify({ logger: true });
await fastify.register(arocapi, {
prisma,
search,
entityVisibilityFilter: AllVisibleEntityFilter,
searchVisibilityFilter: AllVisibleSearchFilter,
access: AllPublicAccess,
// Each entity's RO-Crate and each file sit below ./data, at the storagePath in their meta
storage: DirectoryStorage({ root: './data' }),
});
await fastify.listen({ port: 3000 });

DirectoryStorage serves files below ./data, at the path stored in each row’s meta.storagePath. A row without one, or a path with no file, gives a 404. The storage backend only runs after visibility and access have passed, so it never needs to check either. Storage strategies covers other ways to serve content, such as redirecting to object storage.

arocapi serves what is in your database and index; it doesn’t write to them. In a real repository your indexer fills both from your RO-Crates. For now, add a collection with one file by hand. Create its RO-Crate metadata and the file:

Terminal window
mkdir data
echo 'Hello from arocapi' > data/hello.txt
cat > data/ro-crate-metadata.json <<'EOF'
{
"@context": "https://w3id.org/ro/crate/1.1/context",
"@graph": [
{ "@id": "ro-crate-metadata.json", "@type": "CreativeWork", "about": { "@id": "./" } },
{ "@id": "./", "@type": "Dataset", "name": "My first collection", "hasPart": [{ "@id": "hello.txt" }] },
{ "@id": "hello.txt", "@type": "File", "name": "hello.txt", "encodingFormat": "text/plain" }
]
}
EOF

Then a script that records both entities in the database, then indexes their search documents:

scripts/seed.ts
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/generated/prisma/client.ts';
import { opensearch, search } from '../src/search.ts';
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
const licence = 'https://creativecommons.org/licenses/by/4.0/';
const collectionId = 'https://example.org/collection';
const fileId = 'https://example.org/collection/hello.txt';
const entities = [
{
id: collectionId,
name: 'My first collection',
description: 'A collection with one file',
entityType: 'http://pcdm.org/models#Collection',
memberOf: null,
rootCollection: null,
metadataLicenseId: licence,
contentLicenseId: licence,
},
{
id: fileId,
name: 'hello.txt',
description: 'A greeting',
entityType: 'http://schema.org/MediaObject',
memberOf: collectionId,
rootCollection: collectionId,
metadataLicenseId: licence,
contentLicenseId: licence,
},
];
await prisma.entity.create({
data: { ...entities[0], meta: { storagePath: 'ro-crate-metadata.json' } },
});
await prisma.entity.create({ data: entities[1] });
await prisma.file.create({
data: {
id: fileId,
filename: 'hello.txt',
mediaType: 'text/plain',
size: 19,
meta: { storagePath: 'hello.txt' },
},
});
// Each entity must exist before its search document is indexed
await search.index(entities.map(({ id, name, description, entityType }) => ({ id, name, description, entityType })));
await prisma.$disconnect();
await opensearch.close();

A file is two rows: an Entity that gives it its place in the collection and its licences, and a File with the same ID that describes the bytes. Each storagePath is relative to the ./data root given to DirectoryStorage.

Terminal window
node --env-file=.env scripts/seed.ts

Start the server:

Terminal window
node --env-file=.env src/index.ts

Entity IDs are URLs, so encode them in paths. Routes lists every route and its parameters.

Terminal window
curl http://localhost:3000/entities
curl http://localhost:3000/entity/https%3A%2F%2Fexample.org%2Fcollection
curl http://localhost:3000/entity/https%3A%2F%2Fexample.org%2Fcollection/metadata
curl http://localhost:3000/file/https%3A%2F%2Fexample.org%2Fcollection%2Fhello.txt
curl -X POST http://localhost:3000/search -H 'Content-Type: application/json' -d '{"query": "greeting"}'

Each entity in a response carries an access object saying whether this request may read its metadata and content:

{
"id": "https://example.org/collection",
"name": "My first collection",
"description": "A collection with one file",
"entityType": "http://pcdm.org/models#Collection",
"memberOf": null,
"rootCollection": null,
"metadataLicenseId": "https://creativecommons.org/licenses/by/4.0/",
"contentLicenseId": "https://creativecommons.org/licenses/by/4.0/",
"access": { "metadata": true, "content": true }
}

Most repositories have content that only some people may read. Replace AllPublicAccess with an access function that checks licences, and tell arocapi how to recognise a logged-in request with isAuthenticated. How a request proves who it is stays up to your app; this example accepts a single bearer token from API_TOKEN, and treats every request as Anonymous if it isn’t set.

src/index.ts
import arocapi, {
AllVisibleEntityFilter,
AllVisibleSearchFilter,
type IsAuthenticated,
LicenceAccess,
type Options,
} from 'arocapi';
import type { FastifyInstance } from 'fastify';
// Unchanged from the version above
declare const fastify: FastifyInstance;
declare const prisma: Options['prisma'];
declare const search: Options['search'];
declare const storage: Options['storage'];
const openLicences = new Set(['https://creativecommons.org/licenses/by/4.0/']);
const apiToken = process.env.API_TOKEN;
const isAuthenticated: IsAuthenticated = ({ request }) =>
Boolean(apiToken) && request.headers.authorization === `Bearer ${apiToken}`;
const access = LicenceAccess({
hasLicence: async (licenceId, context) => openLicences.has(licenceId) || (await isAuthenticated(context)),
authorisationUrl: (licenceId) => `https://example.org/request-access?licence=${encodeURIComponent(licenceId)}`,
});
await fastify.register(arocapi, {
prisma,
search,
entityVisibilityFilter: AllVisibleEntityFilter,
searchVisibilityFilter: AllVisibleSearchFilter,
access,
isAuthenticated,
storage,
});

Now an entity whose content licence isn’t open is still listed, with access.content set to false and a contentAuthorizationUrl saying where to ask for it. Fetching its file without the token gets a 401, so a client knows to prompt for a login. With the token it gets the file. In a repository where logged-in users hold different licences, a logged-in request without the licence gets a 403.

Visibility filters work the same way: they receive the request context, so an entity can be hidden from some requests and not others. Visibility vs Access follows a worked example through every route, and Transformers shows how to add your own fields to responses.