@@ -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