tsoa
tsoa copied to clipboard
Feature Request: Support fastify
How would I connect TSOA controllers to Fastify? Does anyone have an example?
You'd likely start off with a custom template and adapt that so can export a fastify plugin.
E: See https://github.com/lukeautry/tsoa/tree/master/packages/cli/src/routeGeneration/templates
This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days
For anyone interested we use the following template to use TSOA in a Fastify project (with middleware
option in tsoa.json
set to express
. I am not sure if it is good enough for a PR though.
/* eslint-disable jsdoc/require-jsdoc */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable prettier/prettier */
/* eslint-disable @typescript-eslint/no-floating-promises */
/* eslint-disable max-len */
import {
Controller,
FieldErrors,
HttpStatusCodeLiteral,
TsoaResponse,
TsoaRoute,
ValidateError,
ValidationService,
} from "@tsoa/runtime";
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
{{#each controllers}}
import { {{name}} } from "{{modulePath}}";
{{/each}}
{{#if authenticationModule}}
import { expressAuthentication } from "{{authenticationModule}}";
{{/if}}
{{#if iocModule}}
import { DependencyInjection } from "{{iocModule}}";
import { IocContainer } from "@tsoa/runtime";
{{/if}}
{{#if useFileUploads}}
const multer = require("multer");
const upload = multer({{{json multerOpts}}});
{{/if}}
const models: TsoaRoute.Models = {
{{#each models}}
"{{@key}}": {
{{#if enums}}
"dataType": "refEnum",
"enums": {{{json enums}}},
{{/if}}
{{#if properties}}
"dataType": "refObject",
"properties": {
{{#each properties}}
"{{@key}}": {{{json this}}},
{{/each}}
},
"additionalProperties": {{{json additionalProperties}}},
{{/if}}
{{#if type}}
"dataType": "refAlias",
"type": {{{json type}}},
{{/if}}
},
{{/each}}
};
const validationService = new ValidationService(models);
export function RegisterRoutes(app: FastifyInstance): void {
// ###################################################################################################################
// NOTE: If you do not see routes for all of your controllers in this file, then you might not have informed tsoa of
// where to look. Please look into the "controllerPathGlobs" config option described in the readme:
// https://github.com/lukeautry/tsoa
// ###################################################################################################################
{{#if useSecurity}}
app.decorateRequest("user", null);
{{/if}}
{{#each controllers}}
{{#each actions}}
app.{{method}}(
"{{fullPath}}",
{{#if uploadFile}}
upload.single("{{uploadFileName}}"),
{{/if}}
{{#if uploadFiles}}
upload.array("{{uploadFilesName}}"),
{{/if}}
{
schema: {
description: `{{description}}`,
summary: "{{summary}}",
{{#if tags}}
tags: {{json tags}},
{{/if}}
response: {
{{#each responses}}
{{name}}: {
{{#if schema.refName}}
$ref: "{{schema.refName}}#",
{{/if}}
{{#if schema.description}}
description: "{{schema.description}}",
{{/if}}
},
{{/each}}
{{#if security.length}}
401: {
type: "object",
properties: {
message: { type: "string" },
status: { type: "number" },
traceId: { type: "string" },
},
},
{{/if}}
},
},
{{#if security.length}}
onRequest: authenticateMiddleware({{json security}}),
{{/if}}
},
async (request: FastifyRequest, response: FastifyReply): Promise<any> => {
const args = {
{{#each parameters}}
{{@key}}: {{{json this}}},
{{/each}}
};
let validatedArgs: any[] = [];
validatedArgs = getValidatedArgs(args, request, response);
{{#if ../../iocModule}}
const container: IocContainer = DependencyInjection;
const controller: {{../name}} = container.get<{{../name}}>({{../name}});
if (isController(controller)) {
controller.setStatus(undefined as any);
}
{{else}}
const controller = new {{../name}}();
{{/if}}
// eslint-disable-next-line prefer-spread
const promise = controller.{{name}}.apply(controller, validatedArgs as any);
return await promiseHandler(controller, promise, response, {{successStatus}});
},
);
{{/each}}
{{/each}}
{{#if useSecurity}}
function authenticateMiddleware(security: TsoaRoute.Security[] = []) {
return function runAuthenticationMiddleware(request: FastifyRequest, _response: any, next: any) {
let responded = 0;
let success = false;
const succeed = function(user: any) {
if (!success) {
success = true;
responded++;
request.user = user;
next();
}
}
const fail = function(error: { status?: number }) {
responded++;
if (responded == security.length && !success) {
error.status = error.status || 401;
next(error)
}
}
for (const secMethod of security) {
if (Object.keys(secMethod).length > 1) {
const promises: Promise<any>[] = [];
for (const name in secMethod) {
promises.push(expressAuthentication(request as any, name, secMethod[name]));
}
Promise.all(promises)
.then((users) => { succeed(users[0]); })
.catch(fail);
} else {
for (const name in secMethod) {
expressAuthentication(request as any, name, secMethod[name])
.then(succeed)
.catch(fail);
}
}
}
}
}
{{/if}}
function isController(object: any): object is Controller {
return "getHeaders" in object && "getStatus" in object && "setStatus" in object;
}
async function promiseHandler<T>(
controllerObj: Controller,
promise: Promise<T>,
response: FastifyReply,
successStatus?: number,
): Promise<T> {
const data: T = await promise;
let statusCode = successStatus;
const headers = controllerObj.getHeaders();
statusCode = controllerObj.getStatus() || statusCode;
return returnHandler(response, statusCode, data, headers);
}
function returnHandler<T>(
response: FastifyReply,
statusCode?: number,
data?: any,
headers: Record<string, string | string[] | undefined> = {},
): T {
if (response.sent) {
return undefined as any;
}
Object.keys(headers).forEach((name: string) => {
response.header(name, headers[name]);
});
if (data && typeof data.pipe === "function" && data.readable && typeof data._read === "function") {
data.pipe(response);
} else if (data !== null && data !== undefined) {
response.status(statusCode || 200);
} else {
response.status(statusCode || 204);
}
return data;
}
function responder(response: FastifyReply): TsoaResponse<HttpStatusCodeLiteral, unknown> {
return function (status, data, headers) {
returnHandler(response, status, data, headers);
};
}
function getValidatedArgs(
args: any,
request: FastifyRequest,
response: FastifyReply,
): any[] {
const fieldErrors: FieldErrors = {};
const values = Object.keys(args).map((key) => {
const name = args[key].name;
switch (args[key].in) {
case "request":
return request;
case "query":
return validationService.ValidateParam(
args[key],
(request.query as Record<string, unknown>)[name],
name,
fieldErrors,
undefined,
{{{json minimalSwaggerConfig}}},
);
case "path":
return validationService.ValidateParam(
args[key],
(request.params as Record<string, unknown>)[name],
name,
fieldErrors,
undefined,
{{{json minimalSwaggerConfig}}},
);
case "header":
return validationService.ValidateParam(
args[key],
request.headers[name],
name,
fieldErrors,
undefined,
{{{json minimalSwaggerConfig}}},
);
case "body":
return validationService.ValidateParam(
args[key],
request.body,
name,
fieldErrors,
undefined,
{{{json minimalSwaggerConfig}}},
);
case "body-prop":
return validationService.ValidateParam(
args[key],
(request.body as Record<string, unknown>)[name],
name,
fieldErrors,
"body.",
{{{json minimalSwaggerConfig}}},
);
case "formData":
if (args[key].dataType === "file") {
return validationService.ValidateParam(
args[key],
(request as any).file,
name,
fieldErrors,
undefined,
{{{json minimalSwaggerConfig}}},
);
} else if (args[key].dataType === "array" && args[key].array?.dataType === "file") {
return validationService.ValidateParam(
args[key],
(request as any).files,
name,
fieldErrors,
undefined,
{{{json minimalSwaggerConfig}}},
);
} else {
return validationService.ValidateParam(
args[key],
(request.body as Record<string, unknown>)[name],
name,
fieldErrors,
undefined,
{{{json minimalSwaggerConfig}}},
);
}
case "res":
return responder(response);
}
});
if (Object.keys(fieldErrors).length > 0) {
throw new ValidateError(fieldErrors, "");
}
return values;
}
}
Hey @WoH, could the above template become native or, at least, documented?
If we can cover it reasonably well in terms of integration tests and get them all green, sure!
I should add that the template works but could be improved by adding some more details to the routeGenerator.buildContent
method. The return object of the method is the one that gets passed to the handlebars template but somehow some details from metadataGenerator are not passed along. I don't know the exact reasoning behind this (maybe they were just forgotten or left out intentionally) but they are located here.
return {
+ description: method.description,
fullPath: normalisedFullPath,
method: method.method.toLowerCase(),
name: method.name,
parameters: parameterObjs,
path: normalisedMethodPath,
uploadFile: !!uploadFileParameter,
uploadFileName: uploadFileParameter === null || uploadFileParameter === void 0 ? void 0 : uploadFileParameter.name,
uploadFiles: !!uploadFilesParameter,
uploadFilesName: uploadFilesParameter === null || uploadFilesParameter === void 0 ? void 0 : uploadFilesParameter.name,
security: method.security,
+ summary: method.name,
successStatus: method.successStatus ? method.successStatus : 'undefined',
+ responses: method.responses ?? [],
};
Also if you use multiline description in JSDoc you should replace the quotes around schema description with backticks.
schema: {
- description: "{{description}}"
+ description: `{{description}}`
summary: "{{summary}}",
and if you document your 401 responses via decorator omit the extra 401 in the template
- {{#if security.length}}
- 401: {
- type: "object",
- properties: {
- message: { type: "string" },
- status: { type: "number" },
- traceId: { type: "string" },
- },
- },
- {{/if}}
otherwise you'll get duplicate 401 responses which will likely fail your build.
@Kampfmoehre I'd be happy to add this as an "alpha" template so we can iterate without any stability presumptions. Would you be interested in sending a PR?
I could make a PR with the needed changes but I have no overview on which tests would be needed and where to put them, also I am not available the next two weeks so it could take some time.
I'll happily guide you with the integration tests. You can open a PR without tests and I'll review it and give pointers.
We probably only need fastify integration tests, but there's a bit of setup required which you shouldn't have to DIY ;)
@WoH I have added some initial things but there is still work to do. I am not sure if I can complete this before my holiday, so expect it to stay open some time.
~~Do we have any new information or updates regarding this particular feature?~~
Actually, I'll use the proposed template above and modify it accordingly, while this gets finished and merged "eventually". Thank you. 👍🏽
Before this can be finished there are some things to solve. The initial support is just the template that can be further modified. This works as a first "quick and easy" solution, but it does not adhere to Fastifys best practices. I tried to get this done in another project where we are using Fastify and TSOA but gave up after it seems to took too much time to solve fully.
Fastify uses JSON Schema for validation and to speed up (de-)serialization. While the validation part is already covered by TSOA it is generally advised to set up a schema for the route.
The template comes with a basic schema, but for the full route schema, we need to change the object TSOA is giving to the template in the first place. This object is build here.
In my modified version I have added summary
, responses
and tags
to this object. For Fastify responses
is the most interesting since it allows us to generate a response schema for Fastify.
Now I am not sure if TSOA maintainers are ok with adding the whole responses Array to the object, maybe @WoH could answer this.
The next problem is, there are a lot of cases how a response can look like, for example, arrays are looking different in Fastifys schema and for a full Fastify support we would need to make sure to cover almost all of these cases. Also it is not easy to add this kind of complex logic to the template, handlebars offers easy #if
that only check if a variable is set, you can't check for example if a variable has a certain value without adding custom helper functions. And again I am not sure if TSOA wants to have such helper functions added that only cover a small edge case of one of multiple supported templates.
Last but not least, Fastify needs to know of the schemas referenced in the generated routes, we sure don't want to add the full schema to each response which would blow up routes.ts
. Instead I have come up with this, which I add to the Fastify initialisation when fastify starts:
docsDir = resolve(process.cwd(), docsDir ?? "dist/docs");
const swaggerFile = resolve(docsDir, "swagger.json");
const jsonString = await readFile(swaggerFile, { encoding: "utf-8" });
const tsoaSwagger = JSON.parse(jsonString) as TsoaSwagger;
for (const key in tsoaSwagger.components.schemas) {
if (Object.prototype.hasOwnProperty.call(tsoaSwagger.components.schemas, key)) {
const schema = tsoaSwagger.components.schemas[key];
if (schema.properties) {
for (const prop in schema.properties as Record<string, unknown>) {
if (Object.prototype.hasOwnProperty.call(schema.properties, prop)) {
const element = (schema.properties as Record<string, unknown>)[prop] as {
$ref?: string;
items?: { $ref?: string };
};
if (element.$ref) {
element.$ref = element.$ref.replace("#/components/schemas/", "");
} else if (element.items?.$ref) {
element.items.$ref = element.items.$ref.replace("#/components/schemas/", "");
}
}
}
}
if (schema.anyOf && Array.isArray(schema.anyOf)) {
for (const item of schema.anyOf) {
if (item.$ref) {
item.$ref = item.$ref.replace("#/components/schemas/", "");
}
}
}
if (schema.$ref) {
schema.$ref = schema.$ref.replace("#/components/schemas/", "");
}
instance.addSchema({
$id: key,
...schema,
});
}
}
This way they can be referenced in the routes.ts
but again there sure are some edge cases that are not covered here. The backend I used this has a relatively easy API and we would have to handle more complex scenarios here.
I thought about adding a flag that enables response schema generation and to warn users about that, after all Fastify is working without that.
Just to give an example of the complexity, consider this easy case: Let's say we have an API endpoint with a simple DTO (DTO properties omitted for less complexity), could look like this
/**
* Returns the mydto found by the id.
* @summary Get mydto by id.
* /
@Get("something")
public async getSomething(someId: number): Promise<MyDto> {
const something = await repo.getSomethingById(someId);
return something;
}
No with the following template you would get a simple response schema for Fastify:
{
schema: {
description: "{{description}}",
summary: "{{summary}}",
response: {
{{#each responses}}
{{name}}: {
{{#if schema.refName}}
$ref: "{{schema.refName}}",
{{/if}}
},
{{/each}}
},
},
This will result in the following fastify schema:
app.get(
"/my/route/something",
{
schema: {
description: "Returns the mydto found by the id.",
summary: "Get mydto by id.",
response: {
200: {
$ref: "MyDTO",
},
},
},
);
Works great (as long as you have provided the schema refs from the generated Swagger JSON to Fastify, but what if you changed your route to this:
- public async getSomething(someId: number): Promise<MyDto> {
+ public async getSomething(someId: number): Promise<MyDto | undefined> {
Now the generated response looks like this:
schema: {
description: "Returns the mydto found by the id.",
summary: "Get mydto by id.",
response: {
200: {
},
},
},
This is bad, becuase Fastify does not serialize your response correctly anymore, no matter what the actual return value is, your response will be {}
which breaks your API.
If you are using things like |
TSOA generates the response as an union object with an array containing the DTO and undefined
which is hard to get from the handlebars template
{
"dataType": "union",
"types": [
{
"dataType": "refObject",
"description": "My Dto",
"properties": [
// ...
],
"refName": "MyDto"
},
{
"dataType": "undefined"
}
]
}
Now let's change the endpoint again
- public async getSomething(someId: number): Promise<MyDto> {
+ public async getSomethings(someId: number): Promise<MyDto[]> {
The template needs a lot more code now:
{
schema: {
description: "{{description}}",
summary: "{{summary}}",
response: {
{{#each responses}}
{{name}}: {
{{#if schema.refName}}
$ref: "{{schema.refName}}",
{{/if}}
{{#if schema.elementType}}
type: "array",
items: {
{{#if schema.elementType.refName}}
$ref: "{{schema.elementType.refName}}"
{{/if}}
}
},
{{/each}}
},
},
This would result in
200: {
type: "array",
items: {
$ref: "MyDto",
}
},
but that would not work for arrays with plan types like string[]
since schema.elementType.refName
is not set then
So there are a lot of cases to consider and I am not sure if TSOA maintainers are ok with adding complexity, more properties to the routeGenerator
object and custom handlers for handlebars.