From e4eea25a99dc40f2bc5d82a2e7ec47755117ccf4 Mon Sep 17 00:00:00 2001 From: John Ferlito Date: Wed, 8 Oct 2025 08:55:51 +1100 Subject: [PATCH 1/2] chore: Add @total-typescript/ts-reset for improved type safety --- package.json | 5 +++-- pnpm-lock.yaml | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 83e4645..c1092af 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@prisma/client": "^6.16.3", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", + "@total-typescript/ts-reset": "^0.6.1", "@types/express": "^5.0.3", "@types/node": "^22.18.8", "@vitest/coverage-v8": "^3.2.4", @@ -71,6 +72,7 @@ "load-test-data": "tsx src/scripts/loadEntities.ts", "lint:biome": "biome check", "lint:types": "tsc --noEmit", + "lint:knip": "knip", "test": "vitest run", "test:watch": "./scripts/setup-integration.sh && vitest", "test:ui": "./scripts/setup-integration.sh && vitest --ui", @@ -79,8 +81,7 @@ "generate": "npx prisma generate", "postinstall": "npm run generate", "db:generate": "prisma generate", - "db:migrate": "prisma migrate deploy", - "knip": "knip" + "db:migrate": "prisma migrate deploy" }, "repository": { "type": "git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9792ef..6885546 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: '@semantic-release/git': specifier: ^10.0.1 version: 10.0.1(semantic-release@24.2.9(typescript@5.9.3)) + '@total-typescript/ts-reset': + specifier: ^0.6.1 + version: 0.6.1 '@types/express': specifier: ^5.0.3 version: 5.0.3 @@ -800,6 +803,9 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@total-typescript/ts-reset@0.6.1': + resolution: {integrity: sha512-cka47fVSo6lfQDIATYqb/vO1nvFfbPw7uWLayIXIhGETj0wcOOlrlkobOMDNQOFr9QOafegUPq13V2+6vtD7yg==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -3524,6 +3530,8 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@total-typescript/ts-reset@0.6.1': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 From 7c639dec3e2d42cc211f5d2a49b64c8444239a58 Mon Sep 17 00:00:00 2001 From: John Ferlito Date: Wed, 8 Oct 2025 08:50:33 +1100 Subject: [PATCH 2/2] feat: add entity transformer system Implement flexible entity transformation pipeline with access control and custom transformers. Enables customisation of entity responses through composable transformers for access control, data enrichment, and response modification. - Add transformer types and base/default implementations - Update all entity routes to support transformer pipeline - Add comprehensive tests and snapshots for transformer functionality - Document transformer system with examples in CLAUDE.md and README.md - Add @total-typescript/ts-reset for improved type safety - Clean up package.json dependencies --- CLAUDE.md | 360 ++++++++++++++++++ README.md | 147 ++++++- package.json | 1 - pnpm-lock.yaml | 3 - src/app.test.ts | 17 +- src/app.ts | 20 +- src/index.ts | 8 +- src/reset.d.ts | 1 + .../__snapshots__/entities.test.ts.snap | 30 ++ src/routes/__snapshots__/entity.test.ts.snap | 31 ++ src/routes/__snapshots__/search.test.ts.snap | 72 ++++ src/routes/entities.test.ts | 46 ++- src/routes/entities.ts | 40 +- src/routes/entity.test.ts | 47 ++- src/routes/entity.ts | 35 +- src/routes/search.test.ts | 249 ++++++++++-- src/routes/search.ts | 76 +++- .../__snapshots__/integration.test.ts.snap | 18 + src/test/integration.setup.ts | 3 +- src/test/integration.test.ts | 48 ++- src/transformers/default.ts | 74 ++++ src/transformers/transformer.test.ts | 229 +++++++++++ src/types/transformers.ts | 30 ++ src/utils/errors.ts | 3 +- vitest.config.ts | 4 + 25 files changed, 1477 insertions(+), 115 deletions(-) create mode 100644 src/reset.d.ts create mode 100644 src/test/__snapshots__/integration.test.ts.snap create mode 100644 src/transformers/default.ts create mode 100644 src/transformers/transformer.test.ts create mode 100644 src/types/transformers.ts diff --git a/CLAUDE.md b/CLAUDE.md index f50be69..135aaa8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,10 @@ src/ │ ├── entity.ts # Single entity operations │ ├── entities.ts # Entity listing/filtering │ └── search.ts # OpenSearch operations +├── transformers/ # Entity transformers +│ └── default.ts # Default entity transformer +├── types/ # TypeScript type definitions +│ └── transformers.ts # Transformer type definitions ├── utils/ # Utility functions │ └── errors.ts # Error handling helpers ├── generated/ # Prisma generated files (excluded from tests) @@ -160,6 +164,362 @@ src/ - Validate against OpenAPI specification - Handle validation errors gracefully +## Entity Transformers + +The API provides a flexible transformer system for customising entity responses. Transformers are applied in a three-stage pipeline: + +1. **Base transformer** - Converts database entities to standard format +2. **Access transformer** - Adds authorisation/access control information +3. **Entity transformers** - Optional additional transformations + +### Overview + +The transformer system enables: +- **Access control**: Control visibility of metadata and content based on licenses +- **Data enrichment**: Add computed fields or fetch related data +- **Response customisation**: Adapt the API response to specific client needs +- **Async operations**: Fetch additional data or perform authorisation checks + +### Transformation Pipeline + +Every entity response flows through this pipeline: + +``` +Database Entity → baseEntityTransformer → accessTransformer → entityTransformers[] → Response +``` + +### Usage + +When mounting the application, you **must** provide an `accessTransformer`. This is a required security feature to ensure conscious decisions about access control. + +```typescript +import { Client } from '@opensearch-project/opensearch'; +import arocapi, { AllPublicAccessTransformer } from 'arocapi'; +import fastify from 'fastify'; +import { PrismaClient } from './generated/prisma/client.js'; + +const server = fastify(); +const prisma = new PrismaClient(); +const opensearch = new Client({ node: process.env.OPENSEARCH_URL }); + +// For fully public datasets, use AllPublicAccessTransformer +await server.register(arocapi, { + prisma, + opensearch, + accessTransformer: AllPublicAccessTransformer, // Explicit choice for public data +}); + +// For restricted content, provide custom accessTransformer +await server.register(arocapi, { + prisma, + opensearch, + // Required: Controls access to metadata and content + accessTransformer: async (entity, { request, fastify }) => { + const user = await authenticateUser(request); + const canAccessContent = await checkLicense(entity.contentLicenseId, user); + + return { + ...entity, + access: { + metadata: true, // Metadata always visible + content: canAccessContent, + contentAuthorisationUrl: canAccessContent + ? undefined + : 'https://example.com/request-access', + }, + }; + }, + // Optional: Additional data transformations + entityTransformers: [ + // Add computed fields + async (entity) => ({ + ...entity, + displayName: `${entity.name} [${entity.entityType.split('/').pop()}]`, + }), + // Fetch related data + async (entity, { fastify }) => { + const collection = entity.memberOf + ? await fastify.prisma.entity.findFirst({ + where: { rocrateId: entity.memberOf }, + }) + : null; + + return { + ...entity, + collection: collection ? { id: collection.rocrateId, name: collection.name } : null, + }; + }, + ], +}); +``` + +### Transformer Types + +#### Access Transformer + +Controls access to metadata and content. Receives a `StandardEntity` and must return an `AuthorisedEntity`. + +```typescript +type AccessTransformer = ( + entity: StandardEntity, + context: TransformerContext, +) => Promise | AuthorisedEntity; + +type StandardEntity = { + id: string; + name: string; + description: string; + entityType: string; + memberOf: string | null; + rootCollection: string | null; + metadataLicenseId: string; + contentLicenseId: string; +}; + +type AuthorisedEntity = StandardEntity & { + access: { + metadata: boolean; + content: boolean; + contentAuthorisationUrl?: string; + }; +}; +``` + +#### Entity Transformers + +Optional transformations applied after access control. Each transformer receives the output of the previous one. + +```typescript +type EntityTransformer = ( + entity: TInput, + context: TransformerContext, +) => Promise | TOutput; +``` + +#### Transformer Context + +All transformers receive a context object: + +```typescript +type TransformerContext = { + request: FastifyRequest; // Access request headers, params, etc. + fastify: FastifyInstance; // Access prisma, opensearch, etc. +}; +``` + +### AllPublicAccessTransformer + +The `AllPublicAccessTransformer` is provided for fully public datasets. It grants full access to all data: + +```typescript +import { AllPublicAccessTransformer } from 'arocapi'; + +// Returns: +{ + ...entity, + access: { + metadata: true, + content: true, + }, +} +``` + +**Security Note**: The `accessTransformer` parameter is **required**. You must explicitly choose `AllPublicAccessTransformer` for public data or implement a custom transformer for restricted content. This prevents accidental exposure of restricted data. + +### Applied Routes + +Transformers are applied to all entity routes: +- `GET /entity/:id` - Single entity +- `GET /entities` - Entity list (each entity transformed) +- `POST /search` - Search results (entities + search metadata) + +For search results, entities are transformed and then wrapped with search metadata: + +```typescript +{ + ...transformedEntity, + searchExtra: { + score: hit._score, + highlight: hit.highlight, + }, +} +``` + +### Examples + +#### Access Control with License Checking + +```typescript +accessTransformer: async (entity, { request, fastify }) => { + const user = await getUserFromRequest(request); + + // Check if user has access to content license + const hasContentAccess = await checkUserLicense( + user, + entity.contentLicenseId, + fastify.prisma, + ); + + return { + ...entity, + access: { + metadata: true, // Metadata always visible + content: hasContentAccess, + contentAuthorisationUrl: hasContentAccess + ? undefined + : `https://rems.example.com/apply?license=${entity.contentLicenseId}`, + }, + }; +} +``` + +#### Add Computed Display Name + +```typescript +entityTransformers: [ + (entity) => ({ + ...entity, + displayName: `${entity.name} [${entity.entityType.split('/').pop()}]`, + shortId: entity.id.split('/').pop(), + }), +] +``` + +#### Fetch Related Collection Data + +```typescript +entityTransformers: [ + async (entity, { fastify }) => { + if (!entity.memberOf) { + return { ...entity, collection: null }; + } + + const collection = await fastify.prisma.entity.findFirst({ + where: { rocrateId: entity.memberOf }, + }); + + return { + ...entity, + collection: collection ? { + id: collection.rocrateId, + name: collection.name, + type: collection.entityType, + } : null, + }; + }, +] +``` + +#### Combine Access Control and Data Enrichment + +```typescript +await server.register(arocapi, { + prisma, + opensearch, + // Control access based on user authentication + accessTransformer: async (entity, { request, fastify }) => { + const token = request.headers.authorisation; + const user = token ? await verifyToken(token) : null; + + const canAccess = user + ? await checkLicense(entity.contentLicenseId, user.id, fastify.prisma) + : entity.contentLicenseId === 'http://creativecommons.org/publicdomain/zero/1.0/'; + + return { + ...entity, + access: { + metadata: true, + content: canAccess, + }, + }; + }, + // Add additional computed fields + entityTransformers: [ + (entity) => ({ + ...entity, + displayName: entity.name.toUpperCase(), + createdYear: new Date().getFullYear(), // Could fetch from metadata + }), + ], +}); +``` + +#### Request-Specific Data + +```typescript +entityTransformers: [ + (entity, { request }) => ({ + ...entity, + requestInfo: { + timestamp: new Date().toISOString(), + userAgent: request.headers['user-agent'], + acceptLanguage: request.headers['accept-language'], + }, + }), +] +``` + +### Testing Transformers + +Test custom transformers by passing them to your test Fastify instance: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { fastify, fastifyBefore } from './test/helpers/fastify.js'; +import entityRoute from './routes/entity.js'; + +describe('Custom Transformer Tests', () => { + beforeEach(async () => { + await fastifyBefore(); + }); + + it('should apply custom access transformer', async () => { + const customAccessTransformer = (entity) => ({ + ...entity, + access: { + metadata: true, + content: false, // Restrict content access + contentAuthorisationUrl: 'https://example.com/request', + }, + }); + + await fastify.register(entityRoute, { + accessTransformer: customAccessTransformer, + }); + + const response = await fastify.inject({ + method: 'GET', + url: '/entity/http://example.com/test', + }); + + const body = JSON.parse(response.body); + expect(body.access.content).toBe(false); + }); + + it('should apply custom entity transformers', async () => { + const customTransformer = (entity) => ({ + ...entity, + tested: true, + displayName: entity.name.toUpperCase(), + }); + + await fastify.register(entityRoute, { + accessTransformer: AllPublicAccessTransformer, + entityTransformers: [customTransformer], + }); + + const response = await fastify.inject({ + method: 'GET', + url: '/entity/http://example.com/test', + }); + + const body = JSON.parse(response.body); + expect(body.tested).toBe(true); + expect(body.displayName).toBe('TEST ENTITY'); + }); +}); +``` + ## Database Management ### Prisma Operations diff --git a/README.md b/README.md index 778b4dd..321ef69 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,8 @@ Create your Fastify application with Typescript support: ```typescript // src/app.ts import { Client } from '@opensearch-project/opensearch'; -import arocapi from 'arocapi'; -import type { FastifyPluginAsync } from 'fastify'; +import arocapi, { AllPublicAccessTransformer } from 'arocapi'; +import Fastify from 'fastify'; import { PrismaClient } from './generated/prisma/client.js'; // NOTE: Only needed if you are going to use these yourself @@ -98,7 +98,13 @@ const opensearch = new Client({ node: opensearchUrl }); const fastify = Fastify({ logger: true, }); -fastify.register(arocapi, { prisma, opensearch }); + +// For fully public datasets +fastify.register(arocapi, { + prisma, + opensearch, + accessTransformer: AllPublicAccessTransformer, +}); try { await fastify.listen({ port: 3000 }); @@ -221,6 +227,141 @@ The arocapi provides the following endpoints: - `DELETE /entity/:id` - Delete an entity - `GET /search` - Search entities using OpenSearch +## Customising Entity Responses + +The API provides a flexible transformer system for customising entity responses +through two types of transformers: + +### Access Transformer (Required) + +The `accessTransformer` parameter is **required** for security. You must explicitly +choose how access control is handled for your repository. + +**For fully public datasets**, use `AllPublicAccessTransformer`: + +```typescript +import arocapi, { AllPublicAccessTransformer } from 'arocapi'; + +await server.register(arocapi, { + prisma, + opensearch, + accessTransformer: AllPublicAccessTransformer, +}); +``` + +**For restricted content**, implement a custom access transformer: + +```typescript +await server.register(arocapi, { + prisma, + opensearch, + accessTransformer: async (entity, { request, fastify }) => { + // Custom logic to determine access + const user = await authenticateUser(request); + const canAccessContent = await checkLicense(entity.contentLicenseId, user); + + return { + ...entity, + access: { + metadata: true, // Metadata always visible + content: canAccessContent, + contentAuthorisationUrl: canAccessContent + ? undefined + : 'https://rems.example.com/request-access', + }, + }; + }, +}); +``` + +> [!WARNING] +> The `accessTransformer` is required to prevent accidental exposure of +> restricted content. You must make an explicit choice about access control for +> your repository. + +### Entity Transformers + +Optional transformations for enriching or modifying response data. Multiple +transformers can be chained together. + +```typescript +await server.register(arocapi, { + prisma, + opensearch, + accessTransformer: AllPublicAccessTransformer, + entityTransformers: [ + // Add computed fields + (entity) => ({ + ...entity, + displayName: `${entity.name} [${entity.entityType.split('/').pop()}]`, + }), + // Add counts + async (entity, { fastify }) => { + const objectCount = entity.memberOf + ? await fastify.prisma.entity.count({ + where: { memberOf: entity.rocrateId }, + }) + : 0; + + return { + ...entity, + counts: { + objects: objectCount, + } + }; + }, + ], +}); +``` + +### Transformation Pipeline + +Every entity response flows through this three-stage pipeline: + +1. **Base transformer** - Converts database entities to standard format +2. **Access transformer** - Adds access control information +3. **Entity transformers** - Optional additional transformations + +### Common Use Cases + +**Access control for restricted content:** + +```typescript +accessTransformer: async (entity, { request, fastify }) => { + const hasAccess = await checkUserPermissions(request, entity.contentLicenseId); + return { + ...entity, + access: { + metadata: true, + content: hasAccess, + }, + }; +} +``` + +**Adding computed or derived fields:** + +```typescript +entityTransformers: [ + (entity) => ({ + ...entity, + shortId: entity.id.split('/').pop(), + year: extractYear(entity.description), + }), +] +``` + +**Fetching related data asynchronously:** + +```typescript +entityTransformers: [ + async (entity, { fastify }) => ({ + ...entity, + stats: await fetchEntityStats(entity.id, fastify.prisma), + }), +] +``` + ## Development Workflow ### Local Development Setup diff --git a/package.json b/package.json index c1092af..796520a 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "@fastify/sensible": "^6.0.0", "@opensearch-project/opensearch": "^3.5.1", "@prisma/config": "^6.16.3", - "arocapi": "link:", "dotenv": "^17.2.3", "express": "^5.1.0", "fastify": "^5.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6885546..4340530 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,6 @@ importers: '@prisma/config': specifier: ^6.16.3 version: 6.16.3(magicast@0.3.5) - arocapi: - specifier: 'link:' - version: 'link:' dotenv: specifier: ^17.2.3 version: 17.2.3 diff --git a/src/app.test.ts b/src/app.test.ts index 259f813..b53e3cb 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -2,7 +2,7 @@ import Fastify from 'fastify'; import { beforeEach, describe, expect, it } from 'vitest'; import { mockReset } from 'vitest-mock-extended'; -import app from './app.js'; +import app, { AllPublicAccessTransformer } from './app.js'; import { opensearch, prisma } from './test/helpers/fastify.js'; @@ -37,6 +37,19 @@ describe('Entity Route', () => { await expect(() => fastify.ready()).rejects.toThrowError('OpenSearch client is required'); }); + it('should handle missing accessTransformer', async () => { + const fastify = Fastify({ logger: false }); + + // @ts-expect-error we are testing missing options + fastify.register(app, { + prisma, + opensearch, + disableCors: false, + }); + + await expect(() => fastify.ready()).rejects.toThrowError('accessTransformer is required'); + }); + it('should handle broken opensearch', async () => { opensearch.ping.mockRejectedValue(new Error('Connection failed')); @@ -46,6 +59,7 @@ describe('Entity Route', () => { prisma, opensearch, disableCors: false, + accessTransformer: AllPublicAccessTransformer, }); await expect(() => fastify.ready()).rejects.toThrowError('Connection failed'); @@ -57,6 +71,7 @@ describe('Entity Route', () => { prisma, opensearch, disableCors: false, + accessTransformer: AllPublicAccessTransformer, }); fastify.get('/error', async () => { throw new Error('Random'); diff --git a/src/app.ts b/src/app.ts index 1014c80..3832f40 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,8 +8,14 @@ import { hasZodFastifySchemaValidationErrors, serializerCompiler, validatorCompi import entities from './routes/entities.js'; import entity from './routes/entity.js'; import search from './routes/search.js'; +import type { AccessTransformer, EntityTransformer } from './types/transformers.js'; import { createValidationError } from './utils/errors.js'; +export type { AuthorisedEntity, StandardEntity } from './transformers/default.js'; +// Re-export transformers and types for external use +export { AllPublicAccessTransformer } from './transformers/default.js'; +export type { AccessTransformer, EntityTransformer, TransformerContext } from './types/transformers.js'; + const setupValidation = (fastify: FastifyInstance) => { fastify.setValidatorCompiler(validatorCompiler); fastify.setSerializerCompiler(serializerCompiler); @@ -54,9 +60,11 @@ export type Options = { prisma: PrismaClient; opensearch: Client; disableCors?: boolean; + accessTransformer: AccessTransformer; + entityTransformers?: EntityTransformer[]; }; const app: FastifyPluginAsync = async (fastify, options) => { - const { prisma, opensearch, disableCors = false } = options; + const { prisma, opensearch, disableCors = false, accessTransformer, entityTransformers } = options; if (!prisma) { throw new Error('Prisma client is required'); @@ -66,6 +74,10 @@ const app: FastifyPluginAsync = async (fastify, options) => { throw new Error('OpenSearch client is required'); } + if (!accessTransformer) { + throw new Error('accessTransformer is required'); + } + fastify.register(sensible); if (!disableCors) { fastify.register(cors); @@ -74,9 +86,9 @@ const app: FastifyPluginAsync = async (fastify, options) => { await setupDatabase(fastify, prisma); await setupSearch(fastify, opensearch); - fastify.register(entities); - fastify.register(entity); - fastify.register(search); + fastify.register(entities, { accessTransformer, entityTransformers }); + fastify.register(entity, { accessTransformer, entityTransformers }); + fastify.register(search, { accessTransformer, entityTransformers }); }; export default fp(app); diff --git a/src/index.ts b/src/index.ts index 747efdb..6b54319 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { Client } from '@opensearch-project/opensearch'; import Fastify from 'fastify'; -import arocapi from './app.js'; +import arocapi, { AllPublicAccessTransformer } from './app.js'; import { PrismaClient } from './generated/prisma/client.js'; declare module 'fastify' { @@ -22,7 +22,11 @@ const opensearch = new Client({ node: opensearchUrl }); const fastify = Fastify({ logger: true, }); -fastify.register(arocapi, { prisma, opensearch }); +fastify.register(arocapi, { + prisma, + opensearch, + accessTransformer: AllPublicAccessTransformer, +}); const start = async () => { try { diff --git a/src/reset.d.ts b/src/reset.d.ts new file mode 100644 index 0000000..12bd3ed --- /dev/null +++ b/src/reset.d.ts @@ -0,0 +1 @@ +import '@total-typescript/ts-reset'; diff --git a/src/routes/__snapshots__/entities.test.ts.snap b/src/routes/__snapshots__/entities.test.ts.snap index b989350..5c65211 100644 --- a/src/routes/__snapshots__/entities.test.ts.snap +++ b/src/routes/__snapshots__/entities.test.ts.snap @@ -1,5 +1,27 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`Entities Route > GET /entities > should apply custom entity transformers 1`] = ` +{ + "entities": [ + { + "access": { + "content": true, + "metadata": true, + }, + "contentLicenseId": "https://creativecommons.org/licenses/by/4.0/", + "description": "First test entity", + "entityType": "http://pcdm.org/models#Collection", + "id": "http://example.com/entity/1", + "memberOf": null, + "metadataLicenseId": "https://creativecommons.org/licenses/by/4.0/", + "name": "Test Entity 1", + "rootCollection": null, + "tested": true, + }, + ], +} +`; + exports[`Entities Route > GET /entities > should return 500 when database error occurs 1`] = ` { "error": { @@ -13,6 +35,10 @@ exports[`Entities Route > GET /entities > should return entities with default pa { "entities": [ { + "access": { + "content": true, + "metadata": true, + }, "contentLicenseId": "https://creativecommons.org/licenses/by/4.0/", "description": "First test entity", "entityType": "http://pcdm.org/models#Collection", @@ -23,6 +49,10 @@ exports[`Entities Route > GET /entities > should return entities with default pa "rootCollection": null, }, { + "access": { + "content": true, + "metadata": true, + }, "contentLicenseId": "https://creativecommons.org/licenses/by/4.0/", "description": "Second test entity", "entityType": "http://pcdm.org/models#Object", diff --git a/src/routes/__snapshots__/entity.test.ts.snap b/src/routes/__snapshots__/entity.test.ts.snap index 887e934..cf49c66 100644 --- a/src/routes/__snapshots__/entity.test.ts.snap +++ b/src/routes/__snapshots__/entity.test.ts.snap @@ -1,5 +1,23 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`Entity Route > GET /entity/:id > should apply custom entity transformers 1`] = ` +{ + "access": { + "content": true, + "metadata": true, + }, + "contentLicenseId": "https://creativecommons.org/licenses/by/4.0/", + "description": "A test entity", + "entityType": "http://pcdm.org/models#Collection", + "id": "http://example.com/entity/123", + "memberOf": null, + "metadataLicenseId": "https://creativecommons.org/licenses/by/4.0/", + "name": "Test Entity", + "rootCollection": null, + "tested": true, +} +`; + exports[`Entity Route > GET /entity/:id > should return 404 when entity not found 1`] = ` { "error": { @@ -23,6 +41,10 @@ exports[`Entity Route > GET /entity/:id > should return 500 when database error exports[`Entity Route > GET /entity/:id > should return entity when found 1`] = ` { + "access": { + "content": true, + "metadata": true, + }, "contentLicenseId": "https://creativecommons.org/licenses/by/4.0/", "description": "A test entity", "entityType": "http://schema.org/Person", @@ -33,3 +55,12 @@ exports[`Entity Route > GET /entity/:id > should return entity when found 1`] = "rootCollection": null, } `; + +exports[`Entity Route > GET /entity/:id > should validate ID parameter format 1`] = ` +{ + "code": "FST_ERR_VALIDATION", + "error": "Bad Request", + "message": "params/id Invalid URI format", + "statusCode": 400, +} +`; diff --git a/src/routes/__snapshots__/search.test.ts.snap b/src/routes/__snapshots__/search.test.ts.snap index f2bd5ed..58c783f 100644 --- a/src/routes/__snapshots__/search.test.ts.snap +++ b/src/routes/__snapshots__/search.test.ts.snap @@ -1,5 +1,33 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`Search Route > POST /search > should apply custom entity transformers 1`] = ` +{ + "entities": [ + { + "access": { + "content": true, + "metadata": true, + }, + "contentLicenseId": null, + "description": "A test entity", + "entityType": "http://pcdm.org/models#Collection", + "id": "http://example.com/entity/1", + "memberOf": null, + "metadataLicenseId": null, + "name": "Test Entity 1", + "rootCollection": null, + "searchExtra": { + "highlight": {}, + "score": 1.5, + }, + "tested": true, + }, + ], + "searchTime": 10, + "total": 1, +} +`; + exports[`Search Route > POST /search > should handle opensearch errors 1`] = ` { "error": { @@ -9,10 +37,23 @@ exports[`Search Route > POST /search > should handle opensearch errors 1`] = ` } `; +exports[`Search Route > POST /search > should handle rocrateId explicitly set to undefined 1`] = ` +{ + "error": { + "code": "INTERNAL_ERROR", + "message": "Search failed", + }, +} +`; + exports[`Search Route > POST /search > should perform basic search successfully 1`] = ` { "entities": [ { + "access": { + "content": true, + "metadata": true, + }, "contentLicenseId": null, "description": "A test entity", "entityType": "http://pcdm.org/models#Collection", @@ -31,6 +72,10 @@ exports[`Search Route > POST /search > should perform basic search successfully }, }, { + "access": { + "content": true, + "metadata": true, + }, "contentLicenseId": null, "description": "Another test entity", "entityType": "http://pcdm.org/models#Object", @@ -65,3 +110,30 @@ exports[`Search Route > POST /search > should perform basic search successfully "total": 2, } `; + +exports[`Search Route > POST /search > should skip entities not found in database and log warning 1`] = ` +{ + "entities": [ + { + "access": { + "content": true, + "metadata": true, + }, + "contentLicenseId": null, + "description": "A test entity", + "entityType": "http://pcdm.org/models#Collection", + "id": "http://example.com/entity/1", + "memberOf": null, + "metadataLicenseId": null, + "name": "Test Entity 1", + "rootCollection": null, + "searchExtra": { + "highlight": {}, + "score": 1.5, + }, + }, + ], + "searchTime": 10, + "total": 2, +} +`; diff --git a/src/routes/entities.test.ts b/src/routes/entities.test.ts index 6e45c67..357c7f3 100644 --- a/src/routes/entities.test.ts +++ b/src/routes/entities.test.ts @@ -1,13 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { fastify, fastifyAfter, fastifyBefore, prisma } from '../test/helpers/fastify.js'; - +import { AllPublicAccessTransformer } from '../transformers/default.js'; import entitiesRoute from './entities.js'; describe('Entities Route', () => { beforeEach(async () => { await fastifyBefore(); - await fastify.register(entitiesRoute); + await fastify.register(entitiesRoute, { accessTransformer: AllPublicAccessTransformer }); }); afterEach(async () => { @@ -234,5 +234,47 @@ describe('Entities Route', () => { expect(response.statusCode).toBe(400); }); + + it('should apply custom entity transformers', async () => { + // biome-ignore lint/suspicious/noExplicitAny: fine in tests + const customTransformer = async (entity: any) => ({ + ...entity, + tested: true, + }); + + await fastifyBefore(); + await fastify.register(entitiesRoute, { + accessTransformer: AllPublicAccessTransformer, + entityTransformers: [customTransformer], + }); + + const mockEntities = [ + { + id: 1, + rocrateId: 'http://example.com/entity/1', + name: 'Test Entity 1', + description: 'First test entity', + 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/', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + // @ts-expect-error TS is looking at the wronf function signature + prisma.entity.findMany.mockResolvedValue(mockEntities); + + const response = await fastify.inject({ + method: 'GET', + url: '/entities', + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(body).toMatchSnapshot(); + }); }); }); diff --git a/src/routes/entities.ts b/src/routes/entities.ts index 85d81b9..47d458f 100644 --- a/src/routes/entities.ts +++ b/src/routes/entities.ts @@ -1,6 +1,8 @@ import type { FastifyPluginAsync } from 'fastify'; import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod/v4'; +import { baseEntityTransformer } from '../transformers/default.js'; +import type { AccessTransformer, EntityTransformer } from '../types/transformers.js'; import { createInternalError } from '../utils/errors.js'; const querySchema = z.object({ @@ -20,7 +22,13 @@ const querySchema = z.object({ order: z.enum(['asc', 'desc']).default('asc'), }); -const entities: FastifyPluginAsync = async (fastify, _opts) => { +type EntitiesRouteOptions = { + accessTransformer: AccessTransformer; + entityTransformers?: EntityTransformer[]; +}; + +const entities: FastifyPluginAsync = async (fastify, opts) => { + const { accessTransformer, entityTransformers = [] } = opts; fastify.withTypeProvider().get( '/entities', { @@ -59,16 +67,26 @@ const entities: FastifyPluginAsync = async (fastify, _opts) => { // Get total count for pagination metadata const total = await fastify.prisma.entity.count({ where }); - const entities = dbEntities.map((entity) => ({ - id: entity.rocrateId, - name: entity.name, - description: entity.description, - entityType: entity.entityType, - memberOf: entity.memberOf, - rootCollection: entity.rootCollection, - metadataLicenseId: entity.metadataLicenseId, - contentLicenseId: entity.contentLicenseId, - })); + // Apply transformers to each entity: base -> access -> additional + const entities = await Promise.all( + dbEntities.map(async (dbEntity) => { + const standardEntity = baseEntityTransformer(dbEntity); + const authorisedEntity = await accessTransformer(standardEntity, { + request, + fastify, + }); + + let result = authorisedEntity; + for (const transformer of entityTransformers) { + result = await transformer(result, { + request, + fastify, + }); + } + + return result; + }), + ); return { total, diff --git a/src/routes/entity.test.ts b/src/routes/entity.test.ts index 99767ea..774dadf 100644 --- a/src/routes/entity.test.ts +++ b/src/routes/entity.test.ts @@ -1,13 +1,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { fastify, fastifyAfter, fastifyBefore, prisma } from '../test/helpers/fastify.js'; - +import { AllPublicAccessTransformer } from '../transformers/default.js'; +import type { StandardErrorResponse } from '../utils/errors.js'; import entityRoute from './entity.js'; describe('Entity Route', () => { beforeEach(async () => { await fastifyBefore(); - await fastify.register(entityRoute); + await fastify.register(entityRoute, { accessTransformer: AllPublicAccessTransformer }); }); afterEach(async () => { @@ -80,10 +81,50 @@ describe('Entity Route', () => { method: 'GET', url: '/entity/invalid-id', }); - const body = JSON.parse(response.body); + const body = JSON.parse(response.body) as StandardErrorResponse; expect(response.statusCode).toBe(400); expect(body.error).toBe('Bad Request'); }); + + it('should apply custom entity transformers', async () => { + // biome-ignore lint/suspicious/noExplicitAny: fine in tests + const customTransformer = async (entity: any) => ({ + ...entity, + tested: true, + }); + + await fastifyBefore(); + await fastify.register(entityRoute, { + accessTransformer: AllPublicAccessTransformer, + entityTransformers: [customTransformer], + }); + + const mockEntity = { + id: 1, + rocrateId: 'http://example.com/entity/123', + name: 'Test Entity', + description: 'A test entity', + 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/', + createdAt: new Date(), + updatedAt: new Date(), + }; + + // @ts-expect-error TS is looking at the wronf function signature + prisma.entity.findFirst.mockResolvedValue(mockEntity); + + const response = await fastify.inject({ + method: 'GET', + url: `/entity/${encodeURIComponent('http://example.com/entity/123')}`, + }); + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(body).toMatchSnapshot(); + }); }); }); diff --git a/src/routes/entity.ts b/src/routes/entity.ts index 2c9f0c5..da2ddec 100644 --- a/src/routes/entity.ts +++ b/src/routes/entity.ts @@ -1,13 +1,21 @@ import type { FastifyPluginAsync } from 'fastify'; import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod/v4'; +import { baseEntityTransformer } from '../transformers/default.js'; +import type { AccessTransformer, EntityTransformer } from '../types/transformers.js'; import { createInternalError, createNotFoundError } from '../utils/errors.js'; const paramsSchema = z.object({ id: z.string().regex(/^https?:\/\/.+/, 'Invalid URI format'), }); -const entity: FastifyPluginAsync = async (fastify, _opts) => { +type EntityRouteOptions = { + accessTransformer: AccessTransformer; + entityTransformers?: EntityTransformer[]; +}; + +const entity: FastifyPluginAsync = async (fastify, opts) => { + const { accessTransformer, entityTransformers = [] } = opts; fastify.withTypeProvider().get( '/entity/:id', { @@ -29,16 +37,21 @@ const entity: FastifyPluginAsync = async (fastify, _opts) => { return reply.code(404).send(createNotFoundError('The requested entity was not found', id)); } - return { - id: entity.rocrateId, - name: entity.name, - description: entity.description, - entityType: entity.entityType, - memberOf: entity.memberOf, - rootCollection: entity.rootCollection, - metadataLicenseId: entity.metadataLicenseId, - contentLicenseId: entity.contentLicenseId, - }; + const standardEntity = baseEntityTransformer(entity); + const authorisedEntity = await accessTransformer(standardEntity, { + request, + fastify, + }); + + let result = authorisedEntity; + for (const transformer of entityTransformers) { + result = await transformer(result, { + request, + fastify, + }); + } + + return result; } catch (error) { fastify.log.error('Database error:', error); return reply.code(500).send(createInternalError()); diff --git a/src/routes/search.test.ts b/src/routes/search.test.ts index c9c0bc4..9712004 100644 --- a/src/routes/search.test.ts +++ b/src/routes/search.test.ts @@ -1,13 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { fastify, fastifyAfter, fastifyBefore, opensearch } from '../test/helpers/fastify.js'; - +import { fastify, fastifyAfter, fastifyBefore, opensearch, prisma } from '../test/helpers/fastify.js'; +import { AllPublicAccessTransformer } from '../transformers/default.js'; import searchRoute from './search.js'; describe('Search Route', () => { beforeEach(async () => { await fastifyBefore(); - await fastify.register(searchRoute); + await fastify.register(searchRoute, { accessTransformer: AllPublicAccessTransformer }); }); afterEach(async () => { @@ -16,6 +16,33 @@ describe('Search Route', () => { describe('POST /search', () => { it('should perform basic search successfully', async () => { + const mockEntities = [ + { + rocrateId: 'http://example.com/entity/1', + name: 'Test Entity 1', + description: 'A test entity', + entityType: 'http://pcdm.org/models#Collection', + memberOf: null, + rootCollection: null, + metadataLicenseId: null, + contentLicenseId: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }, + { + rocrateId: 'http://example.com/entity/2', + name: 'Test Entity 2', + description: 'Another test entity', + entityType: 'http://pcdm.org/models#Object', + memberOf: null, + rootCollection: null, + metadataLicenseId: null, + contentLicenseId: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }, + ]; + const mockSearchResponse = { body: { took: 10, @@ -26,13 +53,6 @@ describe('Search Route', () => { _score: 1.5, _source: { rocrateId: 'http://example.com/entity/1', - name: 'Test Entity 1', - description: 'A test entity', - entityType: 'http://pcdm.org/models#Collection', - memberOf: null, - rootCollection: null, - metadataLicenseId: null, - contentLicenseId: null, }, highlight: { name: ['Test Entity 1'], @@ -42,13 +62,6 @@ describe('Search Route', () => { _score: 1.2, _source: { rocrateId: 'http://example.com/entity/2', - name: 'Test Entity 2', - description: 'Another test entity', - entityType: 'http://pcdm.org/models#Object', - memberOf: null, - rootCollection: null, - metadataLicenseId: null, - contentLicenseId: null, }, highlight: { description: ['Another test entity'], @@ -67,8 +80,10 @@ describe('Search Route', () => { }, }; - // @ts-expect-error TS is looking at the wronf function signature + // @ts-expect-error TS is looking at the wrong function signature opensearch.search.mockResolvedValue(mockSearchResponse); + // @ts-expect-error TS is looking at the wrong function signature + prisma.entity.findMany.mockResolvedValue(mockEntities); const response = await fastify.inject({ method: 'POST', @@ -82,6 +97,16 @@ describe('Search Route', () => { expect(response.statusCode).toBe(200); const body = JSON.parse(response.body); expect(body).toMatchSnapshot(); + + // Verify database was queried with correct rocrateIds + expect(prisma.entity.findMany).toHaveBeenCalledWith({ + where: { + rocrateId: { + in: ['http://example.com/entity/1', 'http://example.com/entity/2'], + }, + }, + }); + expect(opensearch.search).toHaveBeenCalledWith({ index: 'entities', body: { @@ -131,8 +156,9 @@ describe('Search Route', () => { }, }; - // @ts-expect-error TS is looking at the wronf function signature + // @ts-expect-error TS is looking at the wrong function signature opensearch.search.mockResolvedValue(mockSearchResponse); + prisma.entity.findMany.mockResolvedValue([]); const response = await fastify.inject({ method: 'POST', @@ -192,8 +218,9 @@ describe('Search Route', () => { }, }; - // @ts-expect-error TS is looking at the wronf function signature + // @ts-expect-error TS is looking at the wrong function signature opensearch.search.mockResolvedValue(mockSearchResponse); + prisma.entity.findMany.mockResolvedValue([]); const response = await fastify.inject({ method: 'POST', @@ -253,8 +280,9 @@ describe('Search Route', () => { }, }; - // @ts-expect-error TS is looking at the wronf function signature + // @ts-expect-error TS is looking at the wrong function signature opensearch.search.mockResolvedValue(mockSearchResponse); + prisma.entity.findMany.mockResolvedValue([]); const response = await fastify.inject({ method: 'POST', @@ -270,7 +298,7 @@ describe('Search Route', () => { }); expect(response.statusCode).toBe(200); - const body = JSON.parse(response.body); + const body = JSON.parse(response.body) as { geohashGrid: Record }; expect(body.geohashGrid).toEqual({ gbsuv: 3, gbsvb: 1, @@ -322,8 +350,9 @@ describe('Search Route', () => { }, }; - // @ts-expect-error TS is looking at the wronf function signature + // @ts-expect-error TS is looking at the wrong function signature opensearch.search.mockResolvedValue(mockSearchResponse); + prisma.entity.findMany.mockResolvedValue([]); const response = await fastify.inject({ method: 'POST', @@ -375,7 +404,69 @@ describe('Search Route', () => { expect(response.statusCode).toBe(400); }); - it('should handle missing _source in search hit', async () => { + it('should skip entities not found in database and log warning', async () => { + const mockEntities = [ + { + rocrateId: 'http://example.com/entity/1', + name: 'Test Entity 1', + description: 'A test entity', + entityType: 'http://pcdm.org/models#Collection', + memberOf: null, + rootCollection: null, + metadataLicenseId: null, + contentLicenseId: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }, + // Entity 2 is missing from database + ]; + + const mockSearchResponse = { + body: { + took: 10, + hits: { + total: { value: 2 }, + hits: [ + { + _score: 1.5, + _source: { + rocrateId: 'http://example.com/entity/1', + }, + highlight: {}, + }, + { + _score: 1.2, + _source: { + rocrateId: 'http://example.com/entity/2', + }, + highlight: {}, + }, + ], + }, + aggregations: {}, + }, + }; + + // @ts-expect-error TS is looking at the wrong function signature + opensearch.search.mockResolvedValue(mockSearchResponse); + // @ts-expect-error TS is looking at the wrong function signature + prisma.entity.findMany.mockResolvedValue(mockEntities); + + const response = await fastify.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'test', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + expect(body).toMatchSnapshot(); + }); + + it('should handle missing rocrateId in search hit', async () => { const mockSearchResponse = { body: { took: 5, @@ -384,7 +475,9 @@ describe('Search Route', () => { hits: [ { _score: 1.5, - // Missing _source + _source: { + // Missing rocrateId + }, }, ], }, @@ -392,8 +485,9 @@ describe('Search Route', () => { }, }; - // @ts-expect-error TS is looking at the wronf function signature + // @ts-expect-error TS is looking at the wrong function signature opensearch.search.mockResolvedValue(mockSearchResponse); + prisma.entity.findMany.mockResolvedValue([]); const response = await fastify.inject({ method: 'POST', @@ -404,6 +498,109 @@ describe('Search Route', () => { }); expect(response.statusCode).toBe(500); + console.log(response.body); + }); + + it('should handle rocrateId explicitly set to undefined', async () => { + const mockSearchResponse = { + body: { + took: 5, + hits: { + total: { value: 1 }, + hits: [ + { + _score: 1.5, + _source: { + rocrateId: undefined, + name: 'Test Entity', + }, + }, + ], + }, + aggregations: {}, + }, + }; + + // @ts-expect-error TS is looking at the wrong function signature + opensearch.search.mockResolvedValue(mockSearchResponse); + + const response = await fastify.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'test', + }, + }); + + expect(response.statusCode).toBe(500); + const body = JSON.parse(response.body); + expect(body).toMatchSnapshot(); + }); + + it('should apply custom entity transformers', async () => { + // biome-ignore lint/suspicious/noExplicitAny: fine in tests + const customTransformer = async (entity: any) => ({ + ...entity, + tested: true, + }); + + await fastifyBefore(); + await fastify.register(searchRoute, { + accessTransformer: AllPublicAccessTransformer, + entityTransformers: [customTransformer], + }); + + const mockEntities = [ + { + rocrateId: 'http://example.com/entity/1', + name: 'Test Entity 1', + description: 'A test entity', + entityType: 'http://pcdm.org/models#Collection', + memberOf: null, + rootCollection: null, + metadataLicenseId: null, + contentLicenseId: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }, + ]; + + const mockSearchResponse = { + body: { + took: 10, + hits: { + total: { value: 1 }, + hits: [ + { + _score: 1.5, + _source: { + rocrateId: 'http://example.com/entity/1', + }, + highlight: {}, + }, + ], + }, + aggregations: {}, + }, + }; + + // @ts-expect-error TS is looking at the wrong function signature + opensearch.search.mockResolvedValue(mockSearchResponse); + // @ts-expect-error TS is looking at the wrong function signature + prisma.entity.findMany.mockResolvedValue(mockEntities); + + const response = await fastify.inject({ + method: 'POST', + url: '/search', + payload: { + query: 'test', + }, + }); + + const body = JSON.parse(response.body); + + expect(response.statusCode).toBe(200); + expect(body).toMatchSnapshot(); }); }); }); diff --git a/src/routes/search.ts b/src/routes/search.ts index 17f2ae3..c69ccd3 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -4,6 +4,8 @@ import type { Search_Request, Search_RequestBody } from '@opensearch-project/ope import type { FastifyPluginAsync } from 'fastify'; import type { ZodTypeProvider } from 'fastify-type-provider-zod'; import { z } from 'zod/v4'; +import { baseEntityTransformer } from '../transformers/default.js'; +import type { AccessTransformer, EntityTransformer } from '../types/transformers.js'; import { createInternalError } from '../utils/errors.js'; const boundingBoxSchema = z.object({ @@ -162,7 +164,13 @@ const buildSort = (sort: SearchParams['sort'], order: SearchParams['order']) => return [{ [sortField]: order }]; }; -const search: FastifyPluginAsync = async (fastify, _opts) => { +type SearchRouteOptions = { + accessTransformer: AccessTransformer; + entityTransformers?: EntityTransformer[]; +}; + +const search: FastifyPluginAsync = async (fastify, opts) => { + const { accessTransformer, entityTransformers = [] } = opts; fastify.withTypeProvider().post( '/search', { @@ -193,28 +201,58 @@ const search: FastifyPluginAsync = async (fastify, _opts) => { const response = await fastify.opensearch.search(opensearchQuery); - // Transform response + const rocrateIds = response.body.hits.hits + .map((hit) => hit._source?.rocrateId as string | undefined) + .filter(Boolean); - const entities = response.body.hits.hits.map((hit) => { - if (!hit._source) { - throw new Error('Missing _source in search hit'); - } - return { - id: hit._source.rocrateId, - name: hit._source.name, - description: hit._source.description, - entityType: hit._source.entityType, - memberOf: hit._source.memberOf, - rootCollection: hit._source.rootCollection, - metadataLicenseId: hit._source.metadataLicenseId, - contentLicenseId: hit._source.contentLicenseId, - searchExtra: { - score: hit._score, - highlight: hit.highlight, + const dbEntities = await fastify.prisma.entity.findMany({ + where: { + rocrateId: { + in: rocrateIds, }, - }; + }, }); + const entityMap = new Map(dbEntities.map((entity) => [entity.rocrateId, entity])); + + const entities = await Promise.all( + response.body.hits.hits.map(async (hit) => { + if (!hit._source?.rocrateId) { + throw new Error('Missing rocrateId in search hit'); + } + + const dbEntity = entityMap.get(hit._source.rocrateId); + if (!dbEntity) { + fastify.log.warn(`Entity ${hit._source.rocrateId} found in OpenSearch but not in database`); + + return null; + } + + const standardEntity = baseEntityTransformer(dbEntity); + const authorisedEntity = await accessTransformer(standardEntity, { + request, + fastify, + }); + + let result = authorisedEntity; + for (const transformer of entityTransformers) { + result = await transformer(result, { + request, + fastify, + }); + } + + // Add search-specific metadata + return { + ...(result as Record), + searchExtra: { + score: hit._score, + highlight: hit.highlight, + }, + }; + }), + ).then((results) => results.filter(Boolean)); + const facets: Record> = {}; if (response.body.aggregations) { Object.keys(response.body.aggregations).forEach((key) => { diff --git a/src/test/__snapshots__/integration.test.ts.snap b/src/test/__snapshots__/integration.test.ts.snap new file mode 100644 index 0000000..7bc473c --- /dev/null +++ b/src/test/__snapshots__/integration.test.ts.snap @@ -0,0 +1,18 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Integration Tests > GET /entity/:id > should return entity from database 1`] = ` +{ + "access": { + "content": true, + "metadata": true, + }, + "contentLicenseId": "https://creativecommons.org/licenses/by/4.0/", + "description": "First test entity", + "entityType": "http://pcdm.org/models#Collection", + "id": "http://example.com/entity/1", + "memberOf": null, + "metadataLicenseId": "https://creativecommons.org/licenses/by/4.0/", + "name": "Test Collection", + "rootCollection": null, +} +`; diff --git a/src/test/integration.setup.ts b/src/test/integration.setup.ts index b9f4413..367fae6 100644 --- a/src/test/integration.setup.ts +++ b/src/test/integration.setup.ts @@ -1,7 +1,7 @@ import { Client } from '@opensearch-project/opensearch'; import type { FastifyInstance } from 'fastify'; import Fastify from 'fastify'; -import app from '../app.js'; +import app, { AllPublicAccessTransformer } from '../app.js'; import { PrismaClient } from '../generated/prisma/client.js'; let fastify: FastifyInstance; @@ -33,6 +33,7 @@ export async function setupIntegrationTests() { prisma, opensearch, disableCors: true, + accessTransformer: AllPublicAccessTransformer, }); await fastify.ready(); diff --git a/src/test/integration.test.ts b/src/test/integration.test.ts index fbd805b..2bcbeb2 100644 --- a/src/test/integration.test.ts +++ b/src/test/integration.test.ts @@ -1,4 +1,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { AuthorisedEntity } from '../transformers/default.js'; +import type { StandardErrorResponse } from '../utils/errors.js'; import { cleanupTestData, getTestApp, @@ -32,30 +34,20 @@ describe('Integration Tests', () => { method: 'GET', url: `/entity/${encodeURIComponent('http://example.com/entity/1')}`, }); - const body = JSON.parse(response.body); expect(response.statusCode).toBe(200); - expect(body).toEqual({ - id: 'http://example.com/entity/1', - name: 'Test Collection', - description: 'First test entity', - 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/', - }); + expect(body).toMatchSnapshot(); }); it('should return 404 for non-existent entity', async () => { const app = getTestApp(); + const response = await app.inject({ method: 'GET', url: '/entity/http://example.com/entity/nonexistent', }); - - const body = JSON.parse(response.body); + const body = JSON.parse(response.body) as StandardErrorResponse; expect(response.statusCode).toBe(404); expect(body.error).toBe('Not Found'); @@ -65,12 +57,12 @@ describe('Integration Tests', () => { describe('GET /entities', () => { it('should return all entities with pagination', async () => { const app = getTestApp(); + const response = await app.inject({ method: 'GET', url: '/entities', }); - - const body = JSON.parse(response.body); + const body = JSON.parse(response.body) as { total: number; entities: AuthorisedEntity[] }; expect(response.statusCode).toBe(200); expect(body.total).toBe(3); @@ -84,11 +76,16 @@ describe('Integration Tests', () => { rootCollection: null, metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + access: { + metadata: true, + content: true, + }, }); }); it('should filter entities by memberOf', async () => { const app = getTestApp(); + const response = await app.inject({ method: 'GET', url: '/entities', @@ -96,8 +93,7 @@ describe('Integration Tests', () => { memberOf: 'http://example.com/entity/1', }, }); - - const body = JSON.parse(response.body); + const body = JSON.parse(response.body) as { total: number; entities: AuthorisedEntity[] }; expect(response.statusCode).toBe(200); expect(body.total).toBe(2); @@ -115,7 +111,7 @@ describe('Integration Tests', () => { entityType: 'http://schema.org/Person', }, }); - const body = JSON.parse(response.body); + const body = JSON.parse(response.body) as { total: number; entities: AuthorisedEntity[] }; expect(response.statusCode).toBe(200); expect(body.total).toBe(1); @@ -134,7 +130,7 @@ describe('Integration Tests', () => { offset: '1', }, }); - const body = JSON.parse(response.body); + const body = JSON.parse(response.body) as { total: number; entities: AuthorisedEntity[] }; expect(response.statusCode).toBe(200); expect(body.total).toBe(3); @@ -152,7 +148,7 @@ describe('Integration Tests', () => { order: 'desc', }, }); - const body = JSON.parse(response.body); + const body = JSON.parse(response.body) as { total: number; entities: AuthorisedEntity[] }; expect(response.statusCode).toBe(200); expect(body.entities[0].name).toBe('Test Person'); @@ -173,7 +169,7 @@ describe('Integration Tests', () => { searchType: 'basic', }, }); - const body = JSON.parse(response.body); + const body = JSON.parse(response.body) as { total: number; entities: AuthorisedEntity[] }; expect(response.statusCode).toBe(200); expect(body.total).toBeGreaterThan(0); @@ -192,7 +188,7 @@ describe('Integration Tests', () => { searchType: 'basic', }, }); - const body = JSON.parse(response.body); + const body = JSON.parse(response.body) as { total: number; entities: AuthorisedEntity[] }; expect(response.statusCode).toBe(200); expect(body.total).toBe(0); @@ -210,7 +206,7 @@ describe('Integration Tests', () => { searchType: 'basic', }, }); - const body = JSON.parse(response.body); + const body = JSON.parse(response.body) as { total: number; entities: AuthorisedEntity[]; facets: null }; expect(response.statusCode).toBe(200); expect(body.facets).toBeDefined(); @@ -228,7 +224,7 @@ describe('Integration Tests', () => { offset: 0, }, }); - const body = JSON.parse(response.body); + const body = JSON.parse(response.body) as { total: number; entities: AuthorisedEntity[] }; expect(response.statusCode).toBe(200); expect(body.entities.length).toBeLessThanOrEqual(1); @@ -245,7 +241,7 @@ describe('Integration Tests', () => { sort: 'id', }, }); - const body = JSON.parse(response.body); + const body = JSON.parse(response.body) as { total: number; entities: AuthorisedEntity[] }; expect(response.statusCode).toBe(200); expect(body.entities[0].name).toBe('Test Collection'); @@ -262,7 +258,7 @@ describe('Integration Tests', () => { method: 'GET', url: '/entity/invalid-id', }); - const body = JSON.parse(response.body); + const body = JSON.parse(response.body) as StandardErrorResponse; expect(response.statusCode).toBe(400); expect(body.error.code).toBe('VALIDATION_ERROR'); diff --git a/src/transformers/default.ts b/src/transformers/default.ts new file mode 100644 index 0000000..6479f0b --- /dev/null +++ b/src/transformers/default.ts @@ -0,0 +1,74 @@ +import type { Entity } from '../generated/prisma/client.js'; + +/** + * Standard entity shape - output of base transformation + * Does not include access information + */ +export type StandardEntity = { + id: string; + name: string; + description: string; + entityType: string; + memberOf: string | null; + rootCollection: string | null; + metadataLicenseId: string; + contentLicenseId: string; +}; + +/** + * Access information for an entity + */ +type AccessInfo = { + metadata: boolean; + content: boolean; + contentAuthorizationUrl?: string; +}; + +/** + * Authorised entity - includes access information + * This is the output of the access transformer + */ +export type AuthorisedEntity = StandardEntity & { + access: AccessInfo; +}; + +/** + * Base entity transformer - always applied first + * Transforms raw database entity to standard entity shape (without access) + */ +export const baseEntityTransformer = (entity: Entity): StandardEntity => ({ + id: entity.rocrateId, + name: entity.name, + description: entity.description, + entityType: entity.entityType, + memberOf: entity.memberOf, + rootCollection: entity.rootCollection, + metadataLicenseId: entity.metadataLicenseId, + contentLicenseId: entity.contentLicenseId, +}); + +/** + * All Public Access Transformer - grants full access to metadata and content + * + * WARNING: This transformer makes ALL content publicly accessible without restrictions. + * Only use this for fully public datasets where no access control is needed. + * + * For repositories with restricted content, implement a custom accessTransformer + * that checks user permissions and licenses. + * + * @example + * ```typescript + * await server.register(arocapi, { + * prisma, + * opensearch, + * accessTransformer: AllPublicAccessTransformer, // Explicit choice for public data + * }); + * ``` + */ +export const AllPublicAccessTransformer = (entity: StandardEntity): AuthorisedEntity => ({ + ...entity, + access: { + metadata: true, + content: true, + }, +}); diff --git a/src/transformers/transformer.test.ts b/src/transformers/transformer.test.ts new file mode 100644 index 0000000..7b349b8 --- /dev/null +++ b/src/transformers/transformer.test.ts @@ -0,0 +1,229 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { describe, expect, it } from 'vitest'; +import type { AccessTransformer, EntityTransformer, TransformerContext } from '../types/transformers.js'; +import { + AllPublicAccessTransformer, + type AuthorisedEntity, + baseEntityTransformer, + type StandardEntity, +} from './default.js'; + +describe('Entity Transformers', () => { + const mockContext: TransformerContext = { + request: {} as FastifyRequest, + fastify: {} as FastifyInstance, + }; + + const mockStandardEntity: StandardEntity = { + id: 'http://example.com/entity/123', + name: 'Test Entity', + description: 'A test entity', + entityType: 'http://schema.org/Person', + memberOf: 'http://example.com/collection', + rootCollection: 'http://example.com/root', + metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + }; + + const mockAuthorisedEntity: AuthorisedEntity = { + ...mockStandardEntity, + access: { + metadata: true, + content: true, + }, + }; + + describe('baseEntityTransformer', () => { + it('should transform raw entity to standard shape', () => { + const rawEntity = { + id: 1, + rocrateId: 'http://example.com/entity/123', + name: 'Test Entity', + description: 'A test entity', + entityType: 'http://schema.org/Person', + memberOf: 'http://example.com/collection', + rootCollection: 'http://example.com/root', + metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + rocrate: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = baseEntityTransformer(rawEntity); + + expect(result).toEqual(mockStandardEntity); + }); + }); + + describe('AllPublicAccessTransformer', () => { + it('should add full access to standard entity', () => { + const result = AllPublicAccessTransformer(mockStandardEntity); + + expect(result).toEqual({ + ...mockStandardEntity, + access: { + metadata: true, + content: true, + }, + }); + }); + + it('should grant metadata and content access', () => { + const result = AllPublicAccessTransformer(mockStandardEntity); + + expect(result.access.metadata).toBe(true); + expect(result.access.content).toBe(true); + expect(result.access.contentAuthorizationUrl).toBeUndefined(); + }); + }); + + describe('Custom transformers', () => { + it('should allow custom transformer to add computed fields', () => { + const customTransformer: EntityTransformer = ( + entity, + ) => ({ + id: entity.id, + displayName: entity.name.toUpperCase(), + uri: entity.id, + }); + + const result = customTransformer(mockAuthorisedEntity, mockContext); + + expect(result).toEqual({ + id: 'http://example.com/entity/123', + displayName: 'TEST ENTITY', + uri: 'http://example.com/entity/123', + }); + }); + + it('should support async transformers for fetching related data', async () => { + const asyncTransformer: EntityTransformer< + AuthorisedEntity, + { id: string; name: string; relatedData: string } + > = async (entity) => { + // Simulate async operation + await new Promise((resolve) => setTimeout(resolve, 1)); + + return { + id: entity.id, + name: entity.name, + relatedData: 'fetched-data', + }; + }; + + const result = await asyncTransformer(mockAuthorisedEntity, mockContext); + + expect(result).toEqual({ + id: 'http://example.com/entity/123', + name: 'Test Entity', + relatedData: 'fetched-data', + }); + }); + + it('should allow transformer to access context', async () => { + const contextAwareTransformer: EntityTransformer< + AuthorisedEntity, + { + id: string; + name: string; + requestUrl: string; + } + > = async (entity, context) => ({ + id: entity.id, + name: entity.name, + requestUrl: context.request.url || 'unknown', + }); + + const contextWithUrl: TransformerContext = { + request: { url: '/test-url' } as FastifyRequest, + fastify: {} as FastifyInstance, + }; + + const result = await contextAwareTransformer(mockAuthorisedEntity, contextWithUrl); + + expect(result).toEqual({ + id: 'http://example.com/entity/123', + name: 'Test Entity', + requestUrl: '/test-url', + }); + }); + + it('should support transformer pipeline', async () => { + const addDisplayName: EntityTransformer = ( + entity, + ) => ({ + ...entity, + displayName: `${entity.name} [${entity.entityType.split('/').pop()}]`, + }); + + const addUpperCase: EntityTransformer< + AuthorisedEntity & { displayName: string }, + AuthorisedEntity & { displayName: string; upperName: string } + > = (entity) => ({ + ...entity, + upperName: entity.name.toUpperCase(), + }); + + // Simulate pipeline + // biome-ignore lint/suspicious/noExplicitAny: fine in tests + let result: any = mockAuthorisedEntity; + result = await addDisplayName(result, mockContext); + result = await addUpperCase(result, mockContext); + + expect(result).toMatchObject({ + id: 'http://example.com/entity/123', + name: 'Test Entity', + displayName: 'Test Entity [Person]', + upperName: 'TEST ENTITY', + }); + }); + + it('should demonstrate full pipeline: base -> access -> custom', async () => { + const rawEntity = { + id: 1, + rocrateId: 'http://example.com/entity/123', + name: 'Test Entity', + description: 'A test entity', + entityType: 'http://schema.org/Person', + memberOf: 'http://example.com/collection', + rootCollection: 'http://example.com/root', + metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', + rocrate: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const customAccessTransformer: AccessTransformer = (entity) => ({ + ...entity, + access: { + metadata: true, + content: false, + contentAuthorizationUrl: 'https://example.com/auth', + }, + }); + + const addMetadata: EntityTransformer = (entity) => ({ + ...entity, + processed: true, + }); + + // Full pipeline + const standard = baseEntityTransformer(rawEntity); + const authorised = await customAccessTransformer(standard, mockContext); + const final = await addMetadata(authorised, mockContext); + + expect(final).toMatchObject({ + id: 'http://example.com/entity/123', + name: 'Test Entity', + access: { + metadata: true, + content: false, + contentAuthorizationUrl: 'https://example.com/auth', + }, + processed: true, + }); + }); + }); +}); diff --git a/src/types/transformers.ts b/src/types/transformers.ts new file mode 100644 index 0000000..dff46b9 --- /dev/null +++ b/src/types/transformers.ts @@ -0,0 +1,30 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import type { AuthorisedEntity, StandardEntity } from '../transformers/default.js'; + +/** + * Context provided to entity transformers + */ +export type TransformerContext = { + request: FastifyRequest; + fastify: FastifyInstance; +}; + +/** + * Access transformer function - required + * Transforms StandardEntity to AuthorisedEntity by adding access information + */ +export type AccessTransformer = ( + entity: StandardEntity, + context: TransformerContext, +) => Promise | AuthorisedEntity; + +/** + * Entity transformer function + * Receives AuthorisedEntity and transforms it further + * Transformers are applied as a pipeline, with each transformer receiving + * the output of the previous one + */ +export type EntityTransformer = ( + entity: TInput, + context: TransformerContext, +) => Promise | TOutput; diff --git a/src/utils/errors.ts b/src/utils/errors.ts index d4c635f..cf71a3a 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -12,12 +12,11 @@ type ErrorDetails = { [key: string]: unknown; }; -type StandardErrorResponse = { +export type StandardErrorResponse = { error: { code: ErrorCode; message: string; details?: ErrorDetails; - requestId?: string; }; }; diff --git a/vitest.config.ts b/vitest.config.ts index 4fc892a..f4e90ab 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -24,6 +24,10 @@ export default defineConfig({ // Not part of library 'src/index.ts', + // Only types + 'src/types/*', + 'src/reset.d.ts', + // TODO 'src/express.ts', ],