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..1b157e998 --- /dev/null +++ b/packages/server/src/nestjs/api-handler.service.ts @@ -0,0 +1,56 @@ +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 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) { + // 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..c9731d24d --- /dev/null +++ b/packages/server/src/nestjs/interfaces/api-handler-options.interface.ts @@ -0,0 +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; +} 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..e2b45d6ea --- /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..c38964403 100644 --- a/packages/server/tests/adapter/nestjs.test.ts +++ b/packages/server/tests/adapter/nestjs.test.ts @@ -1,10 +1,10 @@ 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, 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[] @@ -22,6 +22,7 @@ describe('NestJS adapter tests', () => { } `; +describe('NestJS adapter tests', () => { it('anonymous', async () => { const { prisma, enhanceRaw } = await loadSchema(schema); @@ -210,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, + }] + }) + }) +}) 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"] }