From 49d9645936d7e1224d5bd6d6c2dc3da218745a07 Mon Sep 17 00:00:00 2001 From: ppodds Date: Tue, 6 May 2025 01:16:18 +0800 Subject: [PATCH 1/2] feat(server): add ApiHandlerService to handle request with NestJS (#2099) --- .../server/src/nestjs/api-handler.service.ts | 56 +++++ packages/server/src/nestjs/index.ts | 2 + .../api-handler-options.interface.ts | 18 ++ .../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 | 221 +++++++++++++++++- packages/server/tsconfig.json | 3 +- 9 files changed, 345 insertions(+), 51 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..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"] } From 922e3c4617f01dab1136c04cc21442dbeea4c4a5 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Wed, 7 May 2025 20:45:12 -0700 Subject: [PATCH 2/2] chore: bump version (#2112) --- package.json | 2 +- packages/ide/jetbrains/build.gradle.kts | 2 +- packages/ide/jetbrains/package.json | 2 +- packages/language/package.json | 2 +- packages/misc/redwood/package.json | 2 +- packages/plugins/openapi/package.json | 2 +- packages/plugins/swr/package.json | 2 +- packages/plugins/tanstack-query/package.json | 2 +- packages/plugins/trpc/package.json | 2 +- packages/runtime/package.json | 2 +- packages/schema/package.json | 2 +- packages/sdk/package.json | 2 +- packages/server/package.json | 2 +- packages/testtools/package.json | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index aceaa10af..ea79546d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "2.14.1", + "version": "2.14.2", "description": "", "scripts": { "build": "pnpm -r --filter=\"!./packages/ide/*\" build", diff --git a/packages/ide/jetbrains/build.gradle.kts b/packages/ide/jetbrains/build.gradle.kts index c9ab211a2..817d324e0 100644 --- a/packages/ide/jetbrains/build.gradle.kts +++ b/packages/ide/jetbrains/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "dev.zenstack" -version = "2.14.1" +version = "2.14.2" repositories { mavenCentral() diff --git a/packages/ide/jetbrains/package.json b/packages/ide/jetbrains/package.json index ae8f10b1e..33706b548 100644 --- a/packages/ide/jetbrains/package.json +++ b/packages/ide/jetbrains/package.json @@ -1,6 +1,6 @@ { "name": "jetbrains", - "version": "2.14.1", + "version": "2.14.2", "displayName": "ZenStack JetBrains IDE Plugin", "description": "ZenStack JetBrains IDE plugin", "homepage": "https://zenstack.dev", diff --git a/packages/language/package.json b/packages/language/package.json index 3dc626a89..222d81bfa 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "2.14.1", + "version": "2.14.2", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/misc/redwood/package.json b/packages/misc/redwood/package.json index d5456d3cd..6694ea62a 100644 --- a/packages/misc/redwood/package.json +++ b/packages/misc/redwood/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/redwood", "displayName": "ZenStack RedwoodJS Integration", - "version": "2.14.1", + "version": "2.14.2", "description": "CLI and runtime for integrating ZenStack with RedwoodJS projects.", "repository": { "type": "git", diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index 6ca4a9593..eeb9a4b4f 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/openapi", "displayName": "ZenStack Plugin and Runtime for OpenAPI", - "version": "2.14.1", + "version": "2.14.2", "description": "ZenStack plugin and runtime supporting OpenAPI", "main": "index.js", "repository": { diff --git a/packages/plugins/swr/package.json b/packages/plugins/swr/package.json index dcc7f89e7..b946993eb 100644 --- a/packages/plugins/swr/package.json +++ b/packages/plugins/swr/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/swr", "displayName": "ZenStack plugin for generating SWR hooks", - "version": "2.14.1", + "version": "2.14.2", "description": "ZenStack plugin for generating SWR hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/tanstack-query/package.json b/packages/plugins/tanstack-query/package.json index 6e167c2b9..bec80688d 100644 --- a/packages/plugins/tanstack-query/package.json +++ b/packages/plugins/tanstack-query/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack plugin for generating tanstack-query hooks", - "version": "2.14.1", + "version": "2.14.2", "description": "ZenStack plugin for generating tanstack-query hooks", "main": "index.js", "exports": { diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index 70a61fc1a..3afd0c572 100644 --- a/packages/plugins/trpc/package.json +++ b/packages/plugins/trpc/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/trpc", "displayName": "ZenStack plugin for tRPC", - "version": "2.14.1", + "version": "2.14.2", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 57c609b43..acb2e17e9 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "2.14.1", + "version": "2.14.2", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/schema/package.json b/packages/schema/package.json index c53768069..0be561296 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "FullStack enhancement for Prisma ORM: seamless integration from database to UI", - "version": "2.14.1", + "version": "2.14.2", "author": { "name": "ZenStack Team" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 7db4d8dc9..0bd0fccdc 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "2.14.1", + "version": "2.14.2", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index bb000ee7f..dde13ffb7 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "2.14.1", + "version": "2.14.2", "displayName": "ZenStack Server-side Adapters", "description": "ZenStack server-side adapters", "homepage": "https://zenstack.dev", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 21982864a..118b87019 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "2.14.1", + "version": "2.14.2", "description": "ZenStack Test Tools", "main": "index.js", "private": true,