diff --git a/README.md b/README.md index 7391128..2e4237d 100644 --- a/README.md +++ b/README.md @@ -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; @@ -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 @@ -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', @@ -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 diff --git a/example/data/seed.ts b/example/data/seed.ts index dab21bb..7e3b8f8 100644 --- a/example/data/seed.ts +++ b/example/data/seed.ts @@ -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']), @@ -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']), @@ -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: @@ -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, }, @@ -217,7 +210,7 @@ const createOpenSearchIndex = async (): Promise => { body: { mappings: { properties: { - rocrateId: { type: 'keyword' }, + id: { type: 'keyword' }, name: { type: 'text', fields: { @@ -249,7 +242,7 @@ const indexEntities = async (): Promise => { 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, @@ -273,8 +266,8 @@ const seed = async (): Promise => { 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', [ diff --git a/package.json b/package.json index f79995b..2d60e1a 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "dist", "!dist/generated", "README.md", - "prisma", + "prisma/models", "prisma.config.ts" ], "scripts": { diff --git a/prisma/migrations/20260326000000_lowercase_table_names/migration.sql b/prisma/migrations/20260326000000_lowercase_table_names/migration.sql new file mode 100644 index 0000000..4d6c8ee --- /dev/null +++ b/prisma/migrations/20260326000000_lowercase_table_names/migration.sql @@ -0,0 +1,5 @@ +-- RenameTable +RENAME TABLE `Entity` TO `entity`; + +-- RenameTable +RENAME TABLE `File` TO `file`; diff --git a/prisma/migrations/20260327000000_schema_improvements/migration.sql b/prisma/migrations/20260327000000_schema_improvements/migration.sql new file mode 100644 index 0000000..11f1c6a --- /dev/null +++ b/prisma/migrations/20260327000000_schema_improvements/migration.sql @@ -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; diff --git a/prisma/migrations/20260327100000_rename_ids/migration.sql b/prisma/migrations/20260327100000_rename_ids/migration.sql new file mode 100644 index 0000000..3f4a9f7 --- /dev/null +++ b/prisma/migrations/20260327100000_rename_ids/migration.sql @@ -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; diff --git a/prisma/models/entity.prisma b/prisma/models/entity.prisma index e932427..6625bcd 100644 --- a/prisma/models/entity.prisma +++ b/prisma/models/entity.prisma @@ -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") } diff --git a/prisma/models/file.prisma b/prisma/models/file.prisma index f189149..ec48165 100644 --- a/prisma/models/file.prisma +++ b/prisma/models/file.prisma @@ -1,20 +1,18 @@ model File { - id Int @id @default(autoincrement()) + id String @id @db.VarChar(768) + entityId String @db.VarChar(768) - 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]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([fileId]) + @@unique([entityId]) + @@map("file") } diff --git a/src/routes/__snapshots__/search.test.ts.snap b/src/routes/__snapshots__/search.test.ts.snap index fe1632c..c0055bf 100644 --- a/src/routes/__snapshots__/search.test.ts.snap +++ b/src/routes/__snapshots__/search.test.ts.snap @@ -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", @@ -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", diff --git a/src/routes/crate.test.ts b/src/routes/crate.test.ts index 5f8e436..7176bbe 100644 --- a/src/routes/crate.test.ts +++ b/src/routes/crate.test.ts @@ -27,12 +27,10 @@ describe('Crate Route', () => { }); const mockFileEntity = { - id: 1, - rocrateId: 'http://example.com/entity/file.wav', + id: 'http://example.com/entity/file.wav', name: 'test.wav', description: 'A test file', entityType: 'http://schema.org/MediaObject', - fileId: null, memberOf: 'http://example.com/collection', rootCollection: 'http://example.com/collection', metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', @@ -44,7 +42,7 @@ describe('Crate Route', () => { const mockCollectionEntity = { ...mockFileEntity, - rocrateId: 'http://example.com/collection', + id: 'http://example.com/collection', name: 'Test Collection', entityType: 'http://pcdm.org/models#Collection', memberOf: null, @@ -53,7 +51,7 @@ describe('Crate Route', () => { const mockObjectEntity = { ...mockFileEntity, - rocrateId: 'http://example.com/object', + id: 'http://example.com/object', name: 'Test Object', entityType: 'http://pcdm.org/models#Object', memberOf: 'http://example.com/collection', @@ -62,7 +60,7 @@ describe('Crate Route', () => { describe('GET /entity/:id/rocrate', () => { it('should stream RO-Crate metadata for File entity', async () => { - prisma.entity.findFirst.mockResolvedValue(mockFileEntity); + prisma.entity.findUnique.mockResolvedValue(mockFileEntity); const mockStream = Readable.from(['{"@context": "https://w3id.org/ro/crate/1.1/context"}']); const mockResult: FileResult = { @@ -98,7 +96,7 @@ describe('Crate Route', () => { }); it('should stream RO-Crate metadata for Collection entity', async () => { - prisma.entity.findFirst.mockResolvedValue(mockCollectionEntity); + prisma.entity.findUnique.mockResolvedValue(mockCollectionEntity); const mockStream = Readable.from(['{"@context": "https://w3id.org/ro/crate/1.1/context"}']); const mockResult: FileResult = { @@ -122,7 +120,7 @@ describe('Crate Route', () => { }); it('should stream RO-Crate metadata for Object entity', async () => { - prisma.entity.findFirst.mockResolvedValue(mockObjectEntity); + prisma.entity.findUnique.mockResolvedValue(mockObjectEntity); const mockStream = Readable.from(['{"@context": "https://w3id.org/ro/crate/1.1/context"}']); const mockResult: FileResult = { @@ -146,7 +144,7 @@ describe('Crate Route', () => { }); it('should handle redirect response', async () => { - prisma.entity.findFirst.mockResolvedValue(mockFileEntity); + prisma.entity.findUnique.mockResolvedValue(mockFileEntity); const mockResult: FileResult = { type: 'redirect', @@ -164,7 +162,7 @@ describe('Crate Route', () => { }); it('should handle file path response without nginx X-Accel-Redirect', async () => { - prisma.entity.findFirst.mockResolvedValue(mockCollectionEntity); + prisma.entity.findUnique.mockResolvedValue(mockCollectionEntity); const mockStream = Readable.from(['rocrate content']); vi.mocked(createReadStream).mockReturnValue(mockStream as never); @@ -192,7 +190,7 @@ describe('Crate Route', () => { }); it('should handle file path response with nginx X-Accel-Redirect', async () => { - prisma.entity.findFirst.mockResolvedValue(mockFileEntity); + prisma.entity.findUnique.mockResolvedValue(mockFileEntity); const mockResult: FileResult = { type: 'file', @@ -217,7 +215,7 @@ describe('Crate Route', () => { }); it('should return 404 when entity not found', async () => { - prisma.entity.findFirst.mockResolvedValue(null); + prisma.entity.findUnique.mockResolvedValue(null); const response = await fastify.inject({ method: 'GET', @@ -232,7 +230,7 @@ describe('Crate Route', () => { }); it('should return 404 when handler returns false', async () => { - prisma.entity.findFirst.mockResolvedValue(mockFileEntity); + prisma.entity.findUnique.mockResolvedValue(mockFileEntity); vi.mocked(mockRoCrateHandler.get).mockResolvedValue(false); const response = await fastify.inject({ @@ -247,7 +245,7 @@ describe('Crate Route', () => { }); it('should return 500 when database error occurs', async () => { - prisma.entity.findFirst.mockRejectedValue(new Error('Database connection failed')); + prisma.entity.findUnique.mockRejectedValue(new Error('Database connection failed')); const response = await fastify.inject({ method: 'GET', @@ -260,7 +258,7 @@ describe('Crate Route', () => { }); it('should return 500 when roCrateHandler throws error', async () => { - prisma.entity.findFirst.mockResolvedValue(mockFileEntity); + prisma.entity.findUnique.mockResolvedValue(mockFileEntity); vi.mocked(mockRoCrateHandler.get).mockRejectedValue(new Error('RO-Crate error')); const response = await fastify.inject({ @@ -287,7 +285,7 @@ describe('Crate Route', () => { describe('HEAD /entity/:id/rocrate', () => { it('should return RO-Crate metadata headers for File entity', async () => { - prisma.entity.findFirst.mockResolvedValue(mockFileEntity); + prisma.entity.findUnique.mockResolvedValue(mockFileEntity); const mockMetadata = { contentType: 'application/ld+json', @@ -318,7 +316,7 @@ describe('Crate Route', () => { }); it('should return RO-Crate metadata headers for Collection entity', async () => { - prisma.entity.findFirst.mockResolvedValue(mockCollectionEntity); + prisma.entity.findUnique.mockResolvedValue(mockCollectionEntity); const mockMetadata = { contentType: 'text/plain', @@ -338,7 +336,7 @@ describe('Crate Route', () => { }); it('should return RO-Crate metadata headers for Object entity', async () => { - prisma.entity.findFirst.mockResolvedValue(mockObjectEntity); + prisma.entity.findUnique.mockResolvedValue(mockObjectEntity); const mockMetadata = { contentType: 'application/json', @@ -358,7 +356,7 @@ describe('Crate Route', () => { }); it('should return 404 when entity not found', async () => { - prisma.entity.findFirst.mockResolvedValue(null); + prisma.entity.findUnique.mockResolvedValue(null); const response = await fastify.inject({ method: 'HEAD', @@ -372,7 +370,7 @@ describe('Crate Route', () => { }); it('should return 404 when head handler returns false', async () => { - prisma.entity.findFirst.mockResolvedValue(mockFileEntity); + prisma.entity.findUnique.mockResolvedValue(mockFileEntity); vi.mocked(mockRoCrateHandler.head).mockResolvedValue(false); const response = await fastify.inject({ @@ -387,7 +385,7 @@ describe('Crate Route', () => { }); it('should return 500 when head handler throws error', async () => { - prisma.entity.findFirst.mockResolvedValue(mockFileEntity); + prisma.entity.findUnique.mockResolvedValue(mockFileEntity); vi.mocked(mockRoCrateHandler.head).mockRejectedValue(new Error('Metadata error')); const response = await fastify.inject({ @@ -403,7 +401,7 @@ describe('Crate Route', () => { describe('GET /entity/:id/rocrate - exhaustiveness check', () => { it('should handle unexpected RO-Crate result type', async () => { - prisma.entity.findFirst.mockResolvedValue(mockFileEntity); + prisma.entity.findUnique.mockResolvedValue(mockFileEntity); // Mock an invalid result type to test exhaustiveness check const invalidResult = { diff --git a/src/routes/crate.ts b/src/routes/crate.ts index b2de4d9..2279cea 100644 --- a/src/routes/crate.ts +++ b/src/routes/crate.ts @@ -1,9 +1,10 @@ import { createReadStream } from 'node:fs'; -import type { FastifyPluginAsync, FastifyReply } from 'fastify'; +import type { FastifyPluginAsync } from 'fastify'; import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod/v4'; import type { FileMetadata, RoCrateHandler } from '../types/fileHandlers.js'; import { createInternalError, createNotFoundError } from '../utils/errors.js'; +import { setFileHeaders } from '../utils/headers.js'; const paramsSchema = z.object({ id: z.url(), @@ -13,21 +14,6 @@ type CrateRouteOptions = { roCrateHandler: RoCrateHandler; }; -const setFileHeaders = ( - reply: FastifyReply, - metadata: { contentType: string; contentLength: number; etag?: string; lastModified?: Date }, -) => { - reply.header('Content-Type', metadata.contentType); - reply.header('Content-Length', metadata.contentLength.toString()); - - if (metadata.etag) { - reply.header('ETag', metadata.etag); - } - if (metadata.lastModified) { - reply.header('Last-Modified', metadata.lastModified.toUTCString()); - } -}; - const crate: FastifyPluginAsync = async (fastify, opts) => { const { roCrateHandler } = opts; @@ -42,10 +28,8 @@ const crate: FastifyPluginAsync = async (fastify, opts) => { const { id } = request.params; try { - const entity = await fastify.prisma.entity.findFirst({ - where: { - rocrateId: id, - }, + const entity = await fastify.prisma.entity.findUnique({ + where: { id }, }); if (!entity) { @@ -84,10 +68,8 @@ const crate: FastifyPluginAsync = async (fastify, opts) => { const { id } = request.params; try { - const entity = await fastify.prisma.entity.findFirst({ - where: { - rocrateId: id, - }, + const entity = await fastify.prisma.entity.findUnique({ + where: { id }, }); if (!entity) { diff --git a/src/routes/entities.test.ts b/src/routes/entities.test.ts index c213a11..811f92f 100644 --- a/src/routes/entities.test.ts +++ b/src/routes/entities.test.ts @@ -18,12 +18,11 @@ describe('Entities Route', () => { it('should return entities with default pagination', async () => { const mockEntities = [ { - id: 1, - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', name: 'Test Entity 1', description: 'First test entity', entityType: 'http://pcdm.org/models#Collection', - fileId: null, + memberOf: null, rootCollection: null, metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', @@ -33,12 +32,11 @@ describe('Entities Route', () => { meta: {}, }, { - id: 2, - rocrateId: 'http://example.com/entity/2', + id: 'http://example.com/entity/2', name: 'Test Entity 2', description: 'Second test entity', entityType: 'http://pcdm.org/models#Object', - fileId: null, + memberOf: 'http://example.com/entity/1', rootCollection: 'http://example.com/entity/1', metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', @@ -62,7 +60,8 @@ describe('Entities Route', () => { expect(body).toMatchSnapshot(); expect(prisma.entity.findMany).toHaveBeenCalledWith({ where: {}, - orderBy: { rocrateId: 'asc' }, + include: { file: { select: { id: true } } }, + orderBy: { id: 'asc' }, skip: 0, take: 100, }); @@ -83,7 +82,8 @@ describe('Entities Route', () => { expect(response.statusCode).toBe(200); expect(prisma.entity.findMany).toHaveBeenCalledWith({ where: { memberOf: 'http://example.com/collection/1' }, - orderBy: { rocrateId: 'asc' }, + include: { file: { select: { id: true } } }, + orderBy: { id: 'asc' }, skip: 0, take: 100, }); @@ -104,7 +104,8 @@ describe('Entities Route', () => { expect(response.statusCode).toBe(200); expect(prisma.entity.findMany).toHaveBeenCalledWith({ where: { entityType: { in: ['http://pcdm.org/models#Collection'] } }, - orderBy: { rocrateId: 'asc' }, + include: { file: { select: { id: true } } }, + orderBy: { id: 'asc' }, skip: 0, take: 100, }); @@ -129,7 +130,8 @@ describe('Entities Route', () => { in: ['http://pcdm.org/models#Collection', 'http://pcdm.org/models#Object'], }, }, - orderBy: { rocrateId: 'asc' }, + include: { file: { select: { id: true } } }, + orderBy: { id: 'asc' }, skip: 0, take: 100, }); @@ -151,7 +153,8 @@ describe('Entities Route', () => { expect(response.statusCode).toBe(200); expect(prisma.entity.findMany).toHaveBeenCalledWith({ where: {}, - orderBy: { rocrateId: 'asc' }, + include: { file: { select: { id: true } } }, + orderBy: { id: 'asc' }, skip: 10, take: 50, }); @@ -173,13 +176,14 @@ describe('Entities Route', () => { expect(response.statusCode).toBe(200); expect(prisma.entity.findMany).toHaveBeenCalledWith({ where: {}, + include: { file: { select: { id: true } } }, orderBy: { name: 'desc' }, skip: 0, take: 100, }); }); - it('should map id sort to rocrateId field', async () => { + it('should sort by id field directly', async () => { prisma.entity.findMany.mockResolvedValue([]); prisma.entity.count.mockResolvedValue(0); @@ -194,7 +198,8 @@ describe('Entities Route', () => { expect(response.statusCode).toBe(200); expect(prisma.entity.findMany).toHaveBeenCalledWith({ where: {}, - orderBy: { rocrateId: 'asc' }, + include: { file: { select: { id: true } } }, + orderBy: { id: 'asc' }, skip: 0, take: 100, }); @@ -252,8 +257,7 @@ describe('Entities Route', () => { const mockEntities = [ { - id: 1, - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', name: 'Test Entity 1', description: 'First test entity', entityType: 'http://pcdm.org/models#Collection', @@ -282,12 +286,11 @@ describe('Entities Route', () => { it('should return null for memberOf/rootCollection when parent entity not found', async () => { const mockEntities = [ { - id: 1, - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', name: 'Test Entity 1', description: 'Entity with missing parent', entityType: 'http://pcdm.org/models#Object', - fileId: null, + memberOf: 'http://example.com/entity/deleted', rootCollection: 'http://example.com/entity/deleted', metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', diff --git a/src/routes/entities.ts b/src/routes/entities.ts index 2e21265..53144dc 100644 --- a/src/routes/entities.ts +++ b/src/routes/entities.ts @@ -52,18 +52,18 @@ const entities: FastifyPluginAsync = async (fastify, opts) }; } - const sortField = sort === 'id' ? 'rocrateId' : sort; - - const dbEntities = await fastify.prisma.entity.findMany({ - where, - orderBy: { - [sortField]: order, - }, - skip: offset, - take: limit, - }); - - const total = await fastify.prisma.entity.count({ where }); + const [dbEntities, total] = await Promise.all([ + fastify.prisma.entity.findMany({ + where, + include: { file: { select: { id: true } } }, + orderBy: { + [sort]: order, + }, + skip: offset, + take: limit, + }), + fastify.prisma.entity.count({ where }), + ]); // Resolve memberOf and rootCollection references const refMap = await resolveEntityReferences(dbEntities, fastify.prisma); diff --git a/src/routes/entity.test.ts b/src/routes/entity.test.ts index c2320f7..f65b68b 100644 --- a/src/routes/entity.test.ts +++ b/src/routes/entity.test.ts @@ -18,12 +18,11 @@ describe('Entity Route', () => { describe('GET /entity/:id', () => { it('should return entity when found', async () => { const mockEntity = { - id: 1, - rocrateId: 'http://example.com/entity/123', + id: 'http://example.com/entity/123', name: 'Test Entity', description: 'A test entity', entityType: 'http://schema.org/Person', - fileId: null, + memberOf: null, rootCollection: null, metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', @@ -33,7 +32,7 @@ describe('Entity Route', () => { meta: {}, }; - prisma.entity.findFirst.mockResolvedValue(mockEntity); + prisma.entity.findUnique.mockResolvedValue(mockEntity); const response = await fastify.inject({ method: 'GET', @@ -43,15 +42,16 @@ describe('Entity Route', () => { expect(response.statusCode).toBe(200); expect(body).toMatchSnapshot(); - expect(prisma.entity.findFirst).toHaveBeenCalledWith({ + expect(prisma.entity.findUnique).toHaveBeenCalledWith({ where: { - rocrateId: 'http://example.com/entity/123', + id: 'http://example.com/entity/123', }, + include: { file: { select: { id: true } } }, }); }); it('should return 404 when entity not found', async () => { - prisma.entity.findFirst.mockResolvedValue(null); + prisma.entity.findUnique.mockResolvedValue(null); const response = await fastify.inject({ method: 'GET', @@ -64,7 +64,7 @@ describe('Entity Route', () => { }); it('should return 500 when database error occurs', async () => { - prisma.entity.findFirst.mockRejectedValue(new Error('Database connection failed')); + prisma.entity.findUnique.mockRejectedValue(new Error('Database connection failed')); const response = await fastify.inject({ method: 'GET', @@ -101,8 +101,7 @@ describe('Entity Route', () => { }); const mockEntity = { - id: 1, - rocrateId: 'http://example.com/entity/123', + id: 'http://example.com/entity/123', name: 'Test Entity', description: 'A test entity', entityType: 'http://pcdm.org/models#Collection', @@ -115,7 +114,7 @@ describe('Entity Route', () => { }; // @ts-expect-error TS is looking at the wronf function signature - prisma.entity.findFirst.mockResolvedValue(mockEntity); + prisma.entity.findUnique.mockResolvedValue(mockEntity); const response = await fastify.inject({ method: 'GET', @@ -129,12 +128,11 @@ describe('Entity Route', () => { it('should return null for memberOf/rootCollection when parent entity not found', async () => { const mockEntity = { - id: 1, - rocrateId: 'http://example.com/entity/123', + id: 'http://example.com/entity/123', name: 'Test Entity', description: 'A test entity', entityType: 'http://pcdm.org/models#Object', - fileId: null, + memberOf: 'http://example.com/entity/deleted', rootCollection: 'http://example.com/entity/deleted', metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', @@ -145,7 +143,7 @@ describe('Entity Route', () => { }; // First call returns the entity, second call (for reference resolution) returns empty - prisma.entity.findFirst.mockResolvedValue(mockEntity); + prisma.entity.findUnique.mockResolvedValue(mockEntity); prisma.entity.findMany.mockResolvedValue([]); const response = await fastify.inject({ diff --git a/src/routes/entity.ts b/src/routes/entity.ts index 8de22b1..56b1fe5 100644 --- a/src/routes/entity.ts +++ b/src/routes/entity.ts @@ -27,10 +27,9 @@ const entity: FastifyPluginAsync = async (fastify, opts) => const { id } = request.params; try { - const entity = await fastify.prisma.entity.findFirst({ - where: { - rocrateId: id, - }, + const entity = await fastify.prisma.entity.findUnique({ + where: { id }, + include: { file: { select: { id: true } } }, }); if (!entity) { diff --git a/src/routes/file.test.ts b/src/routes/file.test.ts index e0e163a..ea64c3f 100644 --- a/src/routes/file.test.ts +++ b/src/routes/file.test.ts @@ -27,14 +27,11 @@ describe('File Route', () => { }); const mockFile = { - id: 1, - fileId: 'http://example.com/file/test.wav', + id: 'http://example.com/file/test.wav', + entityId: 'http://example.com/entity/4', filename: 'test.wav', mediaType: 'audio/wav', size: BigInt(1024), - memberOf: 'http://example.com/collection', - rootCollection: 'http://example.com/collection', - contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', meta: { storagePath: '/data/files/test.wav' }, createdAt: new Date(), updatedAt: new Date(), @@ -42,7 +39,7 @@ describe('File Route', () => { describe('GET /file/:id', () => { it('should stream file content successfully', async () => { - prisma.file.findFirst.mockResolvedValue(mockFile); + prisma.file.findUnique.mockResolvedValue(mockFile); const mockStream = Readable.from(['test file content']); const mockResult: FileResult = { @@ -79,7 +76,7 @@ describe('File Route', () => { }); it('should handle attachment disposition', async () => { - prisma.file.findFirst.mockResolvedValue(mockFile); + prisma.file.findUnique.mockResolvedValue(mockFile); const mockStream = Readable.from(['test']); const mockResult: FileResult = { @@ -102,7 +99,7 @@ describe('File Route', () => { }); it('should use custom filename when provided', async () => { - prisma.file.findFirst.mockResolvedValue(mockFile); + prisma.file.findUnique.mockResolvedValue(mockFile); const mockStream = Readable.from(['test']); const mockResult: FileResult = { @@ -125,7 +122,7 @@ describe('File Route', () => { }); it('should handle redirect response with noRedirect=false (default)', async () => { - prisma.file.findFirst.mockResolvedValue(mockFile); + prisma.file.findUnique.mockResolvedValue(mockFile); const mockResult: FileResult = { type: 'redirect', @@ -143,7 +140,7 @@ describe('File Route', () => { }); it('should handle redirect response with noRedirect=true', async () => { - prisma.file.findFirst.mockResolvedValue(mockFile); + prisma.file.findUnique.mockResolvedValue(mockFile); const mockResult: FileResult = { type: 'redirect', @@ -162,7 +159,7 @@ describe('File Route', () => { }); it('should handle file path response without nginx X-Accel-Redirect', async () => { - prisma.file.findFirst.mockResolvedValue(mockFile); + prisma.file.findUnique.mockResolvedValue(mockFile); const mockStream = Readable.from(['file content']); vi.mocked(createReadStream).mockReturnValue(mockStream as never); @@ -190,7 +187,7 @@ describe('File Route', () => { }); it('should handle file path response with nginx X-Accel-Redirect', async () => { - prisma.file.findFirst.mockResolvedValue(mockFile); + prisma.file.findUnique.mockResolvedValue(mockFile); const mockResult: FileResult = { type: 'file', @@ -218,7 +215,7 @@ describe('File Route', () => { }); it('should return 404 when entity not found', async () => { - prisma.entity.findFirst.mockResolvedValue(null); + prisma.file.findUnique.mockResolvedValue(null); const response = await fastify.inject({ method: 'GET', @@ -233,7 +230,7 @@ describe('File Route', () => { }); it('should return 500 when database error occurs', async () => { - prisma.file.findFirst.mockRejectedValue(new Error('Database connection failed')); + prisma.file.findUnique.mockRejectedValue(new Error('Database connection failed')); const response = await fastify.inject({ method: 'GET', @@ -246,7 +243,7 @@ describe('File Route', () => { }); it('should return 500 when fileHandler throws error', async () => { - prisma.file.findFirst.mockResolvedValue(mockFile); + prisma.file.findUnique.mockResolvedValue(mockFile); vi.mocked(mockFileHandler.get).mockRejectedValue(new Error('File not found in storage')); const response = await fastify.inject({ @@ -271,7 +268,7 @@ describe('File Route', () => { }); it('should validate disposition parameter', async () => { - prisma.file.findFirst.mockResolvedValue(mockFile); + prisma.file.findUnique.mockResolvedValue(mockFile); const response = await fastify.inject({ method: 'GET', @@ -284,7 +281,7 @@ describe('File Route', () => { }); it('should return 404 when get handler returns false', async () => { - prisma.file.findFirst.mockResolvedValue(mockFile); + prisma.file.findUnique.mockResolvedValue(mockFile); vi.mocked(mockFileHandler.get).mockResolvedValue(false); const response = await fastify.inject({ @@ -301,7 +298,7 @@ describe('File Route', () => { describe('HEAD /file/:id', () => { it('should return headers without body using head handler', async () => { - prisma.file.findFirst.mockResolvedValue(mockFile); + prisma.file.findUnique.mockResolvedValue(mockFile); const mockMetadata = { contentType: 'audio/wav', @@ -332,7 +329,7 @@ describe('File Route', () => { }); it('should return 404 when file not found', async () => { - prisma.file.findFirst.mockResolvedValue(null); + prisma.file.findUnique.mockResolvedValue(null); const response = await fastify.inject({ method: 'HEAD', @@ -346,7 +343,7 @@ describe('File Route', () => { }); it('should return 500 when head handler throws error', async () => { - prisma.file.findFirst.mockResolvedValue(mockFile); + prisma.file.findUnique.mockResolvedValue(mockFile); vi.mocked(mockFileHandler.head).mockRejectedValue(new Error('Failed to get metadata')); const response = await fastify.inject({ @@ -360,7 +357,7 @@ describe('File Route', () => { }); it('should return 404 when head handler returns false', async () => { - prisma.file.findFirst.mockResolvedValue(mockFile); + prisma.file.findUnique.mockResolvedValue(mockFile); vi.mocked(mockFileHandler.head).mockResolvedValue(false); const response = await fastify.inject({ @@ -377,7 +374,7 @@ describe('File Route', () => { describe('GET /file/:id - exhaustiveness check', () => { it('should handle unexpected file result type', async () => { - prisma.file.findFirst.mockResolvedValue(mockFile); + prisma.file.findUnique.mockResolvedValue(mockFile); // Mock an invalid result type to test exhaustiveness check const invalidResult = { diff --git a/src/routes/file.ts b/src/routes/file.ts index 60b3284..811f105 100644 --- a/src/routes/file.ts +++ b/src/routes/file.ts @@ -1,9 +1,10 @@ import { createReadStream } from 'node:fs'; -import type { FastifyPluginAsync, FastifyReply } from 'fastify'; +import type { FastifyPluginAsync } from 'fastify'; import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod/v4'; import type { FileHandler, FileMetadata } from '../types/fileHandlers.js'; import { createInternalError, createNotFoundError } from '../utils/errors.js'; +import { setFileHeaders } from '../utils/headers.js'; const paramsSchema = z.object({ id: z.url(), @@ -19,21 +20,6 @@ type FileRouteOptions = { fileHandler: FileHandler; }; -const setFileHeaders = ( - reply: FastifyReply, - metadata: { contentType: string; contentLength: number; etag?: string; lastModified?: Date }, -) => { - reply.header('Content-Type', metadata.contentType); - reply.header('Content-Length', metadata.contentLength.toString()); - - if (metadata.etag) { - reply.header('ETag', metadata.etag); - } - if (metadata.lastModified) { - reply.header('Last-Modified', metadata.lastModified.toUTCString()); - } -}; - const file: FastifyPluginAsync = async (fastify, opts) => { const { fileHandler } = opts; @@ -48,10 +34,8 @@ const file: FastifyPluginAsync = async (fastify, opts) => { const { id } = request.params; try { - const file = await fastify.prisma.file.findFirst({ - where: { - fileId: id, - }, + const file = await fastify.prisma.file.findUnique({ + where: { id }, }); if (!file) { @@ -91,10 +75,8 @@ const file: FastifyPluginAsync = async (fastify, opts) => { const { id } = request.params; try { - const file = await fastify.prisma.file.findFirst({ - where: { - fileId: id, - }, + const file = await fastify.prisma.file.findUnique({ + where: { id }, }); if (!file) { diff --git a/src/routes/files.test.ts b/src/routes/files.test.ts index a893ccf..ec5124b 100644 --- a/src/routes/files.test.ts +++ b/src/routes/files.test.ts @@ -16,28 +16,22 @@ describe('Files Route', () => { }); const mockFile1 = { - id: 1, - fileId: 'http://example.com/file1.wav', + id: 'http://example.com/file1.wav', + entityId: 'http://example.com/entity/4', filename: 'file1.wav', mediaType: 'audio/wav', size: BigInt(1024), - memberOf: 'http://example.com/collection/1', - rootCollection: 'http://example.com/collection/1', - contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', meta: {}, createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-01'), }; const mockFile2 = { - id: 2, - fileId: 'http://example.com/file2.txt', + id: 'http://example.com/file2.txt', + entityId: 'http://example.com/entity/5', filename: 'file2.txt', mediaType: 'text/plain', size: BigInt(512), - memberOf: 'http://example.com/collection/1', - rootCollection: 'http://example.com/collection/1', - contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', meta: {}, createdAt: new Date('2025-01-02'), updatedAt: new Date('2025-01-02'), @@ -62,13 +56,11 @@ describe('Files Route', () => { filename: 'file1.wav', mediaType: 'audio/wav', size: 1024, - memberOf: 'http://example.com/collection/1', - rootCollection: 'http://example.com/collection/1', - contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', access: { content: true, }, }); + expect(body.files[0]).not.toHaveProperty('entityId'); expect(body.files[0]).not.toHaveProperty('metadataLicenseId'); }); @@ -86,7 +78,7 @@ describe('Files Route', () => { expect(body.total).toBe(1); expect(prisma.file.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { memberOf: 'http://example.com/collection/1' }, + where: { entity: { id: 'http://example.com/collection/1' } }, }), ); }); @@ -129,7 +121,7 @@ describe('Files Route', () => { ); }); - it('should support sorting by id (maps to fileId)', async () => { + it('should support sorting by id directly', async () => { prisma.file.findMany.mockResolvedValue([mockFile1, mockFile2]); prisma.file.count.mockResolvedValue(2); @@ -141,7 +133,7 @@ describe('Files Route', () => { expect(response.statusCode).toBe(200); expect(prisma.file.findMany).toHaveBeenCalledWith( expect.objectContaining({ - orderBy: { fileId: 'desc' }, + orderBy: { id: 'desc' }, }), ); }); @@ -171,13 +163,12 @@ describe('Files Route', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body) as { files: Array<{ memberOf: string }> }; + const body = JSON.parse(response.body) as { files: Array<{ id: string }> }; expect(body.files[0]).toMatchObject({ id: 'http://example.com/file1.wav', filename: 'file1.wav', - memberOf: 'http://example.com/collection/1', - contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', }); + expect(body.files[0]).not.toHaveProperty('entityId'); expect(body.files[0]).not.toHaveProperty('metadataLicenseId'); }); diff --git a/src/routes/files.ts b/src/routes/files.ts index 497c211..afb471c 100644 --- a/src/routes/files.ts +++ b/src/routes/files.ts @@ -35,21 +35,20 @@ const files: FastifyPluginAsync = async (fastify, opts) => { const where: NonNullable[0]>['where'] = {}; if (memberOf) { - where.memberOf = memberOf; + where.entity = { id: memberOf }; } - const sortField = sort === 'id' ? 'fileId' : sort; - - const dbFiles = await fastify.prisma.file.findMany({ - where, - orderBy: { - [sortField]: order, - }, - skip: offset, - take: limit, - }); - - const total = await fastify.prisma.file.count({ where }); + const [dbFiles, total] = await Promise.all([ + fastify.prisma.file.findMany({ + where, + orderBy: { + [sort]: order, + }, + skip: offset, + take: limit, + }), + fastify.prisma.file.count({ where }), + ]); // Apply transformers to each entity: base -> access -> additional const filesWithAccess = await Promise.all( diff --git a/src/routes/search.test.ts b/src/routes/search.test.ts index 8b83a76..f9fe15c 100644 --- a/src/routes/search.test.ts +++ b/src/routes/search.test.ts @@ -19,7 +19,7 @@ describe('Search Route', () => { it('should perform basic search successfully', async () => { const mockEntities = [ { - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', name: 'Test Entity 1', description: 'A test entity', entityType: 'http://pcdm.org/models#Collection', @@ -31,7 +31,7 @@ describe('Search Route', () => { updatedAt: new Date('2024-01-01'), }, { - rocrateId: 'http://example.com/entity/2', + id: 'http://example.com/entity/2', name: 'Test Entity 2', description: 'Another test entity', entityType: 'http://pcdm.org/models#Object', @@ -53,7 +53,7 @@ describe('Search Route', () => { { _score: 1.5, _source: { - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', }, highlight: { name: ['Test Entity 1'], @@ -62,7 +62,7 @@ describe('Search Route', () => { { _score: 1.2, _source: { - rocrateId: 'http://example.com/entity/2', + id: 'http://example.com/entity/2', }, highlight: { description: ['Another test entity'], @@ -99,13 +99,14 @@ describe('Search Route', () => { const body = JSON.parse(response.body); expect(body).toMatchSnapshot(); - // Verify database was queried with correct rocrateIds + // Verify database was queried with correct ids expect(prisma.entity.findMany).toHaveBeenCalledWith({ where: { - rocrateId: { + id: { in: ['http://example.com/entity/1', 'http://example.com/entity/2'], }, }, + include: { file: { select: { id: true } } }, }); expect(opensearch.search).toHaveBeenCalledWith({ @@ -414,6 +415,25 @@ describe('Search Route', () => { expect(body).toMatchSnapshot(); }); + it('should return 422 for malformed opensearch query', async () => { + const error = new Error('parsing_exception') as Error & { statusCode: number }; + error.statusCode = 400; + opensearch.search.mockRejectedValue(error); + + const response = await fastify.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'test', + }, + }); + + expect(response.statusCode).toBe(422); + const body = JSON.parse(response.body) as StandardErrorResponse; + expect(body.error.code).toBe('INVALID_REQUEST'); + expect(body.error.message).toBe('parsing_exception'); + }); + it('should validate required query parameter', async () => { const response = await fastify.inject({ method: 'POST', @@ -427,7 +447,7 @@ describe('Search Route', () => { it('should skip entities not found in database and log warning', async () => { const mockEntities = [ { - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', name: 'Test Entity 1', description: 'A test entity', entityType: 'http://pcdm.org/models#Collection', @@ -450,14 +470,14 @@ describe('Search Route', () => { { _score: 1.5, _source: { - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', }, highlight: {}, }, { _score: 1.2, _source: { - rocrateId: 'http://example.com/entity/2', + id: 'http://example.com/entity/2', }, highlight: {}, }, @@ -486,7 +506,7 @@ describe('Search Route', () => { expect(body).toMatchSnapshot(); }); - it('should handle missing rocrateId in search hit', async () => { + it('should handle missing id in search hit', async () => { const mockSearchResponse = { body: { took: 5, @@ -496,7 +516,7 @@ describe('Search Route', () => { { _score: 1.5, _source: { - // Missing rocrateId + // Missing id }, }, ], @@ -521,7 +541,7 @@ describe('Search Route', () => { console.log(response.body); }); - it('should handle rocrateId explicitly set to undefined', async () => { + it('should handle id explicitly set to undefined', async () => { const mockSearchResponse = { body: { took: 5, @@ -531,7 +551,7 @@ describe('Search Route', () => { { _score: 1.5, _source: { - rocrateId: undefined, + id: undefined, name: 'Test Entity', }, }, @@ -597,7 +617,7 @@ describe('Search Route', () => { const mockEntities = [ { - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', name: 'Test Entity 1', description: 'A test entity', entityType: 'http://pcdm.org/models#Collection', @@ -619,7 +639,7 @@ describe('Search Route', () => { { _score: 1.5, _source: { - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', }, highlight: {}, }, @@ -651,7 +671,7 @@ describe('Search Route', () => { it('should handle search response with no aggregations field', async () => { const mockEntities = [ { - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', name: 'Test Entity 1', description: 'A test entity', entityType: 'http://pcdm.org/models#Collection', @@ -673,7 +693,7 @@ describe('Search Route', () => { { _score: 1.5, _source: { - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', }, highlight: {}, }, @@ -706,7 +726,7 @@ describe('Search Route', () => { it('should handle search response with malformed aggregation buckets', async () => { const mockEntities = [ { - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', name: 'Test Entity 1', description: 'A test entity', entityType: 'http://pcdm.org/models#Collection', @@ -728,7 +748,7 @@ describe('Search Route', () => { { _score: 1.5, _source: { - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', }, highlight: {}, }, @@ -768,7 +788,7 @@ describe('Search Route', () => { it('should handle search response with malformed geohash aggregation buckets', async () => { const mockEntities = [ { - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', name: 'Test Entity 1', description: 'A test entity', entityType: 'http://pcdm.org/models#Collection', @@ -790,7 +810,7 @@ describe('Search Route', () => { { _score: 1.5, _source: { - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', }, highlight: {}, }, @@ -827,10 +847,8 @@ describe('Search Route', () => { it('should return null for memberOf/rootCollection when parent entity not found', async () => { const mockEntities = [ { - id: 1, - fileId: null, meta: {}, - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', name: 'Test Entity 1', description: 'Entity with missing parent', entityType: 'http://pcdm.org/models#Object', @@ -852,7 +870,7 @@ describe('Search Route', () => { { _score: 1.5, _source: { - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', }, }, ], diff --git a/src/routes/search.ts b/src/routes/search.ts index e58163a..e1f701a 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -24,7 +24,7 @@ const searchParamsSchema = z.object({ query: z.string(), filters: z.record(z.string(), z.array(z.string())).optional(), boundingBox: boundingBoxSchema.optional(), - geohashPrecision: z.number().int().min(0).max(12).default(5), + geohashPrecision: z.number().int().min(1).max(12).optional(), limit: z.number().int().min(1).max(1000).default(100), offset: z.number().int().min(0).default(0), sort: z.enum(['id', 'name', 'createdAt', 'updatedAt', 'relevance']).default('relevance'), @@ -80,32 +80,31 @@ const search: FastifyPluginAsync = async (fastify, opts) => throw new Error('Invalid search response: missing hits data'); } - const rocrateIds = response.body.hits.hits - .map((hit) => hit._source?.rocrateId as string | undefined) - .filter(Boolean); + const entityIds = response.body.hits.hits.map((hit) => hit._source?.id as string | undefined).filter(Boolean); const dbEntities = await fastify.prisma.entity.findMany({ where: { - rocrateId: { - in: rocrateIds, + id: { + in: entityIds, }, }, + include: { file: { select: { id: true } } }, }); - const entityMap = new Map(dbEntities.map((entity) => [entity.rocrateId, entity])); + const entityMap = new Map(dbEntities.map((entity) => [entity.id, entity])); // Resolve memberOf and rootCollection references const refMap = await resolveEntityReferences(dbEntities, fastify.prisma); const entities = await Promise.all( response.body.hits.hits.map(async (hit) => { - if (!hit._source?.rocrateId) { - throw new Error('Missing rocrateId in search hit'); + if (!hit._source?.id) { + throw new Error('Missing id in search hit'); } - const dbEntity = entityMap.get(hit._source.rocrateId); + const dbEntity = entityMap.get(hit._source.id); if (!dbEntity) { - fastify.log.warn(`Entity ${hit._source.rocrateId} found in OpenSearch but not in database`); + fastify.log.warn(`Entity ${hit._source.id} found in OpenSearch but not in database`); return null; } diff --git a/src/test/integration.setup.ts b/src/test/integration.setup.ts index 7b6eb47..b8bf42e 100644 --- a/src/test/integration.setup.ts +++ b/src/test/integration.setup.ts @@ -70,6 +70,7 @@ export async function teardownIntegrationTests() { } export async function cleanupTestData() { + await prisma.file.deleteMany({}); await prisma.entity.deleteMany({}); await opensearch.indices.delete({ @@ -83,8 +84,7 @@ export async function seedTestData() { const testEntities = [ { - id: 1, - rocrateId: 'http://example.com/entity/1', + id: 'http://example.com/entity/1', name: 'Test Collection', description: 'First test entity', entityType: 'http://pcdm.org/models#Collection', @@ -96,8 +96,7 @@ export async function seedTestData() { updatedAt: new Date(), }, { - id: 2, - rocrateId: 'http://example.com/entity/2', + id: 'http://example.com/entity/2', name: 'Test Object', description: 'Second test entity', entityType: 'http://pcdm.org/models#Object', @@ -109,8 +108,7 @@ export async function seedTestData() { updatedAt: new Date(), }, { - id: 3, - rocrateId: 'http://example.com/entity/3', + id: 'http://example.com/entity/3', name: 'Test Person', description: 'Third test entity', entityType: 'http://schema.org/Person', @@ -122,8 +120,7 @@ export async function seedTestData() { updatedAt: new Date(), }, { - id: 4, - rocrateId: 'http://example.com/entity/4', + id: 'http://example.com/entity/4', name: 'test-audio.wav', description: 'Test audio file', entityType: 'http://schema.org/MediaObject', @@ -135,8 +132,7 @@ export async function seedTestData() { updatedAt: new Date(), }, { - id: 5, - rocrateId: 'http://example.com/entity/5', + id: 'http://example.com/entity/5', name: 'collection-metadata.csv', description: 'Collection metadata file', entityType: 'http://schema.org/MediaObject', @@ -158,7 +154,7 @@ export async function seedTestData() { body: { mappings: { properties: { - rocrateId: { type: 'keyword' }, + id: { type: 'keyword' }, name: { type: 'text', fields: { @@ -178,10 +174,7 @@ export async function seedTestData() { }, }); - const testDocs = testEntities.flatMap((entity, index) => [ - { index: { _index: 'entities', _id: `${index + 1}` } }, - entity, - ]); + const testDocs = testEntities.flatMap((entity) => [{ index: { _index: 'entities', _id: entity.id } }, entity]); await opensearch.bulk({ body: testDocs, diff --git a/src/transformers/default.test.ts b/src/transformers/default.test.ts index 9dd0a8d..fad5718 100644 --- a/src/transformers/default.test.ts +++ b/src/transformers/default.test.ts @@ -4,13 +4,11 @@ import { AllPublicAccessTransformer, baseEntityTransformer } from './default.js' describe('baseEntityTransformer', () => { it('should transform entity to standard entity shape', () => { - const entity: Entity = { - id: 1, - rocrateId: 'http://example.com/entity/123', + const entity: Entity & { file?: { id: string } | null } = { + id: 'http://example.com/entity/123', name: 'Test Entity', description: 'A test entity description', entityType: 'http://pcdm.org/models#Collection', - fileId: null, memberOf: 'http://example.com/parent', rootCollection: 'http://example.com/root', metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', @@ -35,13 +33,11 @@ describe('baseEntityTransformer', () => { }); it('should handle null memberOf and rootCollection', () => { - const entity: Entity = { - id: 1, - rocrateId: 'http://example.com/collection', + const entity: Entity & { file?: { id: string } | null } = { + id: 'http://example.com/collection', name: 'Top Collection', description: 'A top-level collection', entityType: 'http://pcdm.org/models#Collection', - fileId: null, memberOf: null, rootCollection: null, metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', @@ -58,13 +54,11 @@ describe('baseEntityTransformer', () => { }); it('should exclude database-specific fields', () => { - const entity: Entity = { - id: 1, - rocrateId: 'http://example.com/entity/456', + const entity: Entity & { file?: { id: string } | null } = { + id: 'http://example.com/entity/456', name: 'Test', description: 'Test', entityType: 'http://pcdm.org/models#Object', - fileId: null, memberOf: null, rootCollection: null, metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', @@ -76,7 +70,6 @@ describe('baseEntityTransformer', () => { const result = baseEntityTransformer(entity); - // Result should have 'id' (mapped from rocrateId), but not the numeric database id expect(result.id).toBe('http://example.com/entity/456'); expect(result).not.toHaveProperty('createdAt'); expect(result).not.toHaveProperty('updatedAt'); @@ -93,14 +86,12 @@ describe('baseEntityTransformer', () => { ]); }); - it('should handle File entity (MediaObject) with fileId', () => { - const entity: Entity = { - id: 1, - rocrateId: 'http://example.com/file/audio.wav', + it('should handle File entity (MediaObject) with file relation', () => { + const entity: Entity & { file?: { id: string } | null } = { + id: 'http://example.com/file/audio.wav', name: 'Audio File', description: 'An audio recording', entityType: 'http://schema.org/MediaObject', - fileId: 'http://example.com/files/audio.wav', memberOf: 'http://example.com/collection', rootCollection: 'http://example.com/collection', metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', @@ -108,6 +99,7 @@ describe('baseEntityTransformer', () => { createdAt: new Date(), updatedAt: new Date(), meta: null, + file: { id: 'http://example.com/files/audio.wav' }, }; const result = baseEntityTransformer(entity); @@ -117,13 +109,11 @@ describe('baseEntityTransformer', () => { name: 'Audio File', description: 'An audio recording', entityType: 'http://schema.org/MediaObject', - fileId: 'http://example.com/files/audio.wav', memberOf: 'http://example.com/collection', rootCollection: 'http://example.com/collection', metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', }); - expect(result.fileId).toBe('http://example.com/files/audio.wav'); }); }); diff --git a/src/transformers/default.ts b/src/transformers/default.ts index 87d079b..602f336 100644 --- a/src/transformers/default.ts +++ b/src/transformers/default.ts @@ -1,5 +1,7 @@ import type { Entity, File, PrismaClient } from '../generated/prisma/client.js'; +type EntityWithFile = Entity & { file?: { id: string } | null }; + /** * Entity reference - used for memberOf and rootCollection */ @@ -14,13 +16,14 @@ type EntityReference = { */ export type BaseEntity = { id: string; + entityType: string; name: string; description: string; memberOf: string | null; rootCollection: string | null; metadataLicenseId: string; contentLicenseId: string; -} & ({ entityType: string; fileId?: never } | { entityType: 'http://schema.org/MediaObject'; fileId: string }); +}; /** * Standard entity shape - with resolved references @@ -28,13 +31,14 @@ export type BaseEntity = { */ export type StandardEntity = { id: string; + entityType: string; name: string; description: string; memberOf: EntityReference | null; rootCollection: EntityReference | null; metadataLicenseId: string; contentLicenseId: string; -} & ({ entityType: string; fileId?: never } | { entityType: 'http://schema.org/MediaObject'; fileId: string }); +}; /** * Access information for an entity @@ -42,6 +46,7 @@ export type StandardEntity = { type AccessInfo = { metadata: boolean; content: boolean; + metadataAuthorizationUrl?: string; contentAuthorizationUrl?: string; }; @@ -65,16 +70,12 @@ type FileAccessInfo = { /** * Standard file shape - output of base file transformation * Does not include access information - * Files only have contentLicenseId (no metadataLicenseId) */ export type StandardFile = { id: string; filename: string; mediaType: string; size: number; - memberOf: string; - rootCollection: string; - contentLicenseId: string; }; /** @@ -89,10 +90,11 @@ export type AuthorisedFile = StandardFile & { /** * Base entity transformer - always applied first * Transforms raw database entity to base entity shape with unresolved references + * Accepts Entity with optional file relation included */ -export const baseEntityTransformer = (entity: Entity): BaseEntity => { +export const baseEntityTransformer = (entity: EntityWithFile): BaseEntity => { const base: BaseEntity = { - id: entity.rocrateId, + id: entity.id, name: entity.name, description: entity.description, entityType: entity.entityType, @@ -102,18 +104,6 @@ export const baseEntityTransformer = (entity: Entity): BaseEntity => { contentLicenseId: entity.contentLicenseId, }; - if (base.entityType === ('http://schema.org/MediaObject' as const)) { - if (!entity.fileId) { - return base; - } - - return { - ...base, - entityType: base.entityType, - fileId: entity.fileId, - }; - } - return base; }; @@ -148,13 +138,10 @@ export const AllPublicAccessTransformer = (entity: StandardEntity): AuthorisedEn * Transforms raw database file to standard file shape (without access) */ export const baseFileTransformer = (file: File): StandardFile => ({ - id: file.fileId, + id: file.id, filename: file.filename, mediaType: file.mediaType, size: Number(file.size), - memberOf: file.memberOf, - rootCollection: file.rootCollection, - contentLicenseId: file.contentLicenseId, }); /** @@ -215,9 +202,9 @@ export const resolveEntityReferences = async ( } const refs = await prisma.entity.findMany({ - where: { rocrateId: { in: [...refIds] } }, - select: { rocrateId: true, name: true }, + where: { id: { in: [...refIds] } }, + select: { id: true, name: true }, }); - return new Map(refs.map((r) => [r.rocrateId, { id: r.rocrateId, name: r.name }])); + return new Map(refs.map((r) => [r.id, { id: r.id, name: r.name }])); }; diff --git a/src/transformers/transformer.test.ts b/src/transformers/transformer.test.ts index 5725916..e53d3f1 100644 --- a/src/transformers/transformer.test.ts +++ b/src/transformers/transformer.test.ts @@ -37,12 +37,11 @@ describe('Entity Transformers', () => { describe('baseEntityTransformer', () => { it('should transform raw entity to base shape with unresolved references', () => { const rawEntity = { - id: 1, - rocrateId: 'http://example.com/entity/123', + id: 'http://example.com/entity/123', name: 'Test Entity', description: 'A test entity', entityType: 'http://schema.org/Person', - fileId: null, + memberOf: 'http://example.com/collection', rootCollection: 'http://example.com/root', metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', @@ -195,12 +194,11 @@ describe('Entity Transformers', () => { it('should demonstrate full pipeline: base -> resolve -> access -> custom', async () => { const rawEntity = { - id: 1, - rocrateId: 'http://example.com/entity/123', + id: 'http://example.com/entity/123', name: 'Test Entity', description: 'A test entity', entityType: 'http://schema.org/Person', - fileId: null, + memberOf: 'http://example.com/collection', rootCollection: 'http://example.com/root', metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', diff --git a/src/utils/headers.ts b/src/utils/headers.ts new file mode 100644 index 0000000..ca133a9 --- /dev/null +++ b/src/utils/headers.ts @@ -0,0 +1,16 @@ +import type { FastifyReply } from 'fastify'; + +export const setFileHeaders = ( + reply: FastifyReply, + metadata: { contentType: string; contentLength: number; etag?: string; lastModified?: Date }, +) => { + reply.header('Content-Type', metadata.contentType); + reply.header('Content-Length', metadata.contentLength.toString()); + + if (metadata.etag) { + reply.header('ETag', metadata.etag); + } + if (metadata.lastModified) { + reply.header('Last-Modified', metadata.lastModified.toUTCString()); + } +}; diff --git a/src/utils/queryBuilder.ts b/src/utils/queryBuilder.ts index 93c4fb4..6f658d8 100644 --- a/src/utils/queryBuilder.ts +++ b/src/utils/queryBuilder.ts @@ -92,7 +92,7 @@ export class OpensearchQueryBuilder { }; } - buildAggregations(geohashPrecision: number, boundingBox?: BoundingBox) { + buildAggregations(geohashPrecision?: number, boundingBox?: BoundingBox) { const aggs = { ...this.aggregations }; // Add geohash aggregation if precision is specified @@ -123,12 +123,10 @@ export class OpensearchQueryBuilder { return; } - const sortField = sort === 'id' ? 'rocrateId' : sort; - - if (sortField === 'name') { + if (sort === 'name') { return [{ 'name.keyword': order }]; } - return [{ [sortField]: order }]; + return [{ [sort]: order }]; } }