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 83e4645..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", @@ -32,6 +31,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 +71,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 +80,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..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 @@ -54,6 +51,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 +800,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 +3527,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 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', ],