From a20e7431d6207208ad777afb85242d7dc32cc0dd Mon Sep 17 00:00:00 2001 From: ppodds Date: Tue, 29 Apr 2025 07:58:00 +0000 Subject: [PATCH 1/5] feat(server): add ApiHandlerService to handle request with NestJS --- .../server/src/nestjs/api-handler.service.ts | 52 +++++++++++++++++++ packages/server/src/nestjs/index.ts | 2 + .../api-handler-options.interface.ts | 5 ++ .../server/src/nestjs/interfaces/index.ts | 2 + .../zenstack-module-options.interface.ts | 41 +++++++++++++++ .../server/src/nestjs/zenstack.constants.ts | 4 ++ packages/server/src/nestjs/zenstack.module.ts | 49 ++--------------- packages/server/tests/adapter/nestjs.test.ts | 3 +- packages/server/tsconfig.json | 3 +- 9 files changed, 112 insertions(+), 49 deletions(-) create mode 100644 packages/server/src/nestjs/api-handler.service.ts create mode 100644 packages/server/src/nestjs/interfaces/api-handler-options.interface.ts create mode 100644 packages/server/src/nestjs/interfaces/index.ts create mode 100644 packages/server/src/nestjs/interfaces/zenstack-module-options.interface.ts create mode 100644 packages/server/src/nestjs/zenstack.constants.ts diff --git a/packages/server/src/nestjs/api-handler.service.ts b/packages/server/src/nestjs/api-handler.service.ts new file mode 100644 index 000000000..beba4428b --- /dev/null +++ b/packages/server/src/nestjs/api-handler.service.ts @@ -0,0 +1,52 @@ +import { DbClientContract } from '@zenstackhq/runtime'; +import { HttpException, Inject, Injectable, Scope, } from "@nestjs/common"; +import { HttpAdapterHost, REQUEST, } from "@nestjs/core"; +import { loadAssets } from "../shared"; +import { RPCApiHandler } from '../api/rpc'; +import { ENHANCED_PRISMA } from "./zenstack.constants"; +import { ApiHandlerOptions } from './interfaces'; + +/** + * The ZenStack API handler service for NestJS. The service is used to handle API requests + * and forward them to the ZenStack API handler. It is platform agnostic and can be used + * with any HTTP adapter. + */ +@Injectable({scope: Scope.REQUEST}) +export class ApiHandlerService { + constructor(private readonly httpAdapterHost: HttpAdapterHost, @Inject(ENHANCED_PRISMA) private readonly prisma: DbClientContract, @Inject(REQUEST) private readonly request: unknown) {} + + async handleRequest(options?: ApiHandlerOptions): Promise { + const { modelMeta, zodSchemas } = loadAssets(options || {}); + const requestHandler = options?.handler || RPCApiHandler(); + const hostname = this.httpAdapterHost.httpAdapter.getRequestHostname(this.request); + const requestUrl = this.httpAdapterHost.httpAdapter.getRequestUrl(this.request); + // prefix with http:// to make a valid url accepted by URL constructor + const url = new URL(`http://${hostname}${requestUrl}`); + const method = this.httpAdapterHost.httpAdapter.getRequestMethod(this.request); + const path = options?.baseUrl && url.pathname.startsWith(options.baseUrl) ? url.pathname.slice(options.baseUrl.length) : url.pathname; + const searchParams = url.searchParams; + const query = Object.fromEntries(searchParams); + const requestBody = (this.request as {body: unknown}).body; + + const response = await requestHandler({ + method, + path, + query, + requestBody, + prisma: this.prisma, + modelMeta, + zodSchemas, + logger: options?.logger, + }); + + // handle handler error + // if reponse code >= 400 throw nestjs HttpException + // the error response will be generated by nestjs + // caller can use try/catch to deal with this manually also + if (response.status >= 400) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + throw new HttpException(response.body as Record, response.status) + } + return response.body + } +} diff --git a/packages/server/src/nestjs/index.ts b/packages/server/src/nestjs/index.ts index f94469901..f02976629 100644 --- a/packages/server/src/nestjs/index.ts +++ b/packages/server/src/nestjs/index.ts @@ -1 +1,3 @@ export * from './zenstack.module'; +export * from './api-handler.service'; +export * from './zenstack.constants'; diff --git a/packages/server/src/nestjs/interfaces/api-handler-options.interface.ts b/packages/server/src/nestjs/interfaces/api-handler-options.interface.ts new file mode 100644 index 000000000..5a0fe1df9 --- /dev/null +++ b/packages/server/src/nestjs/interfaces/api-handler-options.interface.ts @@ -0,0 +1,5 @@ +import { AdapterBaseOptions } from "../../types"; + +export interface ApiHandlerOptions extends AdapterBaseOptions { + baseUrl?: string; +} diff --git a/packages/server/src/nestjs/interfaces/index.ts b/packages/server/src/nestjs/interfaces/index.ts new file mode 100644 index 000000000..ea713be4e --- /dev/null +++ b/packages/server/src/nestjs/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './zenstack-module-options.interface' +export * from './api-handler-options.interface' diff --git a/packages/server/src/nestjs/interfaces/zenstack-module-options.interface.ts b/packages/server/src/nestjs/interfaces/zenstack-module-options.interface.ts new file mode 100644 index 000000000..fd3a86ede --- /dev/null +++ b/packages/server/src/nestjs/interfaces/zenstack-module-options.interface.ts @@ -0,0 +1,41 @@ +import { FactoryProvider, ModuleMetadata, Provider } from "@nestjs/common"; + +/** + * ZenStack module options. + */ +export interface ZenStackModuleOptions { + /** + * A callback for getting an enhanced `PrismaClient`. + */ + getEnhancedPrisma: (model?: string | symbol ) => unknown; +} + +/** + * ZenStack module async registration options. + */ +export interface ZenStackModuleAsyncOptions extends Pick { + /** + * Whether the module is global-scoped. + */ + global?: boolean; + + /** + * The token to export the enhanced Prisma service. Default is {@link ENHANCED_PRISMA}. + */ + exportToken?: string; + + /** + * The factory function to create the enhancement options. + */ + useFactory: (...args: unknown[]) => Promise | ZenStackModuleOptions; + + /** + * The dependencies to inject into the factory function. + */ + inject?: FactoryProvider['inject']; + + /** + * Extra providers to facilitate dependency injection. + */ + extraProviders?: Provider[]; +} diff --git a/packages/server/src/nestjs/zenstack.constants.ts b/packages/server/src/nestjs/zenstack.constants.ts new file mode 100644 index 000000000..bf082f025 --- /dev/null +++ b/packages/server/src/nestjs/zenstack.constants.ts @@ -0,0 +1,4 @@ +/** + * The default token used to export the enhanced Prisma service. + */ +export const ENHANCED_PRISMA = 'ENHANCED_PRISMA'; diff --git a/packages/server/src/nestjs/zenstack.module.ts b/packages/server/src/nestjs/zenstack.module.ts index a113fb84d..3fff29e8e 100644 --- a/packages/server/src/nestjs/zenstack.module.ts +++ b/packages/server/src/nestjs/zenstack.module.ts @@ -1,49 +1,6 @@ -import { Module, type DynamicModule, type FactoryProvider, type ModuleMetadata, type Provider } from '@nestjs/common'; - -/** - * The default token used to export the enhanced Prisma service. - */ -export const ENHANCED_PRISMA = 'ENHANCED_PRISMA'; - -/** - * ZenStack module options. - */ -export interface ZenStackModuleOptions { - /** - * A callback for getting an enhanced `PrismaClient`. - */ - getEnhancedPrisma: (model?: string | symbol ) => unknown; -} - -/** - * ZenStack module async registration options. - */ -export interface ZenStackModuleAsyncOptions extends Pick { - /** - * Whether the module is global-scoped. - */ - global?: boolean; - - /** - * The token to export the enhanced Prisma service. Default is {@link ENHANCED_PRISMA}. - */ - exportToken?: string; - - /** - * The factory function to create the enhancement options. - */ - useFactory: (...args: unknown[]) => Promise | ZenStackModuleOptions; - - /** - * The dependencies to inject into the factory function. - */ - inject?: FactoryProvider['inject']; - - /** - * Extra providers to facilitate dependency injection. - */ - extraProviders?: Provider[]; -} +import { Module, type DynamicModule } from '@nestjs/common'; +import { ENHANCED_PRISMA } from './zenstack.constants'; +import { ZenStackModuleAsyncOptions } from './interfaces'; /** * The ZenStack module for NestJS. The module exports an enhanced Prisma service, diff --git a/packages/server/tests/adapter/nestjs.test.ts b/packages/server/tests/adapter/nestjs.test.ts index d28a3ecc8..290db9211 100644 --- a/packages/server/tests/adapter/nestjs.test.ts +++ b/packages/server/tests/adapter/nestjs.test.ts @@ -1,7 +1,6 @@ import { Test } from '@nestjs/testing'; import { loadSchema } from '@zenstackhq/testtools'; -import { ZenStackModule } from '../../src/nestjs'; -import { ENHANCED_PRISMA } from '../../src/nestjs/zenstack.module'; +import { ZenStackModule, ENHANCED_PRISMA } from '../../src/nestjs'; describe('NestJS adapter tests', () => { const schema = ` diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 1239ae389..ec25e7fb4 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -4,7 +4,8 @@ "target": "ES2020", "lib": ["ESNext", "DOM"], "outDir": "dist", - "strictPropertyInitialization": false + "strictPropertyInitialization": false, + "emitDecoratorMetadata": true }, "include": ["src/**/*.ts"] } From e0a0e9954844f15d692f4c2d903d265f4f0d321c Mon Sep 17 00:00:00 2001 From: ppodds Date: Tue, 29 Apr 2025 08:23:23 +0000 Subject: [PATCH 2/5] test(server-nest): add ApiHandlerService test cases --- packages/server/tests/adapter/nestjs.test.ts | 220 ++++++++++++++++++- 1 file changed, 217 insertions(+), 3 deletions(-) diff --git a/packages/server/tests/adapter/nestjs.test.ts b/packages/server/tests/adapter/nestjs.test.ts index 290db9211..c38964403 100644 --- a/packages/server/tests/adapter/nestjs.test.ts +++ b/packages/server/tests/adapter/nestjs.test.ts @@ -1,9 +1,10 @@ import { Test } from '@nestjs/testing'; import { loadSchema } from '@zenstackhq/testtools'; -import { ZenStackModule, ENHANCED_PRISMA } from '../../src/nestjs'; +import { ZenStackModule, ENHANCED_PRISMA, ApiHandlerService } from '../../src/nestjs'; +import { HttpAdapterHost, REQUEST } from '@nestjs/core'; +import RESTApiHandler from '../../src/api/rest'; -describe('NestJS adapter tests', () => { - const schema = ` +const schema = ` model User { id Int @id @default(autoincrement()) posts Post[] @@ -21,6 +22,7 @@ describe('NestJS adapter tests', () => { } `; +describe('NestJS adapter tests', () => { it('anonymous', async () => { const { prisma, enhanceRaw } = await loadSchema(schema); @@ -209,3 +211,215 @@ describe('NestJS adapter tests', () => { await expect(postSvc.findAll()).resolves.toHaveLength(2); }); }); + +describe('ApiHandlerService tests', () => { + it('with default option', async () => { + const { prisma, enhanceRaw } = await loadSchema(schema); + + await prisma.user.create({ + data: { + posts: { + create: [ + { title: 'post1', published: true }, + { title: 'post2', published: false }, + ], + }, + }, + }); + + const moduleRef = await Test.createTestingModule({ + imports: [ + ZenStackModule.registerAsync({ + useFactory: (prismaService) => ({ getEnhancedPrisma: () => enhanceRaw(prismaService) }), + inject: ['PrismaService'], + extraProviders: [ + { + provide: 'PrismaService', + useValue: prisma, + }, + ], + }), + ], + providers: [ + { + provide: REQUEST, + useValue: {} + }, + { + provide: HttpAdapterHost, + useValue: { + httpAdapter: { + getRequestHostname: jest.fn().mockReturnValue('localhost'), + getRequestUrl: jest.fn().mockReturnValue('/post/findMany'), + getRequestMethod: jest.fn().mockReturnValue('GET'), + } + } + }, + ApiHandlerService, + ], + }).compile(); + + const service = await moduleRef.resolve(ApiHandlerService); + expect(await service.handleRequest()).toEqual({ + data: [{ + id: 1, + title: 'post1', + published: true, + authorId: 1, + }] + }) + }) + + it('with rest api handler', async () => { + const { prisma, enhanceRaw, modelMeta, zodSchemas } = await loadSchema(schema); + + await prisma.user.create({ + data: { + posts: { + create: [ + { title: 'post1', published: true }, + { title: 'post2', published: false }, + ], + }, + }, + }); + + const moduleRef = await Test.createTestingModule({ + imports: [ + ZenStackModule.registerAsync({ + useFactory: (prismaService) => ({ getEnhancedPrisma: () => enhanceRaw(prismaService) }), + inject: ['PrismaService'], + extraProviders: [ + { + provide: 'PrismaService', + useValue: prisma, + }, + ], + }), + ], + providers: [ + { + provide: REQUEST, + useValue: {} + }, + { + provide: HttpAdapterHost, + useValue: { + httpAdapter: { + getRequestHostname: jest.fn().mockReturnValue('localhost'), + getRequestUrl: jest.fn().mockReturnValue('/post'), + getRequestMethod: jest.fn().mockReturnValue('GET'), + } + } + }, + ApiHandlerService, + ], + }).compile(); + + const service = await moduleRef.resolve(ApiHandlerService); + expect(await service.handleRequest({ + handler: RESTApiHandler({ + endpoint: 'http://localhost', + }), + modelMeta, + zodSchemas, + })).toEqual({ + jsonapi: { + version: "1.1" + }, + data: [{ + type: 'post', + id: 1, + attributes: { + title: 'post1', + published: true, + authorId: 1, + }, + links: { + self: 'http://localhost/post/1', + }, + relationships: { + author: { + data: { + id: 1, + type: 'user', + }, + links: { + related: 'http://localhost/post/1/author', + self: 'http://localhost/post/1/relationships/author', + } + } + } + }], + links: { + first: "http://localhost/post?page%5Blimit%5D=100", + last: "http://localhost/post?page%5Boffset%5D=0", + next: null, + prev: null, + self: "http://localhost/post" + }, + meta: { + total: 1 + } + }) + }) + + it('option baseUrl', async () => { + const { prisma, enhanceRaw } = await loadSchema(schema); + + await prisma.user.create({ + data: { + posts: { + create: [ + { title: 'post1', published: true }, + { title: 'post2', published: false }, + ], + }, + }, + }); + + const moduleRef = await Test.createTestingModule({ + imports: [ + ZenStackModule.registerAsync({ + useFactory: (prismaService) => ({ getEnhancedPrisma: () => enhanceRaw(prismaService) }), + inject: ['PrismaService'], + extraProviders: [ + { + provide: 'PrismaService', + useValue: prisma, + }, + ], + }), + ], + providers: [ + { + provide: REQUEST, + useValue: {} + }, + { + provide: HttpAdapterHost, + useValue: { + httpAdapter: { + getRequestHostname: jest.fn().mockReturnValue('localhost'), + getRequestUrl: jest.fn().mockReturnValue('/api/rpc/post/findMany'), + getRequestMethod: jest.fn().mockReturnValue('GET'), + } + } + }, + ApiHandlerService, + ], + }).compile(); + + const service = await moduleRef.resolve(ApiHandlerService); + expect(await service.handleRequest({ + baseUrl: '/api/rpc' + })).toEqual({ + data: [{ + id: 1, + title: 'post1', + published: true, + authorId: 1, + }] + }) + }) +}) From b6095fdc3f81a694434f5a3c07b04359ff255582 Mon Sep 17 00:00:00 2001 From: ppodds Date: Tue, 29 Apr 2025 09:45:31 +0000 Subject: [PATCH 3/5] chore(server-nestjs): fix response typo --- packages/server/src/nestjs/api-handler.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/nestjs/api-handler.service.ts b/packages/server/src/nestjs/api-handler.service.ts index beba4428b..31e7dd46c 100644 --- a/packages/server/src/nestjs/api-handler.service.ts +++ b/packages/server/src/nestjs/api-handler.service.ts @@ -40,7 +40,7 @@ export class ApiHandlerService { }); // handle handler error - // if reponse code >= 400 throw nestjs HttpException + // if response code >= 400 throw nestjs HttpException // the error response will be generated by nestjs // caller can use try/catch to deal with this manually also if (response.status >= 400) { From b35a6a82c147e293c8d89d3f8923c9282962e1d3 Mon Sep 17 00:00:00 2001 From: ppodds Date: Tue, 29 Apr 2025 09:47:37 +0000 Subject: [PATCH 4/5] chore(server-nestjs): reformat files --- .../server/src/nestjs/api-handler.service.ts | 16 ++++++++++------ .../zenstack-module-options.interface.ts | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/server/src/nestjs/api-handler.service.ts b/packages/server/src/nestjs/api-handler.service.ts index 31e7dd46c..1b157e998 100644 --- a/packages/server/src/nestjs/api-handler.service.ts +++ b/packages/server/src/nestjs/api-handler.service.ts @@ -1,6 +1,6 @@ import { DbClientContract } from '@zenstackhq/runtime'; -import { HttpException, Inject, Injectable, Scope, } from "@nestjs/common"; -import { HttpAdapterHost, REQUEST, } from "@nestjs/core"; +import { HttpException, Inject, Injectable, Scope } from "@nestjs/common"; +import { HttpAdapterHost, REQUEST } from "@nestjs/core"; import { loadAssets } from "../shared"; import { RPCApiHandler } from '../api/rpc'; import { ENHANCED_PRISMA } from "./zenstack.constants"; @@ -11,9 +11,13 @@ import { ApiHandlerOptions } from './interfaces'; * and forward them to the ZenStack API handler. It is platform agnostic and can be used * with any HTTP adapter. */ -@Injectable({scope: Scope.REQUEST}) +@Injectable({ scope: Scope.REQUEST }) export class ApiHandlerService { - constructor(private readonly httpAdapterHost: HttpAdapterHost, @Inject(ENHANCED_PRISMA) private readonly prisma: DbClientContract, @Inject(REQUEST) private readonly request: unknown) {} + constructor( + private readonly httpAdapterHost: HttpAdapterHost, + @Inject(ENHANCED_PRISMA) private readonly prisma: DbClientContract, + @Inject(REQUEST) private readonly request: unknown + ) { } async handleRequest(options?: ApiHandlerOptions): Promise { const { modelMeta, zodSchemas } = loadAssets(options || {}); @@ -26,9 +30,9 @@ export class ApiHandlerService { const path = options?.baseUrl && url.pathname.startsWith(options.baseUrl) ? url.pathname.slice(options.baseUrl.length) : url.pathname; const searchParams = url.searchParams; const query = Object.fromEntries(searchParams); - const requestBody = (this.request as {body: unknown}).body; + const requestBody = (this.request as { body: unknown }).body; - const response = await requestHandler({ + const response = await requestHandler({ method, path, query, diff --git a/packages/server/src/nestjs/interfaces/zenstack-module-options.interface.ts b/packages/server/src/nestjs/interfaces/zenstack-module-options.interface.ts index fd3a86ede..e2b45d6ea 100644 --- a/packages/server/src/nestjs/interfaces/zenstack-module-options.interface.ts +++ b/packages/server/src/nestjs/interfaces/zenstack-module-options.interface.ts @@ -7,7 +7,7 @@ export interface ZenStackModuleOptions { /** * A callback for getting an enhanced `PrismaClient`. */ - getEnhancedPrisma: (model?: string | symbol ) => unknown; + getEnhancedPrisma: (model?: string | symbol) => unknown; } /** From 2dd1cca1986a64ec2a523bae8a3d0ddcc75c11a9 Mon Sep 17 00:00:00 2001 From: ppodds Date: Mon, 5 May 2025 14:08:22 +0000 Subject: [PATCH 5/5] docs(server-nestjs): add comment for baseUrl option --- .../interfaces/api-handler-options.interface.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/server/src/nestjs/interfaces/api-handler-options.interface.ts b/packages/server/src/nestjs/interfaces/api-handler-options.interface.ts index 5a0fe1df9..c9731d24d 100644 --- a/packages/server/src/nestjs/interfaces/api-handler-options.interface.ts +++ b/packages/server/src/nestjs/interfaces/api-handler-options.interface.ts @@ -1,5 +1,18 @@ import { AdapterBaseOptions } from "../../types"; export interface ApiHandlerOptions extends AdapterBaseOptions { + /** + * The base URL for the API handler. This is used to determine the base path for the API requests. + * If you are using the ApiHandlerService in a route with a prefix, you should set this to the prefix. + * + * e.g. + * without baseUrl(API handler default route): + * - RPC API handler: [model]/findMany + * - RESTful API handler: /:type + * + * with baseUrl(/api/crud): + * - RPC API handler: /api/crud/[model]/findMany + * - RESTful API handler: /api/crud/:type + */ baseUrl?: string; }