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.
Register the plugin
Section titled “Register the plugin”arocapi needs seven options:
prisma: the client for your database (Data sources).search: the search backend from the previous page (Search).entityVisibilityFilterandsearchVisibilityFilter: 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.
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.
Add an entity
Section titled “Add an entity”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:
mkdir dataecho 'Hello from arocapi' > data/hello.txtcat > 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" } ]}EOFThen a script that records both entities in the database, then indexes their search documents:
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 indexedawait 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.
node --env-file=.env scripts/seed.tsCall the API
Section titled “Call the API”Start the server:
node --env-file=.env src/index.tsEntity IDs are URLs, so encode them in paths. Routes lists every route and its parameters.
curl http://localhost:3000/entitiescurl http://localhost:3000/entity/https%3A%2F%2Fexample.org%2Fcollectioncurl http://localhost:3000/entity/https%3A%2F%2Fexample.org%2Fcollection/metadatacurl http://localhost:3000/file/https%3A%2F%2Fexample.org%2Fcollection%2Fhello.txtcurl -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 }}Require a login for some content
Section titled “Require a login for some content”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.
import arocapi, { AllVisibleEntityFilter, AllVisibleSearchFilter, type IsAuthenticated, LicenceAccess, type Options,} from 'arocapi';import type { FastifyInstance } from 'fastify';
// Unchanged from the version abovedeclare 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.