Skip to content

Commit 7c639de

Browse files
committed
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
1 parent e4eea25 commit 7c639de

25 files changed

+1477
-115
lines changed

CLAUDE.md

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ src/
110110
│ ├── entity.ts # Single entity operations
111111
│ ├── entities.ts # Entity listing/filtering
112112
│ └── search.ts # OpenSearch operations
113+
├── transformers/ # Entity transformers
114+
│ └── default.ts # Default entity transformer
115+
├── types/ # TypeScript type definitions
116+
│ └── transformers.ts # Transformer type definitions
113117
├── utils/ # Utility functions
114118
│ └── errors.ts # Error handling helpers
115119
├── generated/ # Prisma generated files (excluded from tests)
@@ -160,6 +164,362 @@ src/
160164
- Validate against OpenAPI specification
161165
- Handle validation errors gracefully
162166

167+
## Entity Transformers
168+
169+
The API provides a flexible transformer system for customising entity responses. Transformers are applied in a three-stage pipeline:
170+
171+
1. **Base transformer** - Converts database entities to standard format
172+
2. **Access transformer** - Adds authorisation/access control information
173+
3. **Entity transformers** - Optional additional transformations
174+
175+
### Overview
176+
177+
The transformer system enables:
178+
- **Access control**: Control visibility of metadata and content based on licenses
179+
- **Data enrichment**: Add computed fields or fetch related data
180+
- **Response customisation**: Adapt the API response to specific client needs
181+
- **Async operations**: Fetch additional data or perform authorisation checks
182+
183+
### Transformation Pipeline
184+
185+
Every entity response flows through this pipeline:
186+
187+
```
188+
Database Entity → baseEntityTransformer → accessTransformer → entityTransformers[] → Response
189+
```
190+
191+
### Usage
192+
193+
When mounting the application, you **must** provide an `accessTransformer`. This is a required security feature to ensure conscious decisions about access control.
194+
195+
```typescript
196+
import { Client } from '@opensearch-project/opensearch';
197+
import arocapi, { AllPublicAccessTransformer } from 'arocapi';
198+
import fastify from 'fastify';
199+
import { PrismaClient } from './generated/prisma/client.js';
200+
201+
const server = fastify();
202+
const prisma = new PrismaClient();
203+
const opensearch = new Client({ node: process.env.OPENSEARCH_URL });
204+
205+
// For fully public datasets, use AllPublicAccessTransformer
206+
await server.register(arocapi, {
207+
prisma,
208+
opensearch,
209+
accessTransformer: AllPublicAccessTransformer, // Explicit choice for public data
210+
});
211+
212+
// For restricted content, provide custom accessTransformer
213+
await server.register(arocapi, {
214+
prisma,
215+
opensearch,
216+
// Required: Controls access to metadata and content
217+
accessTransformer: async (entity, { request, fastify }) => {
218+
const user = await authenticateUser(request);
219+
const canAccessContent = await checkLicense(entity.contentLicenseId, user);
220+
221+
return {
222+
...entity,
223+
access: {
224+
metadata: true, // Metadata always visible
225+
content: canAccessContent,
226+
contentAuthorisationUrl: canAccessContent
227+
? undefined
228+
: 'https://example.com/request-access',
229+
},
230+
};
231+
},
232+
// Optional: Additional data transformations
233+
entityTransformers: [
234+
// Add computed fields
235+
async (entity) => ({
236+
...entity,
237+
displayName: `${entity.name} [${entity.entityType.split('/').pop()}]`,
238+
}),
239+
// Fetch related data
240+
async (entity, { fastify }) => {
241+
const collection = entity.memberOf
242+
? await fastify.prisma.entity.findFirst({
243+
where: { rocrateId: entity.memberOf },
244+
})
245+
: null;
246+
247+
return {
248+
...entity,
249+
collection: collection ? { id: collection.rocrateId, name: collection.name } : null,
250+
};
251+
},
252+
],
253+
});
254+
```
255+
256+
### Transformer Types
257+
258+
#### Access Transformer
259+
260+
Controls access to metadata and content. Receives a `StandardEntity` and must return an `AuthorisedEntity`.
261+
262+
```typescript
263+
type AccessTransformer = (
264+
entity: StandardEntity,
265+
context: TransformerContext,
266+
) => Promise<AuthorisedEntity> | AuthorisedEntity;
267+
268+
type StandardEntity = {
269+
id: string;
270+
name: string;
271+
description: string;
272+
entityType: string;
273+
memberOf: string | null;
274+
rootCollection: string | null;
275+
metadataLicenseId: string;
276+
contentLicenseId: string;
277+
};
278+
279+
type AuthorisedEntity = StandardEntity & {
280+
access: {
281+
metadata: boolean;
282+
content: boolean;
283+
contentAuthorisationUrl?: string;
284+
};
285+
};
286+
```
287+
288+
#### Entity Transformers
289+
290+
Optional transformations applied after access control. Each transformer receives the output of the previous one.
291+
292+
```typescript
293+
type EntityTransformer<TInput = AuthorisedEntity, TOutput = TInput> = (
294+
entity: TInput,
295+
context: TransformerContext,
296+
) => Promise<TOutput> | TOutput;
297+
```
298+
299+
#### Transformer Context
300+
301+
All transformers receive a context object:
302+
303+
```typescript
304+
type TransformerContext = {
305+
request: FastifyRequest; // Access request headers, params, etc.
306+
fastify: FastifyInstance; // Access prisma, opensearch, etc.
307+
};
308+
```
309+
310+
### AllPublicAccessTransformer
311+
312+
The `AllPublicAccessTransformer` is provided for fully public datasets. It grants full access to all data:
313+
314+
```typescript
315+
import { AllPublicAccessTransformer } from 'arocapi';
316+
317+
// Returns:
318+
{
319+
...entity,
320+
access: {
321+
metadata: true,
322+
content: true,
323+
},
324+
}
325+
```
326+
327+
**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.
328+
329+
### Applied Routes
330+
331+
Transformers are applied to all entity routes:
332+
- `GET /entity/:id` - Single entity
333+
- `GET /entities` - Entity list (each entity transformed)
334+
- `POST /search` - Search results (entities + search metadata)
335+
336+
For search results, entities are transformed and then wrapped with search metadata:
337+
338+
```typescript
339+
{
340+
...transformedEntity,
341+
searchExtra: {
342+
score: hit._score,
343+
highlight: hit.highlight,
344+
},
345+
}
346+
```
347+
348+
### Examples
349+
350+
#### Access Control with License Checking
351+
352+
```typescript
353+
accessTransformer: async (entity, { request, fastify }) => {
354+
const user = await getUserFromRequest(request);
355+
356+
// Check if user has access to content license
357+
const hasContentAccess = await checkUserLicense(
358+
user,
359+
entity.contentLicenseId,
360+
fastify.prisma,
361+
);
362+
363+
return {
364+
...entity,
365+
access: {
366+
metadata: true, // Metadata always visible
367+
content: hasContentAccess,
368+
contentAuthorisationUrl: hasContentAccess
369+
? undefined
370+
: `https://rems.example.com/apply?license=${entity.contentLicenseId}`,
371+
},
372+
};
373+
}
374+
```
375+
376+
#### Add Computed Display Name
377+
378+
```typescript
379+
entityTransformers: [
380+
(entity) => ({
381+
...entity,
382+
displayName: `${entity.name} [${entity.entityType.split('/').pop()}]`,
383+
shortId: entity.id.split('/').pop(),
384+
}),
385+
]
386+
```
387+
388+
#### Fetch Related Collection Data
389+
390+
```typescript
391+
entityTransformers: [
392+
async (entity, { fastify }) => {
393+
if (!entity.memberOf) {
394+
return { ...entity, collection: null };
395+
}
396+
397+
const collection = await fastify.prisma.entity.findFirst({
398+
where: { rocrateId: entity.memberOf },
399+
});
400+
401+
return {
402+
...entity,
403+
collection: collection ? {
404+
id: collection.rocrateId,
405+
name: collection.name,
406+
type: collection.entityType,
407+
} : null,
408+
};
409+
},
410+
]
411+
```
412+
413+
#### Combine Access Control and Data Enrichment
414+
415+
```typescript
416+
await server.register(arocapi, {
417+
prisma,
418+
opensearch,
419+
// Control access based on user authentication
420+
accessTransformer: async (entity, { request, fastify }) => {
421+
const token = request.headers.authorisation;
422+
const user = token ? await verifyToken(token) : null;
423+
424+
const canAccess = user
425+
? await checkLicense(entity.contentLicenseId, user.id, fastify.prisma)
426+
: entity.contentLicenseId === 'http://creativecommons.org/publicdomain/zero/1.0/';
427+
428+
return {
429+
...entity,
430+
access: {
431+
metadata: true,
432+
content: canAccess,
433+
},
434+
};
435+
},
436+
// Add additional computed fields
437+
entityTransformers: [
438+
(entity) => ({
439+
...entity,
440+
displayName: entity.name.toUpperCase(),
441+
createdYear: new Date().getFullYear(), // Could fetch from metadata
442+
}),
443+
],
444+
});
445+
```
446+
447+
#### Request-Specific Data
448+
449+
```typescript
450+
entityTransformers: [
451+
(entity, { request }) => ({
452+
...entity,
453+
requestInfo: {
454+
timestamp: new Date().toISOString(),
455+
userAgent: request.headers['user-agent'],
456+
acceptLanguage: request.headers['accept-language'],
457+
},
458+
}),
459+
]
460+
```
461+
462+
### Testing Transformers
463+
464+
Test custom transformers by passing them to your test Fastify instance:
465+
466+
```typescript
467+
import { describe, it, expect, beforeEach } from 'vitest';
468+
import { fastify, fastifyBefore } from './test/helpers/fastify.js';
469+
import entityRoute from './routes/entity.js';
470+
471+
describe('Custom Transformer Tests', () => {
472+
beforeEach(async () => {
473+
await fastifyBefore();
474+
});
475+
476+
it('should apply custom access transformer', async () => {
477+
const customAccessTransformer = (entity) => ({
478+
...entity,
479+
access: {
480+
metadata: true,
481+
content: false, // Restrict content access
482+
contentAuthorisationUrl: 'https://example.com/request',
483+
},
484+
});
485+
486+
await fastify.register(entityRoute, {
487+
accessTransformer: customAccessTransformer,
488+
});
489+
490+
const response = await fastify.inject({
491+
method: 'GET',
492+
url: '/entity/http://example.com/test',
493+
});
494+
495+
const body = JSON.parse(response.body);
496+
expect(body.access.content).toBe(false);
497+
});
498+
499+
it('should apply custom entity transformers', async () => {
500+
const customTransformer = (entity) => ({
501+
...entity,
502+
tested: true,
503+
displayName: entity.name.toUpperCase(),
504+
});
505+
506+
await fastify.register(entityRoute, {
507+
accessTransformer: AllPublicAccessTransformer,
508+
entityTransformers: [customTransformer],
509+
});
510+
511+
const response = await fastify.inject({
512+
method: 'GET',
513+
url: '/entity/http://example.com/test',
514+
});
515+
516+
const body = JSON.parse(response.body);
517+
expect(body.tested).toBe(true);
518+
expect(body.displayName).toBe('TEST ENTITY');
519+
});
520+
});
521+
```
522+
163523
## Database Management
164524

165525
### Prisma Operations

0 commit comments

Comments
 (0)