UNPKG

@ts-rest/nest

Version:

Nest server integration for @ts-rest

522 lines (505 loc) 22.4 kB
import { ConfigurableModuleBuilder, Module, Injectable, Optional, Inject, SetMetadata, UseInterceptors, applyDecorators, Put, Patch, Post, Get, Delete, createParamDecorator, BadRequestException, InternalServerErrorException, HttpException } from '@nestjs/common'; import { catchError, of, throwError, map, mergeMap } from 'rxjs'; import { TsRestResponseError, isAppRouteResponse, validateResponse as validateResponse$1, isAppRouteOtherResponse, checkZodSchema, zodErrorResponse, parseJsonQueryObject, ZodErrorSchema, isAppRoute } from '@ts-rest/core'; import { Reflector } from '@nestjs/core'; import { z } from 'zod'; const TsRestAppRouteMetadataKey = Symbol('ts-rest-app-route'); const TsRestOptionsMetadataKey = Symbol('ts-rest-options'); /** * @deprecated Please use `TsRestHandler` instead - will be removed in v4 */ const initNestServer = (router) => { return { controllerShape: {}, routeShapes: {}, responseShapes: {}, route: router, }; }; /** * Returns the contract containing only non-nested routes required by a NestJS controller * * @deprecated Please use `TsRestHandler` instead - will be removed in v4 */ const nestControllerContract = (router) => { // it's not worth actually filtering the contract at runtime // the typing will already ensure that nested routes cannot be used at compile time return router; }; /****************************************************************************** Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ***************************************************************************** */ function __decorate(decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; } function __param(paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } } function __metadata(metadataKey, metadataValue) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(metadataKey, metadataValue); } typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }; const defaultOptions = { jsonQuery: false, validateResponses: false, validateRequestHeaders: true, validateRequestQuery: true, validateRequestBody: true, }; const evaluateTsRestOptions = (globalOptions, context) => { const handlerOptions = Reflect.getMetadata(TsRestOptionsMetadataKey, context.getHandler()); const classOptions = Reflect.getMetadata(TsRestOptionsMetadataKey, context.getClass()); return { ...defaultOptions, ...globalOptions, ...classOptions, ...handlerOptions, }; }; const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN: TS_REST_MODULE_OPTIONS_TOKEN, } = new ConfigurableModuleBuilder() .setExtras({ isGlobal: false, }, (definition, extras) => ({ ...definition, global: extras.isGlobal, })) .build(); let TsRestModule = class TsRestModule extends ConfigurableModuleClass { }; TsRestModule = __decorate([ Module({ exports: [TS_REST_MODULE_OPTIONS_TOKEN], }) ], TsRestModule); let TsRestInterceptor = class TsRestInterceptor { constructor(reflector, globalOptions) { this.reflector = reflector; this.globalOptions = globalOptions; } intercept(context, next) { const res = context.switchToHttp().getResponse(); const appRoute = this.reflector.get(TsRestAppRouteMetadataKey, context.getHandler()); if (!appRoute) { // this will respond with a 500 error without revealing this error message in the response body throw new Error('Make sure your route is decorated with @TsRest()'); } const options = evaluateTsRestOptions(this.globalOptions, context); return next.handle().pipe(catchError((err) => { if (err instanceof TsRestResponseError) { return of({ status: err.statusCode, body: err.body, }); } return throwError(() => err); }), map((value) => { if (isAppRouteResponse(value)) { const statusCode = value.status; const response = options.validateResponses ? validateResponse$1({ appRoute, response: value, }) : value; const responseType = appRoute.responses[statusCode]; if (isAppRouteOtherResponse(responseType)) { if ('setHeader' in res) { res.setHeader('content-type', responseType.contentType); } else { res.header('content-type', responseType.contentType); } } res.status(response.status); return response.body; } return value; })); } }; TsRestInterceptor = __decorate([ Injectable(), __param(1, Optional()), __param(1, Inject(TS_REST_MODULE_OPTIONS_TOKEN)), __metadata("design:paramtypes", [Reflector, Object]) ], TsRestInterceptor); /** * As a class decorator, you can configure ts-rest options. As a method decorator, you can assign the route and also configure options * @param appRouteOrOptions For a method decorator, this is the route. For a class decorator, this is the options * @param options For a method decorator, this is the options * * @deprecated Please use `TsRestHandler` instead - will be removed in v4 */ const TsRest = (appRouteOrOptions, options) => { const decorators = []; const isMethodDecorator = 'path' in appRouteOrOptions; const optionsToUse = isMethodDecorator ? options : appRouteOrOptions; if (isMethodDecorator) { decorators.push(...[ SetMetadata(TsRestAppRouteMetadataKey, appRouteOrOptions), getMethodDecorator(appRouteOrOptions), UseInterceptors(TsRestInterceptor), ]); } if (optionsToUse) { decorators.push(SetMetadata(TsRestOptionsMetadataKey, optionsToUse)); } return applyDecorators(...decorators); }; const getMethodDecorator = (appRoute) => { switch (appRoute.method) { case 'DELETE': return Delete(appRoute.path); case 'GET': return Get(appRoute.path); case 'POST': return Post(appRoute.path); case 'PATCH': return Patch(appRoute.path); case 'PUT': return Put(appRoute.path); } }; /** * @deprecated Please use `TsRestHandler` instead - will be removed in v4 */ const Api = (appRoute) => { return TsRest(appRoute); }; let TsRestValidatorPipe = class TsRestValidatorPipe { constructor(globalOptions) { this.globalOptions = globalOptions; } transform(ctx) { const appRoute = Reflect.getMetadata(TsRestAppRouteMetadataKey, ctx.getHandler()); if (!appRoute) { // this will respond with a 500 error without revealing this error message in the response body throw new Error('Make sure your route is decorated with @TsRest()'); } const req = ctx.switchToHttp().getRequest(); const options = evaluateTsRestOptions(this.globalOptions, ctx); const pathParamsResult = checkZodSchema(req.params, appRoute.pathParams, { passThroughExtraKeys: true, }); if (!pathParamsResult.success) { throw new BadRequestException(zodErrorResponse(pathParamsResult.error)); } const headersResult = checkZodSchema(req.headers, appRoute.headers, { passThroughExtraKeys: true, }); if (!headersResult.success && options.validateRequestHeaders) { throw new BadRequestException(zodErrorResponse(headersResult.error)); } const query = options.jsonQuery ? parseJsonQueryObject(req.query) : req.query; const queryResult = checkZodSchema(query, appRoute.query); if (!queryResult.success && options.validateRequestQuery) { throw new BadRequestException(zodErrorResponse(queryResult.error)); } const bodyResult = checkZodSchema(req.body, appRoute.method === 'GET' ? null : appRoute.body); if (!bodyResult.success && options.validateRequestBody) { throw new BadRequestException(zodErrorResponse(bodyResult.error)); } return { query: queryResult.success ? queryResult.data : req.query, params: pathParamsResult.data, body: bodyResult.success ? bodyResult.data : req.body, headers: headersResult.success ? headersResult.data : req.headers, }; } }; TsRestValidatorPipe = __decorate([ Injectable(), __param(0, Optional()), __param(0, Inject(TS_REST_MODULE_OPTIONS_TOKEN)), __metadata("design:paramtypes", [Object]) ], TsRestValidatorPipe); /** * Parameter decorator used to parse, validate and return the typed request object * * @deprecated Please use `TsRestHandler` instead - will be removed in v4 */ const TsRestRequest = () => createParamDecorator((_, ctx) => { return ctx; })(TsRestValidatorPipe); /** * @deprecated Use `TsRestRequest` instead */ const ApiDecorator = TsRestRequest; class RequestValidationError extends BadRequestException { constructor(pathParams, headers, query, body) { super({ paramsResult: pathParams, headersResult: headers, queryResult: query, bodyResult: body, }); this.pathParams = pathParams; this.headers = headers; this.query = query; this.body = body; } } const RequestValidationErrorSchema = z.object({ paramsResult: ZodErrorSchema.nullable(), headersResult: ZodErrorSchema.nullable(), queryResult: ZodErrorSchema.nullable(), bodyResult: ZodErrorSchema.nullable(), }); class ResponseValidationError extends InternalServerErrorException { constructor(appRoute, error) { super(`[ts-rest] Response validation failed for ${appRoute.method} ${appRoute.path}: ${error.message}`); this.appRoute = appRoute; this.error = error; } } const getHttpVerbDecorator = (route) => { switch (route.method) { case 'GET': return Get(route.path); case 'POST': return Post(route.path); case 'PUT': return Put(route.path); case 'PATCH': return Patch(route.path); case 'DELETE': return Delete(route.path); } }; const TsRestHandler = (appRouterOrRoute, options) => { return (target, propertyKey, descriptor) => { const isMultiHandler = !isAppRoute(appRouterOrRoute); /** * To make multi handler work we've got to do a trick with virtual methods in the class: * * Originally we used the @All decorator on the original method, but this has issues with different controllers conflicting * * Now, we make a new method for each route in the router, and apply the appropriate decorator to it. * * e.g. say there is a contract with two methods, `getPost` and `updatePost` * * we create two new methods in the class, `handler_getPost` and `handler_updatePost` * and decorate @Get on the first and @Put on the second * * Then, we call the original method from the new method */ if (isMultiHandler) { const originalMethod = descriptor.value; // Get parameter metadata using Nest's internal key const ROUTE_ARGS_METADATA = '__routeArguments__'; const originalParamMetadata = Reflect.getMetadata(ROUTE_ARGS_METADATA, target.constructor, propertyKey); Object.entries(appRouterOrRoute).forEach(([routeKey, route]) => { if (isAppRoute(route)) { const methodName = `${String(propertyKey)}_${routeKey}`; // Create new method that calls original target[methodName] = async function (...args) { return originalMethod.apply(this, args); }; if (originalParamMetadata) { Reflect.defineMetadata(ROUTE_ARGS_METADATA, originalParamMetadata, target.constructor, methodName); } const paramTypes = Reflect.getMetadata('design:paramtypes', target, propertyKey); if (paramTypes) { Reflect.defineMetadata('design:paramtypes', paramTypes, target, methodName); } const HttpVerbDecorator = getHttpVerbDecorator(route); HttpVerbDecorator(target, methodName, Object.getOwnPropertyDescriptor(target, methodName)); const reflector = new Reflector(); const metadataKeys = Reflect.getMetadataKeys(descriptor.value); metadataKeys.forEach((key) => { const metadata = reflector.get(key, descriptor.value); if (metadata) { SetMetadata(key, metadata)(target, methodName, Object.getOwnPropertyDescriptor(target, methodName)); } }); if (options) { SetMetadata(TsRestOptionsMetadataKey, options)(target, methodName, Object.getOwnPropertyDescriptor(target, methodName)); } SetMetadata(TsRestAppRouteMetadataKey, { appRoute: route, routeKey, })(target, methodName, Object.getOwnPropertyDescriptor(target, methodName)); UseInterceptors(TsRestHandlerInterceptor)(target, methodName, Object.getOwnPropertyDescriptor(target, methodName)); } }); return descriptor; } else { /** * On the single handler we can just apply the HttpVerb decorator to the original method */ if (!isAppRoute(appRouterOrRoute)) { throw new Error('Expected app route but received app router'); } const HttpVerbDecorator = getHttpVerbDecorator(appRouterOrRoute); HttpVerbDecorator(target, propertyKey, descriptor); if (options) { SetMetadata(TsRestOptionsMetadataKey, options)(target, propertyKey, descriptor); } SetMetadata(TsRestAppRouteMetadataKey, { appRoute: appRouterOrRoute, routeKey: null, })(target, propertyKey, descriptor); UseInterceptors(TsRestHandlerInterceptor)(target, propertyKey, descriptor); return descriptor; } }; }; /** * * @param contract - The contract or route to implement * @param implementation - Implementation of the route or entire contract as an object * @returns */ const tsRestHandler = (contract, implementation) => implementation; /** * Error you can throw to return a response from a handler */ class TsRestException extends HttpException { constructor(route, response, options) { super(response.body, response.status, options); } } let TsRestHandlerInterceptor = class TsRestHandlerInterceptor { constructor(reflector, globalOptions) { this.reflector = reflector; this.globalOptions = globalOptions; } /** * We use metadata to store the route, and the key of the route in a router on a given method */ getAppRouteFromContext(ctx) { const appRouteMetadata = this.reflector.get(TsRestAppRouteMetadataKey, ctx.getHandler()); if (!appRouteMetadata) { throw new Error('Could not find app router or app route, ensure you are using the @TsRestHandler decorator on your method'); } if (!isAppRoute(appRouteMetadata.appRoute)) { throw new Error('Expected app route but received app router'); } return appRouteMetadata; } intercept(ctx, next) { const res = ctx.switchToHttp().getResponse(); const req = ctx.switchToHttp().getRequest(); const { appRoute, routeKey } = this.getAppRouteFromContext(ctx); const options = evaluateTsRestOptions(this.globalOptions, ctx); const paramsResult = checkZodSchema(req.params, appRoute.pathParams, { passThroughExtraKeys: true, }); const headersResult = checkZodSchema(req.headers, appRoute.headers, { passThroughExtraKeys: true, }); const query = options.jsonQuery ? parseJsonQueryObject(req.query) : req.query; const queryResult = checkZodSchema(query, appRoute.query); const bodyResult = checkZodSchema(req.body, 'body' in appRoute ? appRoute.body : null); const isHeadersInvalid = !headersResult.success && options.validateRequestHeaders; const isQueryInvalid = !queryResult.success && options.validateRequestQuery; const isBodyInvalid = !bodyResult.success && options.validateRequestBody; if (!paramsResult.success || isHeadersInvalid || isQueryInvalid || isBodyInvalid) { throw new RequestValidationError(!paramsResult.success ? paramsResult.error : null, isHeadersInvalid ? headersResult.error : null, isQueryInvalid ? queryResult.error : null, isBodyInvalid ? bodyResult.error : null); } return next.handle().pipe(mergeMap(async (impl) => { let result = null; try { const res = { params: paramsResult.data, query: queryResult.success ? queryResult.data : req.query, body: bodyResult.success ? bodyResult.data : req.body, headers: headersResult.success ? headersResult.data : req.headers, }; /** * If we have a routeKey that means we're in a multi handler, and therefore we * need to call the appropriate method WITHIN the implementation object */ result = routeKey ? await impl[routeKey](res) : await impl(res); } catch (e) { if (e instanceof TsRestException) { result = { status: e.getStatus(), body: e.getResponse(), error: e, }; } else if (e instanceof TsRestResponseError) { result = { status: e.statusCode, body: e.body, }; } else { throw e; } } const responseAfterValidation = options.validateResponses ? validateResponse(appRoute, result) : result; const responseType = appRoute.responses[result.status]; if (result.error) { throw new HttpException(responseAfterValidation.body, responseAfterValidation.status, { cause: result.error, }); } if (isAppRouteOtherResponse(responseType)) { if ('setHeader' in res) { res.setHeader('content-type', responseType.contentType); } else { res.header('content-type', responseType.contentType); } } res.status(responseAfterValidation.status); return responseAfterValidation.body; })); } }; TsRestHandlerInterceptor = __decorate([ Injectable(), __param(1, Optional()), __param(1, Inject(TS_REST_MODULE_OPTIONS_TOKEN)), __metadata("design:paramtypes", [Reflector, Object]) ], TsRestHandlerInterceptor); const validateResponse = (appRoute, response) => { const { body } = response; const responseType = appRoute.responses[response.status]; const responseSchema = isAppRouteOtherResponse(responseType) ? responseType.body : responseType; if (!responseSchema) { throw new InternalServerErrorException(`[ts-rest] Couldn't find a response schema for ${response.status} on route ${appRoute.path}`); } const responseValidation = checkZodSchema(body, appRoute.responses[response.status]); if (!responseValidation.success) { const { error } = responseValidation; throw new ResponseValidationError(appRoute, error); } return { status: response.status, body: responseValidation.data, }; }; export { Api, ApiDecorator, RequestValidationError, RequestValidationErrorSchema, ResponseValidationError, TsRest, TsRestAppRouteMetadataKey, TsRestException, TsRestHandler, TsRestHandlerInterceptor, TsRestInterceptor, TsRestModule, TsRestOptionsMetadataKey, TsRestRequest, initNestServer, nestControllerContract, tsRestHandler };