Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 57 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ await server.register(arocapi, {
async (entity, { fastify }) => {
const objectCount = entity.memberOf
? await fastify.prisma.entity.count({
where: { memberOf: entity.rocrateId },
where: { memberOf: entity.id },
})
: 0;

Expand Down Expand Up @@ -638,12 +638,60 @@ The `/file/:id` endpoint supports these query parameters:

### HTTP Range Support

The endpoint automatically handles HTTP range requests for partial content,
useful for media streaming:
Range request support for media streaming can be implemented in your file
handler by parsing the `Range` header from the request context and returning
a partial stream:

- Returns **206 Partial Content** for valid range requests
- Returns **416 Range Not Satisfiable** for invalid ranges
- Sets appropriate `Content-Range` and `Accept-Ranges` headers
```typescript
fileHandler: {
get: async (file, { request }) => {
const range = request.headers.range;
// Parse range header and return partial content stream
// Your storage backend (S3, filesystem, etc.) likely supports range reads natively
},
}
```

### Authentication and Authorisation

The library delegates authentication and authorisation to your transformers.
The access transformer receives the full Fastify request context, so you can
verify credentials and set access flags accordingly:

```typescript
accessTransformer: async (entity, { request }) => {
const token = request.headers.authorization;
const user = await verifyToken(token);

if (!user) {
// Unauthenticated — metadata visible but content restricted
return {
...entity,
access: {
metadata: true,
content: false,
contentAuthorizationUrl: 'https://auth.example.com/login',
},
};
}

const canAccess = await checkUserLicense(user, entity.contentLicenseId);

return {
...entity,
access: {
metadata: true,
content: canAccess,
contentAuthorizationUrl: canAccess
? undefined
: 'https://rems.example.com/request-access',
},
};
}
```

The same pattern applies to `fileAccessTransformer` for controlling file
content downloads.

### Entity Meta Field

Expand All @@ -653,10 +701,10 @@ metadata for your file handler:
```typescript
await prisma.entity.create({
data: {
rocrateId: 'http://example.com/file/123',
id: 'http://example.com/file/123',
name: 'audio.wav',
entityType: 'http://schema.org/MediaObject',
// ... other required fields
// ... other required fields (memberOf, rootCollection, metadataLicenseId, contentLicenseId)
meta: {
bucket: 's3://my-bucket',
storagePath: 'collections/col-01',
Expand Down Expand Up @@ -739,7 +787,7 @@ your-project/
├── prisma/
│ ├── schema.prisma # Prisma schema
│ ├── models/ # Your custom models
│ └── upstream/ # Symlink to arocapi models
│ └── arocapi/ # Symlink to arocapi models
├── prisma.config.ts # Prisma configuration
├── .env # Environment variables
└── package.json
Expand Down
23 changes: 8 additions & 15 deletions example/data/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const processCollection = async (
}

const collectionEntity = {
rocrateId: collectionRocrateId,
id: collectionRocrateId,
name: collectionRoot.name || 'Untitled Collection',
description: collectionRoot.description || '',
entityType: extractEntityType(collectionRoot['@type']),
Expand Down Expand Up @@ -104,7 +104,7 @@ const processItem = async (
}

const itemEntity = {
rocrateId: itemRocrateId,
id: itemRocrateId,
name: itemRoot.name || 'Untitled Item',
description: itemRoot.description || '',
entityType: extractEntityType(itemRoot['@type']),
Expand Down Expand Up @@ -148,11 +148,10 @@ const processItem = async (
const stats = statSync(filePath);

const fileEntity = {
rocrateId: fileRocrateId,
id: fileRocrateId,
name: fileNode.name || filename,
description: fileNode.description || '',
entityType: 'http://schema.org/MediaObject',
fileId: fileRocrateId,
memberOf: itemRocrateId,
rootCollection: collectionRocrateId,
metadataLicenseId:
Expand All @@ -176,17 +175,11 @@ const processItem = async (
});

const fileRecord = {
fileId: fileRocrateId,
id: fileRocrateId,
entityId: fileRocrateId,
filename: filename,
mediaType: (fileNode.encodingFormat as string) || 'application/octet-stream',
size: BigInt(stats.size),
memberOf: itemRocrateId,
rootCollection: collectionRocrateId,
contentLicenseId:
fileNode.license?.['@id'] ||
itemRoot.contentLicense?.['@id'] ||
itemRoot.license?.['@id'] ||
'https://creativecommons.org/licenses/by/4.0/',
meta: {
storagePath: filePath,
},
Expand Down Expand Up @@ -217,7 +210,7 @@ const createOpenSearchIndex = async (): Promise<void> => {
body: {
mappings: {
properties: {
rocrateId: { type: 'keyword' },
id: { type: 'keyword' },
name: {
type: 'text',
fields: {
Expand Down Expand Up @@ -249,7 +242,7 @@ const indexEntities = async (): Promise<void> => {
const operations = entities.flatMap((entity) => [
{ index: { _index: 'entities', _id: `${entity.id}` } },
{
rocrateId: entity.rocrateId,
id: entity.id,
name: entity.name,
description: entity.description,
entityType: entity.entityType,
Expand All @@ -273,8 +266,8 @@ const seed = async (): Promise<void> => {

try {
console.log('Clearing existing data...');
await prisma.entity.deleteMany({});
await prisma.file.deleteMany({});
await prisma.entity.deleteMany({});
console.log(' ✓ Database cleared');

await processCollection('collection-01-nyeleni', 'http://example.com/collection/nyeleni-001', [
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"dist",
"!dist/generated",
"README.md",
"prisma",
"prisma/models",
"prisma.config.ts"
],
"scripts": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- RenameTable
RENAME TABLE `Entity` TO `entity`;

-- RenameTable
RENAME TABLE `File` TO `file`;
46 changes: 46 additions & 0 deletions prisma/migrations/20260327000000_schema_improvements/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
-- AlterTable: reduce column sizes on entity
ALTER TABLE `entity` MODIFY `rocrateId` VARCHAR(768) NOT NULL,
MODIFY `name` VARCHAR(512) NOT NULL,
MODIFY `entityType` VARCHAR(255) NOT NULL,
MODIFY `memberOf` VARCHAR(768) NULL,
MODIFY `rootCollection` VARCHAR(768) NULL,
MODIFY `metadataLicenseId` VARCHAR(255) NOT NULL,
MODIFY `contentLicenseId` VARCHAR(255) NOT NULL;

-- AlterTable: reduce column sizes on file, add entityId
ALTER TABLE `file` MODIFY `fileId` VARCHAR(768) NOT NULL;

-- Populate entityId from entity.fileId before making it required
ALTER TABLE `file` ADD COLUMN `entityId` VARCHAR(768) NULL;

UPDATE `file`
INNER JOIN `entity` ON `entity`.`fileId` = `file`.`fileId`
SET `file`.`entityId` = `entity`.`rocrateId`;

ALTER TABLE `file` MODIFY `entityId` VARCHAR(768) NOT NULL;

-- DropColumn: remove duplicated fields from file
ALTER TABLE `file` DROP COLUMN `contentLicenseId`,
DROP COLUMN `memberOf`,
DROP COLUMN `rootCollection`;

-- DropColumn: remove fileId from entity
ALTER TABLE `entity` DROP COLUMN `fileId`;

-- CreateIndex: unique constraint on entity.rocrateId
CREATE UNIQUE INDEX `entity_rocrateId_key` ON `entity`(`rocrateId`);

-- CreateIndex: indexes on entity
CREATE INDEX `entity_memberOf_idx` ON `entity`(`memberOf`);
CREATE INDEX `entity_rootCollection_idx` ON `entity`(`rootCollection`);
CREATE INDEX `entity_entityType_idx` ON `entity`(`entityType`);

-- DropIndex: old prefix-based unique index on file.fileId
DROP INDEX `File_fileId_key` ON `file`;

-- CreateIndex: unique constraints on file
CREATE UNIQUE INDEX `file_fileId_key` ON `file`(`fileId`);
CREATE UNIQUE INDEX `file_entityId_key` ON `file`(`entityId`);

-- AddForeignKey
ALTER TABLE `file` ADD CONSTRAINT `file_entityId_fkey` FOREIGN KEY (`entityId`) REFERENCES `entity`(`rocrateId`) ON DELETE RESTRICT ON UPDATE CASCADE;
21 changes: 21 additions & 0 deletions prisma/migrations/20260327100000_rename_ids/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Drop foreign key constraint
ALTER TABLE `file` DROP FOREIGN KEY `file_entityId_fkey`;

-- Drop unique constraints that reference old columns
DROP INDEX `entity_rocrateId_key` ON `entity`;
DROP INDEX `file_fileId_key` ON `file`;

-- Entity: drop auto-increment PK, rename rocrateId to id, make it the PK
ALTER TABLE `entity` DROP PRIMARY KEY,
DROP COLUMN `id`,
CHANGE COLUMN `rocrateId` `id` VARCHAR(768) NOT NULL,
ADD PRIMARY KEY (`id`);

-- File: drop auto-increment PK, rename fileId to id, make it the PK
ALTER TABLE `file` DROP PRIMARY KEY,
DROP COLUMN `id`,
CHANGE COLUMN `fileId` `id` VARCHAR(768) NOT NULL,
ADD PRIMARY KEY (`id`);

-- Re-add foreign key referencing the renamed Entity.id
ALTER TABLE `file` ADD CONSTRAINT `file_entityId_fkey` FOREIGN KEY (`entityId`) REFERENCES `entity`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
30 changes: 17 additions & 13 deletions prisma/models/entity.prisma
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
model Entity {
id Int @id @default(autoincrement())
id String @id @db.VarChar(768)

rocrateId String @db.VarChar(2048)
name String @db.VarChar(512)
description String @db.Text

name String @db.VarChar(1024)
description String @db.Text
entityType String @db.VarChar(255)
memberOf String? @db.VarChar(768)
rootCollection String? @db.VarChar(768)
metadataLicenseId String @db.VarChar(255)
contentLicenseId String @db.VarChar(255)

entityType String @db.VarChar(1024)
memberOf String? @db.VarChar(2048)
rootCollection String? @db.VarChar(2048)
metadataLicenseId String @db.VarChar(2048)
contentLicenseId String @db.VarChar(2048)
fileId String? @db.VarChar(2048)
meta Json?

meta Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
file File?

@@index([memberOf])
@@index([rootCollection])
@@index([entityType])
@@map("entity")
}
24 changes: 11 additions & 13 deletions prisma/models/file.prisma
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
model File {
id Int @id @default(autoincrement())
id String @id @db.VarChar(768)
entityId String @db.VarChar(768)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can also remove the entityId altogether because the id is already unique and corresponds one-to-one with the id of the Entity table.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alvinsw I'll see where this ends up after I merge everything.
It was supposed to go but may have been lost when I split all the commits at the end.


fileId String @db.VarChar(2048)
filename String @db.VarChar(255)
mediaType String @db.VarChar(127)
size BigInt

filename String @db.VarChar(255)
mediaType String @db.VarChar(127)
size BigInt
meta Json?

memberOf String @db.VarChar(2048)
rootCollection String @db.VarChar(2048)
contentLicenseId String @db.VarChar(2048)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

meta Json?
entity Entity @relation(fields: [entityId], references: [id])
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
entity Entity @relation(fields: [entityId], references: [id])
entity Entity @relation(fields: [id], references: [id])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Example if we get rid of the entityId


createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([fileId])
@@unique([entityId])
@@map("file")
}
4 changes: 2 additions & 2 deletions src/routes/__snapshots__/search.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ exports[`Search Route > POST /search > should apply custom entity transformers 1
}
`;

exports[`Search Route > POST /search > should handle opensearch errors 1`] = `
exports[`Search Route > POST /search > should handle id explicitly set to undefined 1`] = `
{
"error": {
"code": "INTERNAL_ERROR",
Expand All @@ -37,7 +37,7 @@ exports[`Search Route > POST /search > should handle opensearch errors 1`] = `
}
`;

exports[`Search Route > POST /search > should handle rocrateId explicitly set to undefined 1`] = `
exports[`Search Route > POST /search > should handle opensearch errors 1`] = `
{
"error": {
"code": "INTERNAL_ERROR",
Expand Down
Loading