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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
360 changes: 360 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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> | 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<TInput = AuthorisedEntity, TOutput = TInput> = (
entity: TInput,
context: TransformerContext,
) => Promise<TOutput> | 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
Expand Down
Loading