FoalTS: Merge hooks/decorators in a reusable way

TLDR

error in FoalTS using mergeHooks to combine multiple hooks into one, resulted in error message

t.push is not iterable (cannot read property Symbol(Symbol.iterator))

underlying problem was that some of the decorators I was trying to combine with mergeHooks were actually not hooks (ValidatePathParam) but rather decorators (ApiOperationId). So instead of mergeHooks, the proper approach was a custom mergeDecorators method.

Longer version

We are using FoalTS as framework for our (nodejs/typescript-based) application server at apimock. One nice feature are decorators and hooks for annotation of controllers to describe stuff like validation rules for incoming requests.

A controller might look like this:

class ProductController {
    @Get('/:uuid')
    @ApiOperationId(`find$ProductByUuid`),
    @ApiOperationSummary(`Find a product by ID.`),
    @ApiResponse(404, { description: product not found.` }),
    @ApiResponse(200, { description: `Returns the product` }),
    @ValidatePathParam('uuid', { type: 'string', format: 'uuid' })
    public async getProduct(ctx: Context<User>) {
        const products = await Product.findOne({
            uuid: ctx.request.params.uuid
        });
        return new HttpResponseOK(products);
    }
}

As our appserver controllers for typical crud-operations (GET/POST/PUT/DELETE) follow a similar pattern, some sort code-reuse seemed prudent. The FoalTS docs describe merging multiple hooks as

function ValidateAll() {
  return MergeHooks(
    ValidateHeaders({...}),
    ValidateCookie({...})
  )
}

class ProductController {
  @Get('/:uuid')
  @ValidateAll()
  getProduct() {
    // ...
  }
}

so doing the above with following custom hook to combine all commonly used decorators for a getByUuid method

import { ApiOperationId, ApiOperationSummary, ApiResponse, ValidatePathParam, MergeHooks } from '@foal/core';
import { applyDecorators } from '../../../sharedlogic/helpers/typescript.helper';

export function ApidocItemGet(entityName: string) {
    return MergeHooks(
        ApiOperationId(`find${entityName}ByUuid`),
        ApiOperationSummary(`Find a ${entityName} by ID.`),
        ApiResponse(404, { description: `${entityName} not found.` }),
        ApiResponse(200, { description: `Returns the ${entityName}.` }),
        ValidatePathParam('uuid', { type: 'string', format: 'uuid' })
    );
}

resulted in error:

t.push is not iterable (cannot read property Symbol(Symbol.iterator))

Solution

The underlying problem was that some of the decorators I was trying to combine with mergeHooks (in particular the Api* decorators) were actually not hooks (ValidatePathParam) but rather decorators (ApiOperationId). So instead of mergeHooks, the proper approach was a custom mergeDecorators method:

mergeDecorators helper:

export function mergeDecorators(...decorators: (ClassDecorator | MethodDecorator | PropertyDecorator)[]) {
return <TFunction extends Function, Y>(target: TFunction | object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<Y>) => {
    for (const decorator of decorators) {
        if (target instanceof Function && !descriptor) {
            (decorator as ClassDecorator)(target);
            continue;
        }
        (decorator as MethodDecorator | PropertyDecorator)(target, propertyKey, descriptor);
    }
};
}

re-usable dynamic hook:

import { ApiOperationId, ApiOperationSummary, ApiResponse, ValidatePathParam } from '@foal/core';
import { mergeDecorators } from './mergeDecorators.helper';

export function ApidocItemGet(entityName: string) {
    return mergeDecorators(
        ApiOperationId(`find${entityName}ByUuid`),
        ApiOperationSummary(`Find a ${entityName} by ID.`),
        ApiResponse(404, { description: `${entityName} not found.` }),
        ApiResponse(200, { description: `Returns the ${entityName}.` }),
        ValidatePathParam('uuid', { type: 'string', format: 'uuid' })
    );
}

controller:

class ProductController {
    @Get('/:uuid')
    @ApidocItemGet('Product')
    public async getProduct(ctx: Context<User>) {
        const products = await Product.findOne({
            uuid: ctx.request.params.uuid
        });
        return new HttpResponseOK(products);
    }
}
Home Blog Imprint & Contact