├── .husky └── pre-commit ├── .tool-versions ├── packages ├── test │ ├── test-app-fastify-cjs │ │ ├── src │ │ │ └── .gitkeep │ │ ├── tsconfig.build.json │ │ ├── nest-cli.json │ │ ├── tsconfig.json │ │ ├── test │ │ │ └── create-app.ts │ │ ├── .gitignore │ │ ├── vitest.config.ts │ │ └── package.json │ ├── test-app-fastify-esm │ │ ├── src │ │ │ └── .gitkeep │ │ ├── tsconfig.build.json │ │ ├── nest-cli.json │ │ ├── tsconfig.json │ │ ├── test │ │ │ └── create-app.ts │ │ ├── .gitignore │ │ ├── vitest.config.ts │ │ └── package.json │ ├── test-react-query-client │ │ ├── vitest.setup.ts │ │ ├── vitest.config.ts │ │ ├── tsconfig.json │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── App.tsx │ │ │ ├── GreetPage.tsx │ │ │ └── UserPage.tsx │ │ └── test │ │ │ └── client.spec.tsx │ ├── test-app-express-cjs │ │ ├── src │ │ │ ├── endpoints │ │ │ │ ├── user │ │ │ │ │ ├── user.repository.token.ts │ │ │ │ │ ├── user.entity.ts │ │ │ │ │ ├── appointment │ │ │ │ │ │ ├── appointment.entity.ts │ │ │ │ │ │ ├── appointment-repository.interface.ts │ │ │ │ │ │ ├── _endpoints │ │ │ │ │ │ │ ├── count.endpoint.ts │ │ │ │ │ │ │ └── create │ │ │ │ │ │ │ │ └── endpoint.ts │ │ │ │ │ │ └── appointment.repository.ts │ │ │ │ │ ├── list │ │ │ │ │ │ ├── user-list-for-router-with-path.endpoint.ts │ │ │ │ │ │ ├── user-list-no-path.endpoint.ts │ │ │ │ │ │ ├── user-list-with-path.endpoint.ts │ │ │ │ │ │ ├── user-list-with-path-no-suffix.ts │ │ │ │ │ │ └── user-list.module.ts │ │ │ │ │ ├── purge.endpoint.ts │ │ │ │ │ ├── user.repository.ts │ │ │ │ │ ├── user.service.ts │ │ │ │ │ ├── find.endpoint.ts │ │ │ │ │ ├── get.endpoint.ts │ │ │ │ │ └── create.endpoint.ts │ │ │ │ ├── hello.service.ts │ │ │ │ ├── test │ │ │ │ │ ├── status.endpoint.ts │ │ │ │ │ └── error.endpoint.ts │ │ │ │ ├── complex-get.endpoint.ts │ │ │ │ └── greet.endpoint.ts │ │ │ ├── auth │ │ │ │ ├── auth.service.ts │ │ │ │ ├── _endpoints │ │ │ │ │ └── login.endpoint.ts │ │ │ │ └── auth.module.ts │ │ │ ├── decorators │ │ │ │ └── current-user.decorator.ts │ │ │ ├── vanilla.controller.ts │ │ │ ├── auth.guard.ts │ │ │ ├── codegen.ts │ │ │ ├── main.ts │ │ │ └── app.module.ts │ │ ├── nest-cli.json │ │ ├── tsconfig.build.json │ │ ├── test │ │ │ ├── create-app.ts │ │ │ ├── vanilla.e2e-spec.ts │ │ │ ├── raw-input.e2e-spec.ts │ │ │ ├── client.e2e-spec.ts │ │ │ ├── invoke.e2e-spec.ts │ │ │ ├── zod-openapi.e2e-spec.ts │ │ │ └── app.e2e-spec.ts │ │ ├── tsconfig.json │ │ ├── .gitignore │ │ ├── vitest.config.ts │ │ └── package.json │ └── test-app-express-esm │ │ ├── nest-cli.json │ │ ├── tsconfig.build.json │ │ ├── test │ │ └── create-app.ts │ │ ├── tsconfig.json │ │ ├── .gitignore │ │ ├── vitest.config.ts │ │ └── package.json └── nestjs-endpoints │ ├── .gitignore │ ├── src │ ├── codegen │ │ ├── index.ts │ │ ├── builder │ │ │ ├── axios.ts │ │ │ └── react-query.ts │ │ └── setup.ts │ ├── index.ts │ ├── consts.ts │ ├── exceptions.ts │ ├── zod-to-openapi.ts │ ├── setup-openapi.ts │ ├── endpoints-router.module.ts │ ├── helpers.ts │ ├── zod-to-openapi.spec.ts │ └── endpoint-fn.ts │ ├── tsconfig.cjs.json │ ├── tsconfig.build.json │ ├── tsconfig.esm.json │ ├── vitest.config.ts │ ├── tsconfig.json │ └── package.json ├── .npmrc ├── .prettierrc.yaml ├── .lintstagedrc.json ├── pnpm-workspace.yaml ├── .gitignore ├── tsconfig.json ├── .vscode └── settings.json ├── scripts ├── build.sh ├── change-log-entry.sh └── test.sh ├── package.json ├── CLAUDE.md ├── .github └── workflows │ ├── pr.yaml │ └── release.yaml ├── LICENSE ├── eslint.config.js ├── CHANGELOG.md └── README.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint-staged 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 23.9.0 2 | pnpm 9.4.0 3 | -------------------------------------------------------------------------------- /packages/test/test-app-fastify-cjs/src/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/test/test-app-fastify-esm/src/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=false # default is true 2 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | trailingComma: all 3 | printWidth: 75 4 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/src/codegen/index.ts: -------------------------------------------------------------------------------- 1 | export { setupCodegen } from './setup'; 2 | -------------------------------------------------------------------------------- /packages/test/test-react-query-client/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json" 3 | } 4 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,tsx}": "pnpm eslint --fix --max-warnings=0 --no-warn-ignored" 3 | } 4 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'packages/test/*' 4 | overrides: 5 | 'reflect-metadata': '0.1.14' 6 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/user.repository.token.ts: -------------------------------------------------------------------------------- 1 | export const UserRepositoryToken = Symbol('UserRepository'); 2 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/user.entity.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | id: number; 3 | name: string; 4 | email: string; 5 | }; 6 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/appointment/appointment.entity.ts: -------------------------------------------------------------------------------- 1 | export type Appointment = { 2 | id: number; 3 | date: Date; 4 | address: string; 5 | userId: number; 6 | }; 7 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AuthService { 5 | checkPermission() { 6 | return true; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/hello.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class HelloService { 5 | greet(name: string) { 6 | return `Hello, ${name}!`; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/test/test-app-fastify-cjs/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "test", 6 | "dist", 7 | "**/*spec.ts", 8 | "vitest.config.ts" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /packages/test/test-app-fastify-esm/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "test", 6 | "dist", 7 | "**/*spec.ts", 8 | "vitest.config.ts" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/test/test-app-express-esm/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/test/test-app-fastify-cjs/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/test/test-app-fastify-esm/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/test/status.endpoint.ts: -------------------------------------------------------------------------------- 1 | import { endpoint } from 'nestjs-endpoints'; 2 | 3 | export default endpoint({ 4 | handler: () => { 5 | return { 6 | health: 'ok', 7 | }; 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist/cjs" 6 | }, 7 | "exclude": ["node_modules", "dist", "**/*.spec.ts", "vitest.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "test", 6 | "dist", 7 | "generated", 8 | "**/*spec.ts", 9 | "vitest.config.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /packages/test/test-react-query-client/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'jsdom', 6 | globals: true, 7 | setupFiles: ['./vitest.setup.ts'], 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/decorators/current-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator } from '@nestjs/common'; 2 | 3 | export const CurrentUser = createParamDecorator(() => { 4 | return { 5 | name: 'John Smith', 6 | isSuperAdmin: true, 7 | }; 8 | }); 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | openapi.json 3 | *.e2e-spec.ts 4 | !packages/test/test-app-express-cjs/**/*.e2e-spec.ts 5 | generated/ 6 | !packages/test/test-app-express-cjs/generated 7 | packages/test/test-app-express-esm/test/create-app.ts 8 | packages/test/test-app-fastify-esm/test/create-app.ts 9 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/test/error.endpoint.ts: -------------------------------------------------------------------------------- 1 | import { endpoint } from 'nestjs-endpoints'; 2 | 3 | export default endpoint({ 4 | handler: async () => { 5 | throw new Error('test'); 6 | await new Promise((resolve) => setTimeout(resolve, 1000)); 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "moduleResolution": "Bundler", 6 | "outDir": "./dist/esm" 7 | }, 8 | "tsc-alias": { 9 | "resolveFullPaths": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | globals: true, 7 | root: path.join(__dirname, '../..'), 8 | include: [path.join(__dirname, '**/*.spec.ts')], 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/test/test-app-express-esm/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "test", 6 | "dist", 7 | "generated", 8 | "**/*spec.ts", 9 | "vitest.config.ts" 10 | ], 11 | "tsc-alias": { 12 | "resolveFullPaths": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/vanilla.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post } from '@nestjs/common'; 2 | 3 | @Controller('vanilla') 4 | export class VanillaController { 5 | @Get('null') 6 | getNull() { 7 | return null; 8 | } 9 | 10 | @Post('null') 11 | postNull() { 12 | return null; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/complex-get.endpoint.ts: -------------------------------------------------------------------------------- 1 | import { endpoint, z } from 'nestjs-endpoints'; 2 | 3 | export default endpoint({ 4 | input: z.object({ 5 | add: z.object({ 6 | a: z.coerce.number(), 7 | b: z.coerce.number(), 8 | }), 9 | }), 10 | output: z.number(), 11 | handler: ({ input }) => { 12 | return input.add.a + input.add.b; 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/list/user-list-for-router-with-path.endpoint.ts: -------------------------------------------------------------------------------- 1 | import { endpoint, z } from 'nestjs-endpoints'; 2 | 3 | export default endpoint({ 4 | path: '/user/list-for-router-with-path', 5 | output: z.array( 6 | z.object({ 7 | id: z.number(), 8 | name: z.string(), 9 | email: z.string(), 10 | }), 11 | ), 12 | handler: () => [], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/auth/_endpoints/login.endpoint.ts: -------------------------------------------------------------------------------- 1 | import { endpoint, z } from 'nestjs-endpoints'; 2 | 3 | export default endpoint({ 4 | method: 'post', 5 | input: z.object({ 6 | email: z.string().email(), 7 | password: z.string(), 8 | }), 9 | output: z.object({ 10 | token: z.string(), 11 | }), 12 | handler: () => { 13 | return { 14 | token: '123', 15 | }; 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/src/index.ts: -------------------------------------------------------------------------------- 1 | export { z } from 'zod'; 2 | export { 3 | endpoint, 4 | decorated, 5 | schema, 6 | EndpointResponse, 7 | } from './endpoint-fn'; 8 | export { 9 | ZodValidationException, 10 | ZodSerializationException, 11 | } from './exceptions'; 12 | export * from './helpers'; 13 | export * from './setup-openapi'; 14 | export * from './codegen'; 15 | export { EndpointsRouterModule } from './endpoints-router.module'; 16 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/greet.endpoint.ts: -------------------------------------------------------------------------------- 1 | import { endpoint } from 'nestjs-endpoints'; 2 | import { z } from 'zod'; 3 | import { HelloService } from './hello.service'; 4 | 5 | export default endpoint({ 6 | input: z.object({ 7 | name: z.string(), 8 | }), 9 | output: z.string(), 10 | inject: { 11 | helloService: HelloService, 12 | }, 13 | handler: ({ input, helloService }) => helloService.greet(input.name), 14 | }); 15 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/appointment/appointment-repository.interface.ts: -------------------------------------------------------------------------------- 1 | export type IAppointmentRepository = { 2 | create: ( 3 | userId: number, 4 | date: Date, 5 | address: string, 6 | ) => Promise<{ id: number; date: Date; address: string }>; 7 | hasConflict: (date: Date) => boolean; 8 | count: (userId: number) => number; 9 | }; 10 | 11 | export const AppointmentRepositoryToken = Symbol('AppointmentRepository'); 12 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { EndpointsRouterModule } from 'nestjs-endpoints'; 3 | import { AuthService } from './auth.service'; 4 | 5 | @Module({ 6 | providers: [AuthService], 7 | exports: [AuthService], 8 | imports: [ 9 | EndpointsRouterModule.register({ 10 | basePath: 'auth', 11 | providers: [AuthService], 12 | }), 13 | ], 14 | }) 15 | export class AuthModule {} 16 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/test/create-app.ts: -------------------------------------------------------------------------------- 1 | import type { NestExpressApplication } from '@nestjs/platform-express'; 2 | import type { TestingModule } from '@nestjs/testing'; 3 | 4 | export async function createApp(moduleFixture: TestingModule) { 5 | const app = 6 | moduleFixture.createNestApplication(); 7 | app.set('query parser', 'extended'); 8 | await app.init(); 9 | await app.listen(0); 10 | return { app, httpAdapter: 'express' }; 11 | } 12 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2021", 5 | "jsx": "react", 6 | "esModuleInterop": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "skipLibCheck": true, 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "types": ["vitest/globals"] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/test/test-app-express-esm/test/create-app.ts: -------------------------------------------------------------------------------- 1 | import type { NestExpressApplication } from '@nestjs/platform-express'; 2 | import type { TestingModule } from '@nestjs/testing'; 3 | 4 | export async function createApp(moduleFixture: TestingModule) { 5 | const app = 6 | moduleFixture.createNestApplication(); 7 | app.set('query parser', 'extended'); 8 | await app.init(); 9 | await app.listen(0); 10 | return { app, httpAdapter: 'express' }; 11 | } 12 | -------------------------------------------------------------------------------- /packages/test/test-app-fastify-cjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2021", 5 | "jsx": "react", 6 | "esModuleInterop": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "skipLibCheck": true, 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "types": ["vitest/globals"] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["esnext"], 5 | "target": "esnext", 6 | "module": "esnext", 7 | "moduleDetection": "force", 8 | "allowJs": true, 9 | 10 | // Bundler mode 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "noEmit": true, 15 | "esModuleInterop": true, 16 | "skipLibCheck": true 17 | }, 18 | "include": ["./eslint.config.js"] 19 | } 20 | -------------------------------------------------------------------------------- /packages/test/test-app-express-esm/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "esnext", 5 | "moduleResolution": "Bundler", 6 | "jsx": "react", 7 | "esModuleInterop": true, 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "skipLibCheck": true, 14 | "strict": true, 15 | "noImplicitAny": true, 16 | "types": ["vitest/globals"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/test/test-app-fastify-esm/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "esnext", 5 | "moduleResolution": "Bundler", 6 | "jsx": "react", 7 | "esModuleInterop": true, 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "skipLibCheck": true, 14 | "strict": true, 15 | "noImplicitAny": true, 16 | "types": ["vitest/globals"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/purge.endpoint.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@nestjs/common'; 2 | import { decorated, endpoint } from 'nestjs-endpoints'; 3 | import { UserRepository } from './user.repository'; 4 | import { UserRepositoryToken } from './user.repository.token'; 5 | 6 | export default endpoint({ 7 | method: 'post', 8 | inject: { 9 | userRepository: decorated(Inject(UserRepositoryToken)), 10 | }, 11 | handler: ({ userRepository }) => { 12 | userRepository.purge(); 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": true, 3 | "eslint.format.enable": true, 4 | "eslint.useFlatConfig": true, 5 | "editor.formatOnType": false, // required 6 | "editor.formatOnPaste": true, 7 | "editor.formatOnSave": true, 8 | "editor.formatOnSaveMode": "file", // required to format on save 9 | "eslint.rules.customizations": [ 10 | { "rule": "prettier/*", "severity": "off" } 11 | ], 12 | "[typescript]": { 13 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 14 | }, 15 | "typescript.preferences.importModuleSpecifier": "relative" 16 | } 17 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/src/consts.ts: -------------------------------------------------------------------------------- 1 | export const endpointFileRegex = /\bendpoint\.(js|ts|mjs|cjs|mts)$/; 2 | 3 | export const settings: { 4 | endpoints: { 5 | file: string; 6 | setupFn: (settings: { 7 | rootDirectory: string; 8 | basePath: string; 9 | }) => void; 10 | }[]; 11 | openapi: { 12 | components: { 13 | schemas: Record; 14 | }; 15 | }; 16 | } = { 17 | endpoints: [], 18 | openapi: { 19 | components: { 20 | schemas: {}, 21 | }, 22 | }, 23 | }; 24 | 25 | export const openApiVersion = '3.1.1'; 26 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "lib": ["esnext"], 5 | "target": "esnext", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "sourceMap": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "strict": true, 12 | "skipLibCheck": true, 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true, 15 | "types": ["vitest/globals"] 16 | }, 17 | "include": ["src", "vitest.config.ts"], 18 | "exclude": ["node_modules", "dist"] 19 | } 20 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | generated 38 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | CanActivate, 4 | ExecutionContext, 5 | UnauthorizedException, 6 | } from '@nestjs/common'; 7 | import { Observable } from 'rxjs'; 8 | 9 | @Injectable() 10 | export class AuthGuard implements CanActivate { 11 | canActivate( 12 | context: ExecutionContext, 13 | ): boolean | Promise | Observable { 14 | const request = context.switchToHttp().getRequest(); 15 | 16 | if (!request.headers.authorization) { 17 | throw new UnauthorizedException(); 18 | } 19 | return true; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/test/test-react-query-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "esnext", 5 | "lib": ["ESNext", "DOM"], 6 | "moduleResolution": "Bundler", 7 | "jsx": "react", 8 | "esModuleInterop": true, 9 | "types": ["vite/client", "vitest/globals"], 10 | "strict": true, 11 | "strictNullChecks": true, 12 | "skipLibCheck": true 13 | }, 14 | "include": [ 15 | "src/**/*", 16 | "test/**/*", 17 | "../test-app-express-cjs/generated/react-query-client.tsx", 18 | "vitest.config.ts", 19 | "vitest.setup.ts" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /packages/test/test-app-fastify-cjs/test/create-app.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FastifyAdapter, 3 | NestFastifyApplication, 4 | } from '@nestjs/platform-fastify'; 5 | import type { TestingModule } from '@nestjs/testing'; 6 | import qs from 'qs'; 7 | 8 | export async function createApp(moduleFixture: TestingModule) { 9 | const app = moduleFixture.createNestApplication( 10 | new FastifyAdapter({ 11 | querystringParser: (str) => qs.parse(str), 12 | }), 13 | ); 14 | await app.init(); 15 | await app.listen(0); 16 | await app.getHttpAdapter().getInstance().ready(); 17 | return { app, httpAdapter: 'fastify' }; 18 | } 19 | -------------------------------------------------------------------------------- /packages/test/test-app-fastify-esm/test/create-app.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FastifyAdapter, 3 | NestFastifyApplication, 4 | } from '@nestjs/platform-fastify'; 5 | import type { TestingModule } from '@nestjs/testing'; 6 | import qs from 'qs'; 7 | 8 | export async function createApp(moduleFixture: TestingModule) { 9 | const app = moduleFixture.createNestApplication( 10 | new FastifyAdapter({ 11 | querystringParser: (str) => qs.parse(str), 12 | }), 13 | ); 14 | await app.init(); 15 | await app.listen(0); 16 | await app.getHttpAdapter().getInstance().ready(); 17 | return { app, httpAdapter: 'fastify' }; 18 | } 19 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/codegen.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { setupCodegen } from 'nestjs-endpoints'; 3 | import { AppModule } from './app.module'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | await setupCodegen(app, { 8 | clients: [ 9 | { 10 | type: 'axios', 11 | outputFile: process.cwd() + '/generated/axios-client.ts', 12 | }, 13 | { 14 | type: 'react-query', 15 | outputFile: process.cwd() + '/generated/react-query-client.tsx', 16 | }, 17 | ], 18 | forceGenerate: true, 19 | }); 20 | } 21 | void bootstrap(); 22 | -------------------------------------------------------------------------------- /packages/test/test-app-express-esm/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | test/jest-e2e.json 38 | test/app.e2e-spec.ts 39 | 40 | src/* 41 | !src/.gitkeep 42 | -------------------------------------------------------------------------------- /packages/test/test-app-fastify-cjs/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | test/jest-e2e.json 38 | test/app.e2e-spec.ts 39 | 40 | src/* 41 | !src/.gitkeep 42 | -------------------------------------------------------------------------------- /packages/test/test-app-fastify-esm/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | test/jest-e2e.json 38 | test/app.e2e-spec.ts 39 | 40 | src/* 41 | !src/.gitkeep 42 | -------------------------------------------------------------------------------- /packages/test/test-react-query-client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | React Query Client Test 10 | 11 | 12 |
13 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/list/user-list-no-path.endpoint.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@nestjs/common'; 2 | import { decorated, endpoint, z } from 'nestjs-endpoints'; 3 | import { UserRepository } from '../user.repository'; 4 | import { UserRepositoryToken } from '../user.repository.token'; 5 | 6 | export const userListNoPath = endpoint({ 7 | output: z.array( 8 | z.object({ 9 | id: z.number(), 10 | name: z.string(), 11 | email: z.string(), 12 | }), 13 | ), 14 | inject: { 15 | userRepository: decorated(Inject(UserRepositoryToken)), 16 | }, 17 | handler: ({ userRepository }) => userRepository.findAll(), 18 | }); 19 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/appointment/_endpoints/count.endpoint.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@nestjs/common'; 2 | import { decorated, endpoint, z } from 'nestjs-endpoints'; 3 | import { 4 | AppointmentRepositoryToken, 5 | IAppointmentRepository, 6 | } from '../appointment-repository.interface'; 7 | 8 | export default endpoint({ 9 | input: z.object({ 10 | userId: z.coerce.number(), 11 | }), 12 | output: z.number(), 13 | inject: { 14 | appointmentsRepository: decorated( 15 | Inject(AppointmentRepositoryToken), 16 | ), 17 | }, 18 | handler: ({ input, appointmentsRepository }) => 19 | appointmentsRepository.count(input.userId), 20 | }); 21 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import swc from 'unplugin-swc'; 3 | import tsconfigPaths from 'vite-tsconfig-paths'; 4 | import { defineConfig } from 'vitest/config'; 5 | 6 | export default defineConfig({ 7 | test: { 8 | globals: true, 9 | root: path.resolve(__dirname, '../../../'), 10 | include: [`${path.resolve(__dirname, 'test')}/**/*.e2e-spec.ts`], 11 | }, 12 | plugins: [ 13 | // This is required to build the test files with SWC 14 | swc.vite({ 15 | // Explicitly set the module type to avoid inheriting this value from a `.swcrc` config file 16 | module: { type: 'es6' }, 17 | }) as any, 18 | tsconfigPaths(), 19 | ], 20 | }); 21 | -------------------------------------------------------------------------------- /packages/test/test-app-express-esm/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import swc from 'unplugin-swc'; 3 | import tsconfigPaths from 'vite-tsconfig-paths'; 4 | import { defineConfig } from 'vitest/config'; 5 | 6 | export default defineConfig({ 7 | test: { 8 | globals: true, 9 | root: path.resolve(__dirname, '../../../'), 10 | include: [`${path.resolve(__dirname, 'test')}/**/*.e2e-spec.ts`], 11 | }, 12 | plugins: [ 13 | // This is required to build the test files with SWC 14 | swc.vite({ 15 | // Explicitly set the module type to avoid inheriting this value from a `.swcrc` config file 16 | module: { type: 'es6' }, 17 | }) as any, 18 | tsconfigPaths(), 19 | ], 20 | }); 21 | -------------------------------------------------------------------------------- /packages/test/test-app-fastify-cjs/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import swc from 'unplugin-swc'; 3 | import tsconfigPaths from 'vite-tsconfig-paths'; 4 | import { defineConfig } from 'vitest/config'; 5 | 6 | export default defineConfig({ 7 | test: { 8 | globals: true, 9 | root: path.resolve(__dirname, '../../../'), 10 | include: [`${path.resolve(__dirname, 'test')}/**/*.e2e-spec.ts`], 11 | }, 12 | plugins: [ 13 | // This is required to build the test files with SWC 14 | swc.vite({ 15 | // Explicitly set the module type to avoid inheriting this value from a `.swcrc` config file 16 | module: { type: 'es6' }, 17 | }) as any, 18 | tsconfigPaths(), 19 | ], 20 | }); 21 | -------------------------------------------------------------------------------- /packages/test/test-app-fastify-esm/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import swc from 'unplugin-swc'; 3 | import tsconfigPaths from 'vite-tsconfig-paths'; 4 | import { defineConfig } from 'vitest/config'; 5 | 6 | export default defineConfig({ 7 | test: { 8 | globals: true, 9 | root: path.resolve(__dirname, '../../../'), 10 | include: [`${path.resolve(__dirname, 'test')}/**/*.e2e-spec.ts`], 11 | }, 12 | plugins: [ 13 | // This is required to build the test files with SWC 14 | swc.vite({ 15 | // Explicitly set the module type to avoid inheriting this value from a `.swcrc` config file 16 | module: { type: 'es6' }, 17 | }) as any, 18 | tsconfigPaths(), 19 | ], 20 | }); 21 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/list/user-list-with-path.endpoint.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@nestjs/common'; 2 | import { decorated, endpoint, z } from 'nestjs-endpoints'; 3 | import { UserRepository } from '../user.repository'; 4 | import { UserRepositoryToken } from '../user.repository.token'; 5 | 6 | export const userListWithPath = endpoint({ 7 | path: '/user/list-with-path', 8 | output: z.array( 9 | z.object({ 10 | id: z.number(), 11 | name: z.string(), 12 | email: z.string(), 13 | }), 14 | ), 15 | inject: { 16 | userRepository: decorated(Inject(UserRepositoryToken)), 17 | }, 18 | handler: ({ userRepository }) => userRepository.findAll(), 19 | }); 20 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import type { User } from './user.entity'; 3 | 4 | @Injectable() 5 | export class UserRepository { 6 | private users: User[] = []; 7 | 8 | create(data: { name: string; email: string }) { 9 | const user = { 10 | id: this.users.length + 1, 11 | name: data.name, 12 | email: data.email, 13 | }; 14 | this.users.push(user); 15 | return user; 16 | } 17 | 18 | find(id: number) { 19 | return this.users.find((user) => user.id === id) ?? null; 20 | } 21 | 22 | findAll() { 23 | return this.users; 24 | } 25 | 26 | purge() { 27 | this.users = []; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/list/user-list-with-path-no-suffix.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@nestjs/common'; 2 | import { decorated, endpoint, z } from 'nestjs-endpoints'; 3 | import { UserRepository } from '../user.repository'; 4 | import { UserRepositoryToken } from '../user.repository.token'; 5 | 6 | export const userListWithPathNoSuffix = endpoint({ 7 | path: '/user/list-with-path-no-suffix', 8 | output: z.array( 9 | z.object({ 10 | id: z.number(), 11 | name: z.string(), 12 | email: z.string(), 13 | }), 14 | ), 15 | inject: { 16 | userRepository: decorated(Inject(UserRepositoryToken)), 17 | }, 18 | handler: ({ userRepository }) => userRepository.findAll(), 19 | }); 20 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/list/user-list.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserRepository } from '../user.repository'; 3 | import { UserRepositoryToken } from '../user.repository.token'; 4 | import { userListNoPath } from './user-list-no-path.endpoint'; 5 | import { userListWithPathNoSuffix } from './user-list-with-path-no-suffix'; 6 | import { userListWithPath } from './user-list-with-path.endpoint'; 7 | 8 | @Module({ 9 | controllers: [ 10 | userListNoPath, 11 | userListWithPath, 12 | userListWithPathNoSuffix, 13 | ], 14 | providers: [ 15 | { 16 | provide: UserRepositoryToken, 17 | useClass: UserRepository, 18 | }, 19 | ], 20 | }) 21 | export class UserListModule {} 22 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { User } from './user.entity'; 3 | import { UserRepositoryToken } from './user.repository.token'; 4 | 5 | export type IUserRepository = { 6 | create: (data: { name: string; email: string }) => { id: number }; 7 | find: (id: number) => User | null; 8 | }; 9 | 10 | @Injectable() 11 | export class UserService { 12 | constructor( 13 | @Inject(UserRepositoryToken) 14 | private readonly userRepository: IUserRepository, 15 | ) {} 16 | 17 | create(data: { name: string; email: string }) { 18 | return this.userRepository.create(data); 19 | } 20 | 21 | find(id: number) { 22 | return this.userRepository.find(id); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/src/exceptions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | HttpStatus, 4 | InternalServerErrorException, 5 | } from '@nestjs/common'; 6 | import { ZodError } from 'zod'; 7 | 8 | export class ZodValidationException extends BadRequestException { 9 | constructor(private error: ZodError) { 10 | super({ 11 | statusCode: HttpStatus.BAD_REQUEST, 12 | message: 'Validation failed', 13 | errors: error.issues, 14 | }); 15 | } 16 | 17 | public getZodError() { 18 | return this.error; 19 | } 20 | } 21 | 22 | export class ZodSerializationException extends InternalServerErrorException { 23 | constructor(private error: ZodError) { 24 | super(); 25 | } 26 | 27 | public getZodError() { 28 | return this.error; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/find.endpoint.ts: -------------------------------------------------------------------------------- 1 | import { endpoint, z } from 'nestjs-endpoints'; 2 | import { UserService } from './user.service'; 3 | 4 | export default endpoint({ 5 | input: z.object({ 6 | // GET endpoints use query params for input, 7 | // so we need to coerce the string to a number 8 | id: z.coerce.number(), 9 | }), 10 | // @ts-expect-error: Type 'ZodNullable>' is not assignable to type 'undefin 11 | output: z 12 | .object({ 13 | id: z.number(), 14 | name: z.string(), 15 | email: z.string(), 16 | }) 17 | .nullable(), 18 | inject: { 19 | userService: UserService, 20 | }, 21 | handler: ({ input, userService }) => userService.find(input.id), 22 | }); 23 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/get.endpoint.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | import { endpoint, z } from 'nestjs-endpoints'; 3 | import { UserService } from './user.service'; 4 | 5 | export default endpoint({ 6 | input: z.object({ 7 | // GET endpoints use query params for input, 8 | // so we need to coerce the string to a number 9 | id: z.coerce.number(), 10 | }), 11 | output: z.object({ 12 | id: z.number(), 13 | name: z.string(), 14 | email: z.string(), 15 | }), 16 | inject: { 17 | userService: UserService, 18 | }, 19 | handler: ({ input, userService }) => { 20 | const user = userService.find(input.id); 21 | if (!user) { 22 | throw new NotFoundException('User not found'); 23 | } 24 | return user; 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | __dirname=$(realpath "$(dirname "$0")") 4 | 5 | cd "$__dirname/../packages/nestjs-endpoints" 6 | rm -rf dist 7 | pnpm tsc --project tsconfig.cjs.json 8 | echo '{"type": "commonjs"}' > dist/cjs/package.json 9 | pnpm tsc --project tsconfig.esm.json && pnpm tsc-alias --project tsconfig.esm.json 10 | echo '{"type": "module"}' > dist/esm/package.json 11 | # Modify the ESM router-module.js to use dynamic import instead of require 12 | sed -i.bak 's/require(f).default/await import(f).then((m) => m.default)/g' dist/esm/endpoints-router.module.js 13 | rm -f dist/esm/endpoints-router.module.js.bak 14 | # Modify the ESM codegen/setup.js to use import.meta.dirname instead of __dirname 15 | sed -i.bak 's/__dirname/import.meta.dirname/g' dist/esm/codegen/setup.js 16 | rm -f dist/esm/codegen/setup.js.bak 17 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/appointment/appointment.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import type { Appointment } from './appointment.entity'; 3 | 4 | @Injectable() 5 | export class AppointmentRepository { 6 | private appointments: Appointment[] = []; 7 | 8 | create(userId: number, date: Date, address: string) { 9 | const appointment = { 10 | id: this.appointments.length + 1, 11 | userId, 12 | date, 13 | address, 14 | }; 15 | this.appointments.push(appointment); 16 | return Promise.resolve(appointment); 17 | } 18 | 19 | hasConflict(date: Date) { 20 | return this.appointments.some( 21 | (appointment) => appointment.date.getTime() === date.getTime(), 22 | ); 23 | } 24 | 25 | count(userId: number) { 26 | return this.appointments.filter( 27 | (appointment) => appointment.userId === userId, 28 | ).length; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { setupCodegen } from 'nestjs-endpoints'; 3 | import { AppModule } from './app.module'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | app.enableCors({ 8 | origin: '*', 9 | }); 10 | await setupCodegen(app, { 11 | openapi: { 12 | outputFile: process.cwd() + '/openapi.json', 13 | }, 14 | clients: [ 15 | { 16 | type: 'axios', 17 | outputFile: process.cwd() + '/generated/axios-client.ts', 18 | }, 19 | { 20 | type: 'react-query', 21 | outputFile: process.cwd() + '/generated/react-query-client.tsx', 22 | }, 23 | ], 24 | forceGenerate: true, 25 | }); 26 | const port = process.env.PORT || 3000; 27 | await app.listen(port, () => { 28 | console.log(`Server is running on port ${port}`); 29 | }); 30 | } 31 | void bootstrap(); 32 | -------------------------------------------------------------------------------- /packages/test/test-react-query-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-react-query-client", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "VITE_WEB_API_PORT=3000 vite", 8 | "test": "VITE_WEB_API_PORT=3000 vitest", 9 | "tscheck": "tsc --noEmit" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@tanstack/react-query": "^5.71.10", 16 | "@testing-library/react": "^16.3.0", 17 | "@testing-library/user-event": "^14.6.1", 18 | "@types/react-dom": "^19.1.1", 19 | "axios": "^1.7.9", 20 | "jsdom": "^26.0.0", 21 | "react": "^19.1.0", 22 | "typescript": "^5.8.3", 23 | "vitest": "^3.2.4" 24 | }, 25 | "devDependencies": { 26 | "@testing-library/dom": "^10.4.0", 27 | "@testing-library/jest-dom": "^6.6.3", 28 | "@types/react": "^19.1.0", 29 | "react-dom": "^19.1.0", 30 | "vite": "^6.2.5" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/test/test-react-query-client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 2 | import React, { useMemo } from 'react'; 3 | import { createApiClient } from '../../test-app-express-cjs/generated/react-query-client'; 4 | import { ApiClientProvider } from '../../test-app-express-cjs/generated/react-query-client'; 5 | import { UserPage } from './UserPage'; 6 | 7 | interface AppProps { 8 | children?: React.ReactNode; 9 | } 10 | 11 | export function App({ children }: AppProps = {}) { 12 | const queryClient = useMemo(() => new QueryClient({}), []); 13 | const apiClient = useMemo( 14 | () => 15 | createApiClient({ 16 | baseURL: `http://localhost:${import.meta.env.VITE_WEB_API_PORT}`, 17 | }), 18 | [], 19 | ); 20 | 21 | return ( 22 | 23 | 24 | {children ?? } 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-endpoints-monorepo", 3 | "author": "Carlos González ", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "test": "./scripts/build.sh && ./scripts/test.sh", 8 | "dev": "pnpm --filter nestjs-endpoints --filter test-app-express-cjs run --parallel dev", 9 | "prepare": "husky" 10 | }, 11 | "devDependencies": { 12 | "@eslint/js": "^9.29.0", 13 | "@nestjs/common": "^11.0.13", 14 | "@nestjs/core": "^11.0.13", 15 | "@nestjs/swagger": "^11.1.1", 16 | "@types/node": "^22.13.1", 17 | "@typescript-eslint/parser": "^8.23.0", 18 | "eslint": "^9.29.0", 19 | "eslint-config-prettier": "^10.1.5", 20 | "eslint-plugin-import": "^2.31.0", 21 | "eslint-plugin-prettier": "^5.4.1", 22 | "eslint-plugin-unused-imports": "^4.1.4", 23 | "husky": "^9.1.7", 24 | "lint-staged": "^15.4.3", 25 | "prettier": "^3.0.0", 26 | "reflect-metadata": "^0.1.13", 27 | "typescript-eslint": "^8.24.0", 28 | "zod": "^4.1.5" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | - Please read the README.md file to understand what this project is about. 2 | - The only deployable lives at packages/nestjs-endpoints 3 | - There are five test projects at packages/test: 4 | - All test-app-\* projects are meant to test backend calls both regular http calls and using the generated axios client 5 | - I only write tests for those in packages/test/test-app-express-cjs, since scripts/test.sh copies the app and test source files to the other test-app-\* projects. 6 | - Main test file for http calls using supertest are in packages/test/test-app-express-cjs/test/app.e2e-spec.ts 7 | - Test file for calls using the axios client are in packages/test/test-app-express-cjs/test/client.e2e-spec.ts 8 | - Other files have specific purposes 9 | - packages/test/test-react-query-client is meant to test the generated react-query client. 10 | - You will need to first add code in packages/test/test-react-query-client/src that imports the client queries or mutations, and then add tests in packages/test/test-react-query-client/test 11 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: CI tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | concurrency: 9 | group: ${{ github.ref_name }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | checks: 14 | name: Checks 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: asdf-vm/actions/install@v3 19 | with: 20 | asdf_branch: v0.15.0 21 | - run: pnpm install --frozen-lockfile 22 | - name: ESLint 23 | run: | 24 | git fetch origin ${{ github.base_ref }} 25 | changed=$(git diff --diff-filter=d --name-only origin/${{ github.base_ref }} HEAD) 26 | echo "Changed files between ${{ github.base_ref }} and PR:" 27 | echo "$changed" 28 | export NODE_OPTIONS="--max_old_space_size=4096" 29 | echo "$changed" | xargs pnpm eslint --max-warnings=0 --no-warn-ignored 30 | - name: Build 31 | run: ./scripts/build.sh 32 | - name: E2E tests 33 | run: ./scripts/test.sh 34 | env: 35 | FORCE_COLOR: true 36 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/src/zod-to-openapi.ts: -------------------------------------------------------------------------------- 1 | import type { ZodType } from 'zod'; 2 | import { createSchema } from 'zod-openapi'; 3 | import { openApiVersion, settings } from './consts'; 4 | 5 | export function zodToOpenApi(params: { 6 | schema: ZodType; 7 | schemaType: 'input' | 'output'; 8 | ref?: string; 9 | }): any { 10 | const s = params.ref 11 | ? params.schema.meta({ id: params.ref }) 12 | : params.schema; 13 | const result = createSchema(s, { 14 | io: params.schemaType, 15 | openapiVersion: openApiVersion, 16 | opts: { 17 | override: ({ jsonSchema }) => { 18 | if (jsonSchema.anyOf && !jsonSchema.oneOf) { 19 | jsonSchema.oneOf = jsonSchema.anyOf; 20 | delete jsonSchema.anyOf; 21 | } 22 | }, 23 | }, 24 | }); 25 | const { schema: openApiSchema, components: schemaComponents } = result; 26 | if (params.ref) { 27 | settings.openapi.components.schemas = { 28 | ...settings.openapi.components.schemas, 29 | ...schemaComponents, 30 | }; 31 | } 32 | return { openApiSchema, schemaComponents }; 33 | } 34 | -------------------------------------------------------------------------------- /packages/test/test-react-query-client/src/GreetPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useGreet } from '../../test-app-express-cjs/generated/react-query-client'; 3 | 4 | export function GreetPage() { 5 | const [name, setName] = useState(''); 6 | const { data, error, status, refetch } = useGreet( 7 | { name }, 8 | { 9 | query: { 10 | enabled: !!name, 11 | retry: false, 12 | }, 13 | }, 14 | ); 15 | 16 | return ( 17 |
18 |
19 | setName(e.target.value)} 23 | placeholder="Enter your name" 24 | /> 25 | 26 |
27 | 28 | {status === 'pending' && name &&
Loading...
} 29 | 30 | {error && ( 31 |
32 | Error: {(error.response?.data as { message: string }).message} 33 |
34 | )} 35 | 36 | {data &&
{data}
} 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/create.endpoint.ts: -------------------------------------------------------------------------------- 1 | import { endpoint, z } from 'nestjs-endpoints'; 2 | import { AuthService } from '../../auth/auth.service'; 3 | import { UserService } from './user.service'; 4 | 5 | export default endpoint({ 6 | method: 'post', 7 | input: z.object({ 8 | name: z.string(), 9 | email: z.string().email(), 10 | }), 11 | output: z.object({ 12 | id: z.number(), 13 | }), 14 | inject: { 15 | userService: UserService, 16 | authService: AuthService, 17 | }, 18 | handler: ({ input, userService, authService, schemas }) => { 19 | authService.checkPermission(); 20 | schemas.input 21 | .superRefine((_, ctx) => { 22 | if (input.name === 'error') { 23 | ctx.addIssue({ 24 | code: 'custom', 25 | message: 'The name triggered me', 26 | path: ['name'], 27 | }); 28 | } 29 | }) 30 | .parse(input); 31 | const user = userService.create(input); 32 | return { 33 | id: user.id, 34 | extra: 'This will be stripped', 35 | }; 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/test/vanilla.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import request from 'supertest'; 3 | import { AppModule } from '../src/app.module'; 4 | 5 | describe('vanilla controller', { concurrent: true }, () => { 6 | async function setup() { 7 | const moduleFixture: TestingModule = await Test.createTestingModule({ 8 | imports: [AppModule], 9 | }).compile(); 10 | const app = moduleFixture.createNestApplication(); 11 | await app.init(); 12 | await app.listen(0); 13 | return { app }; 14 | } 15 | test('get null', async () => { 16 | const { app } = await setup(); 17 | const req = request(app.getHttpServer()); 18 | try { 19 | await req.get('/vanilla/null').expect(200, {}); 20 | } finally { 21 | await app.close(); 22 | } 23 | }); 24 | test('post null', async () => { 25 | const { app } = await setup(); 26 | const req = request(app.getHttpServer()); 27 | try { 28 | await req.post('/vanilla/null').expect(201, {}); 29 | } finally { 30 | await app.close(); 31 | } 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Carlos González 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /scripts/change-log-entry.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | __dirname=$(realpath "$(dirname "$0")") 4 | 5 | # Function to extract changelog entry for a specific version 6 | extract_changelog_entry() { 7 | local version=$1 8 | local changelog_file="$__dirname/../CHANGELOG.md" 9 | 10 | # Check if changelog file exists 11 | if [ ! -f "$changelog_file" ]; then 12 | echo "Error: Changelog file not found at $changelog_file" >&2 13 | exit 1 14 | fi 15 | 16 | # Extract the entry for the specified version 17 | local entry=$(awk -v version="## $version" ' 18 | BEGIN { found=0; printing=0; } 19 | $0 ~ version { found=1; printing=0; next; } 20 | found && !printing && /^###/ { printing=1; } 21 | printing && /^## [0-9]+\.[0-9]+\.[0-9]+/ { printing=0; exit; } 22 | printing { print } 23 | END { if (!found) exit 1 } 24 | ' "$changelog_file") 25 | 26 | if [ $? -ne 0 ]; then 27 | echo "Error: Version $version not found in changelog" >&2 28 | exit 1 29 | fi 30 | 31 | # Output the entry to stdout 32 | echo "$entry" 33 | } 34 | 35 | # Check if version argument is provided 36 | if [ $# -ne 1 ]; then 37 | echo "Usage: $0 " >&2 38 | echo "Example: $0 1.1.0" >&2 39 | exit 1 40 | fi 41 | 42 | # Extract and print the changelog entry 43 | extract_changelog_entry "$1" 44 | -------------------------------------------------------------------------------- /packages/test/test-app-fastify-esm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-app-fastify-esm", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "type": "module", 9 | "scripts": { 10 | "test:e2e": "vitest --config ./vitest.config.ts", 11 | "codegen": "tsx ./src/codegen.ts" 12 | }, 13 | "dependencies": { 14 | "@nestjs/common": "^11.0.13", 15 | "@nestjs/core": "^11.0.13", 16 | "@nestjs/platform-fastify": "^11.0.13", 17 | "@tanstack/react-query": "^5.71.10", 18 | "@types/react": "^19.1.0", 19 | "nestjs-endpoints": "workspace:*", 20 | "qs": "^6.14.0", 21 | "react": "^19.1.0", 22 | "rxjs": "^7.8.1", 23 | "tsx": "^4.19.3", 24 | "vitest": "^3.2.4" 25 | }, 26 | "devDependencies": { 27 | "@nestjs/cli": "^11.0.6", 28 | "@nestjs/schematics": "^11.0.3", 29 | "@nestjs/testing": "^11.0.13", 30 | "@types/node": "^20.3.1", 31 | "@types/qs": "^6.14.0", 32 | "@types/supertest": "^2.0.12", 33 | "axios": "^1.7.9", 34 | "orval": "~7.8.0", 35 | "prettier": "^3.0.0", 36 | "source-map-support": "^0.5.21", 37 | "supertest": "^7.0.0", 38 | "ts-loader": "^9.4.3", 39 | "ts-node": "^10.9.1", 40 | "tsconfig-paths": "^4.2.0", 41 | "typescript": "^5.1.3", 42 | "unplugin-swc": "^1.5.4", 43 | "vite-tsconfig-paths": "^5.1.4" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/src/codegen/builder/axios.ts: -------------------------------------------------------------------------------- 1 | import type { OutputClientFunc } from 'orval'; 2 | 3 | export const axios = (): OutputClientFunc => { 4 | return (clients) => { 5 | return { 6 | ...clients.axios, 7 | dependencies: () => { 8 | // https://github.com/orval-labs/orval/blob/a154264719ccc49b3ab95dadbb3d62513110d8c3/packages/axios/src/index.ts#L22 9 | return [ 10 | { 11 | exports: [ 12 | { 13 | name: 'Axios', 14 | default: true, 15 | values: true, 16 | syntheticDefaultImport: true, 17 | }, 18 | { name: 'AxiosRequestConfig' }, 19 | { name: 'AxiosResponse' }, 20 | { name: 'CreateAxiosDefaults' }, 21 | { name: 'AxiosInstance' }, 22 | ], 23 | dependency: 'axios', 24 | }, 25 | ]; 26 | }, 27 | header: () => { 28 | return ` 29 | export const createApiClient = (config?: CreateAxiosDefaults | AxiosInstance) => { 30 | const axios = 31 | config && 32 | 'defaults' in config && 33 | 'interceptors' in config && 34 | typeof config.request === 'function' 35 | ? config 36 | : Axios.create(config as CreateAxiosDefaults); 37 | 38 | `; 39 | }, 40 | footer: (params) => { 41 | const result = clients.axios.footer!(params); 42 | return result.replace( 43 | /return {(.+?)}/, 44 | (_, captured) => `return {${captured}, axios}`, 45 | ); 46 | }, 47 | }; 48 | }; 49 | }; 50 | -------------------------------------------------------------------------------- /packages/test/test-app-fastify-cjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-app-fastify-cjs", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "test:e2e": "vitest --config ./vitest.config.ts", 16 | "codegen": "tsx ./src/codegen.ts" 17 | }, 18 | "dependencies": { 19 | "@nestjs/common": "^11.0.13", 20 | "@nestjs/core": "^11.0.13", 21 | "@nestjs/platform-fastify": "^11.0.13", 22 | "@tanstack/react-query": "^5.71.10", 23 | "@types/react": "^19.1.0", 24 | "nestjs-endpoints": "workspace:*", 25 | "qs": "^6.14.0", 26 | "react": "^19.1.0", 27 | "rxjs": "^7.8.1", 28 | "tsx": "^4.19.3", 29 | "vitest": "^3.2.4" 30 | }, 31 | "devDependencies": { 32 | "@nestjs/cli": "^11.0.6", 33 | "@nestjs/schematics": "^11.0.3", 34 | "@nestjs/testing": "^11.0.13", 35 | "@types/node": "^20.3.1", 36 | "@types/qs": "^6.14.0", 37 | "@types/supertest": "^2.0.12", 38 | "axios": "^1.7.9", 39 | "orval": "~7.8.0", 40 | "prettier": "^3.0.0", 41 | "source-map-support": "^0.5.21", 42 | "supertest": "^7.0.0", 43 | "ts-loader": "^9.4.3", 44 | "ts-node": "^10.9.1", 45 | "tsconfig-paths": "^4.2.0", 46 | "typescript": "^5.1.3", 47 | "unplugin-swc": "^1.5.4", 48 | "vite-tsconfig-paths": "^5.1.4" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/test/raw-input.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { endpoint, z } from 'nestjs-endpoints'; 4 | import request from 'supertest'; 5 | 6 | describe('raw input', () => { 7 | test('raw input is available in the handler', async () => { 8 | const testEndpoint = endpoint({ 9 | method: 'post', 10 | path: '/test', 11 | input: z.object({ 12 | name: z.string(), 13 | }), 14 | handler: (params) => { 15 | return { 16 | input: params.input, 17 | rawInput: params.rawInput, 18 | }; 19 | }, 20 | }); 21 | @Module({ 22 | controllers: [testEndpoint], 23 | }) 24 | class TestModule {} 25 | 26 | const moduleFixture: TestingModule = await Test.createTestingModule({ 27 | imports: [TestModule], 28 | }).compile(); 29 | 30 | const app = moduleFixture.createNestApplication(); 31 | try { 32 | await app.init(); 33 | await app.listen(0); 34 | const req = request(app.getHttpServer()); 35 | 36 | await req 37 | .post('/test') 38 | .send({ 39 | name: 'John', 40 | age: 30, 41 | }) 42 | .expect(200) 43 | .then((resp) => { 44 | expect(resp.body).toEqual({ 45 | input: { 46 | name: 'John', 47 | }, 48 | rawInput: { 49 | name: 'John', 50 | age: 30, 51 | }, 52 | }); 53 | }); 54 | } finally { 55 | await app.close(); 56 | } 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /packages/test/test-app-express-esm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-app-express-esm", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "type": "module", 9 | "scripts": { 10 | "build": "rm -rf ./dist && pnpm tsc --project tsconfig.build.json && pnpm tsc-alias --project tsconfig.build.json", 11 | "start": "pnpm build && pnpm start:prod", 12 | "start:prod": "node dist/main", 13 | "dev": "tsx watch --inspect=0 --clear-screen=false ./src/main.ts", 14 | "test:e2e": "vitest --config ./vitest.config.ts", 15 | "codegen": "tsx ./src/codegen.ts" 16 | }, 17 | "dependencies": { 18 | "@nestjs/common": "^11.0.13", 19 | "@nestjs/core": "^11.0.13", 20 | "@nestjs/platform-express": "^11.0.13", 21 | "@tanstack/react-query": "^5.71.10", 22 | "@types/react": "^19.1.0", 23 | "nestjs-endpoints": "workspace:*", 24 | "react": "^19.1.0", 25 | "rxjs": "^7.8.1", 26 | "vitest": "^3.2.4" 27 | }, 28 | "devDependencies": { 29 | "@nestjs/cli": "^11.0.6", 30 | "@nestjs/schematics": "^11.0.3", 31 | "@nestjs/testing": "^11.0.13", 32 | "@types/express": "^5.0.0", 33 | "@types/node": "^20.3.1", 34 | "@types/supertest": "^2.0.12", 35 | "axios": "^1.7.9", 36 | "orval": "~7.8.0", 37 | "prettier": "^3.0.0", 38 | "source-map-support": "^0.5.21", 39 | "supertest": "^7.0.0", 40 | "ts-loader": "^9.4.3", 41 | "ts-node": "^10.9.1", 42 | "tsc-alias": "^1.8.10", 43 | "tsconfig-paths": "^4.2.0", 44 | "tsx": "^4.19.3", 45 | "typescript": "^5.1.3", 46 | "unplugin-swc": "^1.5.4", 47 | "vite-tsconfig-paths": "^5.1.4" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-app-express-cjs", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "rm -rf ./dist && pnpm tsc --project tsconfig.build.json", 10 | "start": "pnpm build && pnpm start:prod", 11 | "start:dev": "nest start --watch", 12 | "start:prod": "node dist/main", 13 | "dev": "tsx watch --inspect=0 --clear-screen=false ./src/main.ts", 14 | "test:e2e": "vitest --config ./vitest.config.ts", 15 | "codegen": "tsx ./src/codegen.ts", 16 | "tscheck": "tsc --noEmit" 17 | }, 18 | "dependencies": { 19 | "@nestjs/common": "^11.0.13", 20 | "@nestjs/core": "^11.0.13", 21 | "@nestjs/platform-express": "^11.0.13", 22 | "@tanstack/react-query": "^5.71.10", 23 | "@types/react": "^19.1.0", 24 | "nestjs-endpoints": "workspace:*", 25 | "react": "^19.1.0", 26 | "rxjs": "^7.8.1", 27 | "vitest": "^3.2.4" 28 | }, 29 | "devDependencies": { 30 | "@nestjs/cli": "^11.0.6", 31 | "@nestjs/schematics": "^11.0.3", 32 | "@nestjs/testing": "^11.0.13", 33 | "@swc/core": "^1.12.1", 34 | "@types/express": "^5.0.0", 35 | "@types/node": "^20.3.1", 36 | "@types/supertest": "^2.0.12", 37 | "axios": "^1.7.9", 38 | "orval": "~7.8.0", 39 | "prettier": "^3.0.0", 40 | "source-map-support": "^0.5.21", 41 | "supertest": "^7.0.0", 42 | "ts-loader": "^9.4.3", 43 | "ts-node": "^10.9.1", 44 | "tsconfig-paths": "^4.2.0", 45 | "tsx": "^4.19.3", 46 | "typescript": "^5.1.3", 47 | "unplugin-swc": "^1.5.4", 48 | "vite-tsconfig-paths": "^5.1.4" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/test/test-react-query-client/src/UserPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { 3 | useApiClient, 4 | useUserCreate, 5 | useUserGet, 6 | } from '../../test-app-express-cjs/generated/react-query-client'; 7 | 8 | export function UserPage() { 9 | const { data, error, status, refetch } = useUserGet( 10 | { 11 | id: 1, 12 | }, 13 | { 14 | query: { 15 | retry: false, 16 | }, 17 | }, 18 | ); 19 | const { mutateAsync: createUser } = useUserCreate(); 20 | 21 | const apiClient = useApiClient(); 22 | const [purged, setPurged] = useState(false); 23 | useEffect(() => { 24 | void (async () => { 25 | await apiClient.userPurge(); 26 | setPurged(true); 27 | })(); 28 | }, []); 29 | 30 | return ( 31 |
32 | {!purged || status === 'pending' ? ( 33 |
Loading...
34 | ) : ( 35 | <> 36 | {error && ( 37 |
38 | Error:{' '} 39 | {(error.response?.data as { message: string }).message} 40 |
41 | )} 42 | {data && ( 43 |
44 |
Name: {data.name}
45 |
Email: {data.email}
46 |
47 | )} 48 | 49 | )} 50 | 51 |
52 | 65 |
66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-endpoints", 3 | "version": "0.0.0", 4 | "description": "A lightweight tool for writing clean and succinct HTTP APIs with NestJS that embraces the REPR design pattern, code colocation, and the Single Responsibility Principle.", 5 | "author": "Carlos González ", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/rhyek/nestjs-endpoints.git" 10 | }, 11 | "keywords": [ 12 | "nestjs", 13 | "endpoints", 14 | "repr", 15 | "openapi", 16 | "swagger", 17 | "file-based", 18 | "routing", 19 | "routes", 20 | "rest", 21 | "schema", 22 | "zod", 23 | "codegen", 24 | "trpc" 25 | ], 26 | "files": [ 27 | "src", 28 | "dist" 29 | ], 30 | "main": "./dist/cjs/index.js", 31 | "types": "./dist/esm/index.d.ts", 32 | "exports": { 33 | ".": { 34 | "require": "./dist/cjs/index.js", 35 | "import": "./dist/esm/index.js" 36 | } 37 | }, 38 | "sideEffects": false, 39 | "scripts": { 40 | "dev": "tsc-watch --preserveWatchOutput --onSuccess \"../../scripts/build.sh\"", 41 | "test": "vitest" 42 | }, 43 | "dependencies": { 44 | "@orval/query": "~7.8.0", 45 | "callsites": "^3.1.0", 46 | "openapi-types": "^12.1.3", 47 | "orval": "~7.8.0", 48 | "zod-openapi": "^5.4.0" 49 | }, 50 | "peerDependencies": { 51 | "@nestjs/common": ">=10.0.0", 52 | "@nestjs/core": ">=10.0.0", 53 | "@nestjs/swagger": ">=7.0.0", 54 | "zod": ">=4.1.0" 55 | }, 56 | "devDependencies": { 57 | "@types/node": "^22.13.1", 58 | "tsc-alias": "^1.8.10", 59 | "tsc-watch": "^6.2.1", 60 | "typescript": "^5.7.3", 61 | "vitest": "^3.2.4" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/test/test-react-query-client/test/client.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import React from 'react'; 4 | import { App } from '../src/App'; 5 | import { GreetPage } from '../src/GreetPage'; 6 | import { UserPage } from '../src/UserPage'; 7 | 8 | describe('Client', () => { 9 | test('hello world', () => { 10 | render(
Hello
); 11 | expect(screen.getByText('Hello')).toBeInTheDocument(); 12 | }); 13 | 14 | test('client works', async () => { 15 | render( 16 | 17 | 18 | , 19 | ); 20 | expect(await screen.findByText('Loading...')).toBeInTheDocument(); 21 | await vitest.waitFor(async () => { 22 | expect( 23 | await screen.findByText('Error: User not found'), 24 | ).toBeInTheDocument(); 25 | }); 26 | await userEvent.click(screen.getByText('Create User')); 27 | await vitest.waitFor(async () => { 28 | expect( 29 | await screen.findByText('Name: John Doe'), 30 | ).toBeInTheDocument(); 31 | expect( 32 | await screen.findByText('Email: john.doe@example.com'), 33 | ).toBeInTheDocument(); 34 | }); 35 | }); 36 | 37 | test('greet functionality works', async () => { 38 | render( 39 | 40 | 41 | , 42 | ); 43 | 44 | const input = screen.getByPlaceholderText('Enter your name'); 45 | const button = screen.getByText('Greet'); 46 | 47 | await userEvent.type(input, 'World'); 48 | await userEvent.click(button); 49 | 50 | await vitest.waitFor(async () => { 51 | expect(await screen.findByTestId('greeting')).toHaveTextContent( 52 | 'Hello, World!', 53 | ); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+' 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | id-token: write 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: asdf-vm/actions/install@v3 18 | with: 19 | asdf_branch: v0.15.0 20 | - run: pnpm install --frozen-lockfile 21 | 22 | - name: Set package version from tag 23 | working-directory: packages/nestjs-endpoints 24 | run: | 25 | version="${GITHUB_REF#refs/tags/}" 26 | echo "Setting package version to: $version" 27 | npm version "$version" --no-git-tag-version 28 | 29 | - name: Build package 30 | run: ./scripts/build.sh 31 | 32 | - run: cp README.md ./packages/nestjs-endpoints/README.md 33 | 34 | - name: Extract changelog entry 35 | run: ./scripts/change-log-entry.sh ${{ github.ref_name }} > ${{ github.workspace }}/RELEASE_BODY.txt 36 | 37 | - name: GitHub Release 38 | uses: softprops/action-gh-release@v2 39 | with: 40 | body_path: ${{ github.workspace }}/RELEASE_BODY.txt 41 | files: | 42 | packages/nestjs-endpoints/package.json 43 | packages/nestjs-endpoints/README.md 44 | packages/nestjs-endpoints/dist 45 | 46 | - name: Publish package to npm 47 | working-directory: packages/nestjs-endpoints 48 | run: | 49 | cat << 'EOF' > .npmrc 50 | //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} 51 | registry=https://registry.npmjs.org/ 52 | always-auth=true 53 | EOF 54 | 55 | npm publish --provenance --access public 56 | env: 57 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 58 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | Module, 6 | NestInterceptor, 7 | Type, 8 | } from '@nestjs/common'; 9 | import { APP_INTERCEPTOR } from '@nestjs/core'; 10 | import { 11 | EndpointsRouterModule, 12 | ZodValidationException, 13 | } from 'nestjs-endpoints'; 14 | import { Observable, catchError, throwError } from 'rxjs'; 15 | import { ZodError } from 'zod'; 16 | import { AuthModule } from './auth/auth.module'; 17 | import { HelloService } from './endpoints/hello.service'; 18 | import { 19 | AppointmentRepositoryToken, 20 | IAppointmentRepository, 21 | } from './endpoints/user/appointment/appointment-repository.interface'; 22 | import { AppointmentRepository } from './endpoints/user/appointment/appointment.repository'; 23 | import { UserListModule } from './endpoints/user/list/user-list.module'; 24 | import { UserRepository } from './endpoints/user/user.repository'; 25 | import { UserRepositoryToken } from './endpoints/user/user.repository.token'; 26 | import { 27 | IUserRepository, 28 | UserService, 29 | } from './endpoints/user/user.service'; 30 | import { VanillaController } from './vanilla.controller'; 31 | 32 | @Injectable() 33 | export class ZodErrorInterceptor implements NestInterceptor { 34 | intercept( 35 | _context: ExecutionContext, 36 | next: CallHandler, 37 | ): Observable { 38 | return next.handle().pipe( 39 | catchError((error) => { 40 | if (error instanceof ZodError) { 41 | return throwError(() => new ZodValidationException(error)); 42 | } 43 | return throwError(() => error); 44 | }), 45 | ); 46 | } 47 | } 48 | 49 | @Module({ 50 | imports: [ 51 | AuthModule, 52 | EndpointsRouterModule.register({ 53 | rootDirectory: './endpoints', 54 | imports: [AuthModule], 55 | providers: [ 56 | UserService, 57 | { 58 | provide: UserRepositoryToken, 59 | useClass: UserRepository as Type, 60 | }, 61 | { 62 | provide: AppointmentRepositoryToken, 63 | useClass: AppointmentRepository as Type, 64 | }, 65 | HelloService, 66 | ], 67 | }), 68 | UserListModule, 69 | ], 70 | providers: [ 71 | { 72 | provide: APP_INTERCEPTOR, 73 | useClass: ZodErrorInterceptor, 74 | }, 75 | ], 76 | controllers: [VanillaController], 77 | }) 78 | export class AppModule {} 79 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/src/endpoints/user/appointment/_endpoints/create/endpoint.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import { Inject, Req, UseGuards } from '@nestjs/common'; 3 | import { decorated, endpoint, schema, z } from 'nestjs-endpoints'; 4 | import { AuthGuard } from '../../../../../auth.guard'; 5 | import { CurrentUser } from '../../../../../decorators/current-user.decorator'; 6 | import { UserService } from '../../../user.service'; 7 | import { 8 | AppointmentRepositoryToken, 9 | IAppointmentRepository, 10 | } from '../../appointment-repository.interface'; 11 | 12 | export default endpoint({ 13 | method: 'post', 14 | summary: 'Create an appointment', 15 | input: z.object({ 16 | userId: z.number(), 17 | date: z.coerce.date(), 18 | }), 19 | output: { 20 | 201: schema( 21 | z.object({ 22 | id: z.number(), 23 | date: z 24 | .date() 25 | .transform((date) => date.toISOString()) 26 | .meta({ 27 | type: 'string', 28 | format: 'date-time', 29 | }), 30 | address: z.string(), 31 | }), 32 | { 33 | description: 'Appointment created', 34 | }, 35 | ), 36 | 400: z.union([ 37 | z.string(), 38 | z.object({ 39 | message: z.string(), 40 | errorCode: z.string(), 41 | }), 42 | ]), 43 | }, 44 | decorators: [UseGuards(AuthGuard)], 45 | inject: { 46 | userService: UserService, 47 | appointmentsRepository: decorated( 48 | Inject(AppointmentRepositoryToken), 49 | ), 50 | }, 51 | injectAtRequest: { 52 | currentUser: decorated<{ name: string; isSuperAdmin: boolean }>( 53 | CurrentUser(), 54 | ), 55 | req: decorated<{ ip: string | undefined }>(Req()), 56 | }, 57 | handler: async ({ 58 | input, 59 | userService, 60 | appointmentsRepository, 61 | currentUser, 62 | req, 63 | response, 64 | }) => { 65 | assert(typeof req.ip === 'string'); 66 | assert(currentUser.isSuperAdmin); 67 | const user = userService.find(input.userId); 68 | if (!user) { 69 | return response(400, 'User not found'); 70 | } 71 | if (appointmentsRepository.hasConflict(input.date)) { 72 | return response(400, { 73 | message: 'Appointment has conflict', 74 | errorCode: 'APPOINTMENT_CONFLICT', 75 | }); 76 | } 77 | return response( 78 | 201, 79 | await appointmentsRepository.create( 80 | input.userId, 81 | input.date, 82 | req.ip, 83 | ), 84 | ); 85 | }, 86 | }); 87 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/src/setup-openapi.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | import { Writable } from 'node:stream'; 4 | import { INestApplication } from '@nestjs/common'; 5 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 6 | import { settings } from './consts'; 7 | import { getCallsiteFile } from './helpers'; 8 | 9 | async function readPkgJson() { 10 | const start = path.dirname(getCallsiteFile()); 11 | for ( 12 | let dir = start; 13 | dir !== path.parse(dir).root && dir !== process.cwd(); 14 | dir = path.dirname(dir) 15 | ) { 16 | const pkgJson = path.join(dir, 'package.json'); 17 | if (await fs.stat(pkgJson).catch(() => false)) { 18 | const parsed = JSON.parse(await fs.readFile(pkgJson, 'utf-8')); 19 | if (parsed.name === 'nestjs-endpoints') { 20 | return parsed; 21 | } 22 | } 23 | } 24 | return null; 25 | } 26 | 27 | export async function setupOpenAPI( 28 | app: INestApplication, 29 | options?: { 30 | configure?: (documentBuilder: DocumentBuilder) => void; 31 | outputFile?: string | Writable; 32 | }, 33 | ) { 34 | const builder = new DocumentBuilder(); 35 | if (options?.configure) { 36 | options.configure(builder); 37 | } 38 | const config = builder.build(); 39 | const document = SwaggerModule.createDocument(app, config); 40 | document.components = { 41 | ...document.components, 42 | schemas: { 43 | ...document.components?.schemas, 44 | ...settings.openapi.components.schemas, 45 | }, 46 | }; 47 | Object.assign(document, { 48 | info: { 49 | ...document.info, 50 | 'nestjs-endpoints': { 51 | version: (await readPkgJson())?.version ?? '1', 52 | }, 53 | }, 54 | }); 55 | let changed = false; 56 | 57 | if (options?.outputFile) { 58 | const outputFile = options.outputFile; 59 | const newDocument = JSON.stringify(document, null, 2); 60 | 61 | if (outputFile instanceof Writable) { 62 | await new Promise((resolve, reject) => { 63 | outputFile.write(newDocument, (err) => { 64 | if (err) reject(err); 65 | else resolve(); 66 | }); 67 | }); 68 | changed = true; 69 | } else { 70 | const documentFile = path.isAbsolute(outputFile) 71 | ? outputFile 72 | : path.resolve(path.dirname(getCallsiteFile()), outputFile); 73 | const currentDocument = await fs 74 | .readFile(documentFile, 'utf-8') 75 | .catch(() => ''); 76 | if (currentDocument !== newDocument) { 77 | await fs.writeFile(documentFile, newDocument, 'utf-8'); 78 | changed = true; 79 | } 80 | } 81 | } 82 | return { document, changed }; 83 | } 84 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | __dirname=$(realpath "$(dirname "$0")") 4 | 5 | pnpm --filter "nestjs-endpoints" test run 6 | 7 | pnpm --filter "./packages/test/*" exec rm -rf generated 8 | 9 | pnpm --filter "./packages/test/*" --filter "!test-app-express-cjs" --filter "!test-react-query-client" exec sh -c "\ 10 | rsync -ar ../test-app-express-cjs/src/ ./src/ && \ 11 | rsync -ar ../test-app-express-cjs/test/ ./test/ --exclude=create-app.ts 12 | " 13 | 14 | cp ./packages/test/test-app-express-cjs/test/create-app.ts ./packages/test/test-app-express-esm/test/create-app.ts 15 | cp ./packages/test/test-app-fastify-cjs/test/create-app.ts ./packages/test/test-app-fastify-esm/test/create-app.ts 16 | 17 | pnpm --filter "./packages/test/*" run codegen 18 | pnpm -r run tscheck 19 | 20 | pnpm --filter "test-app-*" run test:e2e --no-cache 21 | 22 | # Function to test a specific module type (cjs or esm) 23 | test_real_run() { 24 | local type=$1 25 | 26 | cd $__dirname/../packages/test/test-app-express-$type 27 | # enable job control so we get a new process group for the server 28 | set -m 29 | pnpm start & 30 | SERVER_PID=$! 31 | set +m 32 | 33 | wget -qO- https://raw.githubusercontent.com/eficode/wait-for/v2.2.3/wait-for | sh -s -- http://localhost:3000/test/status --timeout=10 -- echo $type success 34 | 35 | # Get the process group ID and ensure it's a valid number before using it 36 | PGID=$(ps -o pgid= -p $SERVER_PID | tr -d ' ') 37 | if [[ -n "$PGID" && "$PGID" =~ ^[0-9]+$ ]]; then 38 | kill -9 -- -$PGID 39 | else 40 | # Fallback to killing just the server process if we can't get the group 41 | kill -9 $SERVER_PID 2>/dev/null || true 42 | fi 43 | 44 | wait $SERVER_PID 2>/dev/null || true 45 | } 46 | 47 | # Test CJS module 48 | test_real_run cjs 49 | 50 | # Test ESM module 51 | test_real_run esm 52 | 53 | test_react_query_client() { 54 | cd $__dirname/../packages/test/test-app-express-cjs 55 | # enable job control so we get a new process group for the server 56 | 57 | set -m 58 | pnpm start & 59 | SERVER_PID=$! 60 | set +m 61 | 62 | wget -qO- https://raw.githubusercontent.com/eficode/wait-for/v2.2.3/wait-for | sh -s -- http://localhost:3000/test/status --timeout=10 -- echo $type success 63 | 64 | cd $__dirname/../packages/test/test-react-query-client 65 | pnpm test run 66 | 67 | # Get the process group ID and ensure it's a valid number before using it 68 | PGID=$(ps -o pgid= -p $SERVER_PID | tr -d ' ') 69 | if [[ -n "$PGID" && "$PGID" =~ ^[0-9]+$ ]]; then 70 | kill -9 -- -$PGID 71 | else 72 | # Fallback to killing just the server process if we can't get the group 73 | kill -9 $SERVER_PID 2>/dev/null || true 74 | fi 75 | 76 | wait $SERVER_PID 2>/dev/null || true 77 | } 78 | test_react_query_client 79 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import eslint from '@eslint/js'; 3 | import importPlugin from 'eslint-plugin-import'; 4 | import eslintConfigPrettier from 'eslint-plugin-prettier/recommended'; 5 | import unusedImports from 'eslint-plugin-unused-imports'; 6 | import tseslint from 'typescript-eslint'; 7 | 8 | export default tseslint.config( 9 | { 10 | ignores: [ 11 | '**/node_modules', 12 | '**/dist', 13 | 'packages/test-endpoints-module/*', 14 | '!packages/test-endpoints-module/test-app-express-cjs/', 15 | 'packages/test-endpoints-router-module/*', 16 | '!packages/test-endpoints-router-module/test-app-express-cjs/', 17 | ], 18 | }, 19 | eslint.configs.recommended, 20 | tseslint.configs.recommendedTypeChecked, 21 | { 22 | languageOptions: { 23 | parserOptions: { 24 | tsconfigRootDir: import.meta.dirname, 25 | projectService: true, 26 | sourceType: 'module', 27 | }, 28 | }, 29 | }, 30 | { 31 | plugins: { 32 | import: importPlugin, 33 | 'unused-imports': unusedImports, 34 | }, 35 | }, 36 | eslintConfigPrettier, 37 | { 38 | rules: { 39 | 'import/order': [ 40 | 'error', 41 | { 42 | groups: [ 43 | 'builtin', 44 | 'external', 45 | 'internal', 46 | 'parent', 47 | 'sibling', 48 | 'index', 49 | ], 50 | pathGroupsExcludedImportTypes: ['builtin', 'object'], 51 | alphabetize: { 52 | order: 'asc', 53 | caseInsensitive: true, 54 | }, 55 | 'newlines-between': 'never', 56 | }, 57 | ], 58 | 'unused-imports/no-unused-imports': 'error', 59 | 'unused-imports/no-unused-vars': [ 60 | 'warn', 61 | { 62 | vars: 'all', 63 | varsIgnorePattern: '^_', 64 | args: 'after-used', 65 | argsIgnorePattern: '^_', 66 | }, 67 | ], 68 | '@typescript-eslint/no-unsafe-call': 'off', 69 | '@typescript-eslint/no-unsafe-member-access': 'off', 70 | '@typescript-eslint/no-unsafe-assignment': 'off', 71 | '@typescript-eslint/no-unsafe-argument': 'off', 72 | '@typescript-eslint/no-explicit-any': 'off', 73 | '@typescript-eslint/no-unsafe-return': 'off', 74 | '@typescript-eslint/no-redundant-type-constituents': 'off', 75 | }, 76 | }, 77 | { 78 | files: ['packages/nestjs-endpoints/**'], 79 | rules: { 80 | 'no-console': 'error', 81 | }, 82 | }, 83 | { 84 | files: ['packages/test/test-react-query-client/**'], 85 | rules: { 86 | 'no-console': 'off', 87 | '@typescript-eslint/no-misused-promises': 'off', 88 | }, 89 | }, 90 | { 91 | files: ['packages/test/*/generated/**'], 92 | rules: { 93 | '@typescript-eslint/no-unnecessary-type-assertion': 'off', 94 | 'import/order': 'off', 95 | }, 96 | }, 97 | ); 98 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/test/client.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import Axios from 'axios'; 3 | import { createApiClient } from '../generated/axios-client'; 4 | import { AppModule } from '../src/app.module'; 5 | import { createApp } from './create-app'; 6 | 7 | describe('generated client', () => { 8 | test.concurrent('create user - axios config', async () => { 9 | const moduleFixture: TestingModule = await Test.createTestingModule({ 10 | imports: [AppModule], 11 | }).compile(); 12 | const { app } = await createApp(moduleFixture); 13 | try { 14 | const client = createApiClient({ 15 | baseURL: await app.getUrl(), 16 | }); 17 | await expect(client.userGet({ id: 1 })).rejects.toMatchObject({ 18 | response: { 19 | status: 404, 20 | data: { 21 | statusCode: 404, 22 | message: 'User not found', 23 | }, 24 | }, 25 | }); 26 | const { 27 | data: { id }, 28 | } = await client.userCreate({ 29 | name: 'Jake', 30 | email: 'jake@gmail.com', 31 | }); 32 | const { data: user } = await client.userFind({ id }); 33 | expect(user).toEqual({ 34 | id, 35 | name: 'Jake', 36 | email: 'jake@gmail.com', 37 | }); 38 | } finally { 39 | await app.close(); 40 | } 41 | }); 42 | 43 | test.concurrent('create user - axios instance', async () => { 44 | const moduleFixture: TestingModule = await Test.createTestingModule({ 45 | imports: [AppModule], 46 | }).compile(); 47 | const app = moduleFixture.createNestApplication(); 48 | await app.init(); 49 | await app.listen(0); 50 | try { 51 | const axios = Axios.create({ 52 | baseURL: await app.getUrl(), 53 | }); 54 | const client = createApiClient(axios); 55 | await expect(client.userGet({ id: 1 })).rejects.toMatchObject({ 56 | response: { 57 | status: 404, 58 | data: { 59 | statusCode: 404, 60 | message: 'User not found', 61 | }, 62 | }, 63 | }); 64 | const { 65 | data: { id }, 66 | } = await client.userCreate({ 67 | name: 'Jake', 68 | email: 'jake@gmail.com', 69 | }); 70 | const { data: user } = await client.userFind({ id }); 71 | expect(user).toEqual({ 72 | id, 73 | name: 'Jake', 74 | email: 'jake@gmail.com', 75 | }); 76 | } finally { 77 | await app.close(); 78 | } 79 | }); 80 | 81 | test.concurrent('greet endpoint via axios client', async () => { 82 | const moduleFixture: TestingModule = await Test.createTestingModule({ 83 | imports: [AppModule], 84 | }).compile(); 85 | const { app } = await createApp(moduleFixture); 86 | try { 87 | const client = createApiClient({ 88 | baseURL: await app.getUrl(), 89 | }); 90 | const { data } = await client.greet({ name: 'Alice' }); 91 | expect(data).toBe('Hello, Alice!'); 92 | } finally { 93 | await app.close(); 94 | } 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/test/invoke.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ZodValidationException } from 'nestjs-endpoints'; 3 | import { AuthService } from '../src/auth/auth.service'; 4 | import userCreateEndpoint from '../src/endpoints/user/create.endpoint'; 5 | import userFindEndpoint from '../src/endpoints/user/find.endpoint'; 6 | import { userListNoPath as userListNoPathEndpoint } from '../src/endpoints/user/list/user-list-no-path.endpoint'; 7 | import userPurgeEndpoint from '../src/endpoints/user/purge.endpoint'; 8 | import { UserRepository } from '../src/endpoints/user/user.repository'; 9 | import { UserRepositoryToken } from '../src/endpoints/user/user.repository.token'; 10 | import { UserService } from '../src/endpoints/user/user.service'; 11 | 12 | test('can test controllers directly without http pipeline', async () => { 13 | const moduleFixture: TestingModule = await Test.createTestingModule({ 14 | controllers: [ 15 | userListNoPathEndpoint, 16 | userCreateEndpoint, 17 | userPurgeEndpoint, 18 | userFindEndpoint, 19 | ], 20 | providers: [ 21 | AuthService, 22 | { 23 | provide: UserRepositoryToken, 24 | useClass: UserRepository, 25 | }, 26 | UserService, 27 | ], 28 | }).compile(); 29 | const app = moduleFixture.createNestApplication(); 30 | app.useLogger(false); 31 | await app.init(); 32 | const userRepository = app.get(UserRepositoryToken); 33 | const userListNoPath = app.get(userListNoPathEndpoint); 34 | const userCreate = app.get(userCreateEndpoint); 35 | const userPurge = app.get(userPurgeEndpoint); 36 | const userFind = app.get(userFindEndpoint); 37 | expect(userRepository.findAll()).toEqual([]); 38 | await expect(userListNoPath.invoke()).resolves.toEqual([]); 39 | try { 40 | await userCreate.invoke({ 41 | email: 'john@example.com', 42 | } as any); 43 | throw new Error('Should not reach here'); 44 | } catch (error) { 45 | expect(error).toBeInstanceOf(ZodValidationException); 46 | const zodError = (error as ZodValidationException).getZodError(); 47 | expect(zodError).toMatchObject({ 48 | issues: [ 49 | { 50 | expected: 'string', 51 | code: 'invalid_type', 52 | path: ['name'], 53 | message: 'Invalid input: expected string, received undefined', 54 | }, 55 | ], 56 | }); 57 | } 58 | await expect( 59 | userCreate.invoke({ 60 | name: 'John', 61 | email: 'john@example.com', 62 | }), 63 | ).resolves.toEqual({ id: 1 }); 64 | expect(userRepository.findAll()).toEqual([ 65 | { id: 1, name: 'John', email: 'john@example.com' }, 66 | ]); 67 | await expect(userListNoPath.invoke()).resolves.toEqual([ 68 | { id: 1, name: 'John', email: 'john@example.com' }, 69 | ]); 70 | await expect(userFind.invoke({ id: 1 })).resolves.toEqual({ 71 | id: 1, 72 | name: 'John', 73 | email: 'john@example.com', 74 | }); 75 | await expect(userPurge.invoke()).resolves.toEqual(undefined); 76 | await expect(userListNoPath.invoke()).resolves.toEqual([]); 77 | }); 78 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/src/endpoints-router.module.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import { 4 | DynamicModule, 5 | Module, 6 | ModuleMetadata, 7 | Type, 8 | } from '@nestjs/common'; 9 | import { endpointFileRegex, settings } from './consts'; 10 | import { getCallsiteFile, moduleAls } from './helpers'; 11 | 12 | @Module({}) 13 | export class EndpointsRouterModule { 14 | static async register(params?: { 15 | /** 16 | * The root directory to load endpoints from recursively. Relative and absolute 17 | * paths are supported. 18 | * @default The directory of the calling file 19 | */ 20 | rootDirectory?: string; 21 | /** 22 | * The base path to use for endpoints in this module. 23 | * @default '/' 24 | */ 25 | basePath?: string; 26 | imports?: ModuleMetadata['imports']; 27 | exports?: ModuleMetadata['exports']; 28 | providers?: ModuleMetadata['providers']; 29 | }): Promise { 30 | const definedAtDir = path.dirname(getCallsiteFile()); 31 | const resolveDir = (dir: string) => { 32 | if (!path.isAbsolute(dir)) { 33 | return path.join(definedAtDir, dir); 34 | } 35 | return dir; 36 | }; 37 | const rootDirectory = params?.rootDirectory 38 | ? resolveDir(params.rootDirectory) 39 | : definedAtDir; 40 | let endpoints: Type[] = []; 41 | const endopointFiles = findEndpoints(rootDirectory); 42 | const endpointFilesNotImported = endopointFiles.filter((f) => 43 | settings.endpoints.every((e) => e.file !== f), 44 | ); 45 | // eslint-disable-next-line @typescript-eslint/require-await -- needed since we are replacing the require with await import during build for esm 46 | await moduleAls.run(true, async () => { 47 | for (const f of endpointFilesNotImported) { 48 | // eslint-disable-next-line @typescript-eslint/no-require-imports 49 | const endpoint = require(f).default; 50 | if (endpoint) { 51 | endpoints.push(endpoint); 52 | } 53 | } 54 | }); 55 | for (const { setupFn } of settings.endpoints.filter((e) => 56 | endpointFilesNotImported.some((f) => f === e.file), 57 | )) { 58 | setupFn({ rootDirectory, basePath: params?.basePath ?? '/' }); 59 | } 60 | if (endpoints.length > 0) { 61 | endpoints = endpoints.filter((e) => { 62 | return Reflect.getMetadataKeys(e).some( 63 | (k) => k === 'endpoints:path', 64 | ); 65 | }); 66 | } 67 | 68 | return { 69 | module: EndpointsRouterModule, 70 | imports: params?.imports, 71 | exports: params?.exports, 72 | providers: params?.providers, 73 | controllers: endpoints, 74 | }; 75 | } 76 | } 77 | 78 | function findEndpoints(dir: string, endopointFiles: string[] = []) { 79 | const files = fs.readdirSync(dir); 80 | for (const f of files) { 81 | const file = path.join(dir, f); 82 | if (fs.statSync(file).isDirectory()) { 83 | findEndpoints(file, endopointFiles); 84 | } 85 | if (f.match(endpointFileRegex)) { 86 | endopointFiles.push(file); 87 | } 88 | } 89 | return endopointFiles; 90 | } 91 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/src/codegen/setup.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | import { Writable } from 'node:stream'; 4 | import { INestApplication } from '@nestjs/common'; 5 | import { DocumentBuilder } from '@nestjs/swagger'; 6 | import type { QueryOptions } from 'orval'; 7 | import { setupOpenAPI } from '../setup-openapi'; 8 | import { axios } from './builder/axios'; 9 | import { reactQuery } from './builder/react-query'; 10 | 11 | export async function setupCodegen( 12 | app: INestApplication, 13 | params: { 14 | openapi?: { 15 | outputFile?: string | Writable; 16 | configure?: (documentBuilder: DocumentBuilder) => void; 17 | }; 18 | clients: ({ 19 | outputFile: string; 20 | } & ( 21 | | { 22 | type: 'axios'; 23 | } 24 | | { 25 | type: 'react-query'; 26 | options?: QueryOptions; 27 | } 28 | ))[]; 29 | /** 30 | * If true, the codegen will be forced to run even if the output file already exists 31 | * or the OpenAPI document has not changed. 32 | */ 33 | forceGenerate?: boolean | undefined; 34 | }, 35 | ) { 36 | const openapiOutputFile = 37 | params.openapi?.outputFile ?? 38 | path.resolve(__dirname, '__generated-openapi.json'); 39 | const { document, changed } = await setupOpenAPI(app, { 40 | ...params.openapi, 41 | outputFile: openapiOutputFile, 42 | }); 43 | const outputFileExists = await Promise.all( 44 | params.clients.map((client) => 45 | fs 46 | .stat(client.outputFile) 47 | .then(() => true) 48 | .catch(() => false), 49 | ), 50 | ); 51 | if ( 52 | params.forceGenerate || 53 | changed || 54 | outputFileExists.some((exists) => !exists) 55 | ) { 56 | await import('orval').then(async ({ generate }) => { 57 | await Promise.all( 58 | params.clients 59 | .filter( 60 | (_, index) => 61 | params.forceGenerate || changed || !outputFileExists[index], 62 | ) 63 | .map(async (client) => { 64 | if (client.type === 'axios') { 65 | await generate({ 66 | input: { 67 | target: document as any, 68 | }, 69 | output: { 70 | target: client.outputFile, 71 | client: axios(), 72 | mode: 'single', 73 | indexFiles: false, 74 | }, 75 | }); 76 | } else if (client.type === 'react-query') { 77 | const queryOptions: QueryOptions = { 78 | version: 5, 79 | ...client.options, 80 | }; 81 | await generate({ 82 | input: { 83 | target: document as any, 84 | }, 85 | output: { 86 | target: client.outputFile, 87 | client: reactQuery(), 88 | mode: 'single', 89 | indexFiles: false, 90 | override: { 91 | query: queryOptions, 92 | }, 93 | }, 94 | }); 95 | } 96 | }), 97 | ); 98 | }); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.0.3 (2025-11-16) 4 | 5 | ## Bug fixes 6 | 7 | - Disallow both ZodNull and ZodNullable types for output 8 | 9 | ## 2.0.2 (2025-11-16) 10 | 11 | ### Bug fixes 12 | 13 | - Middleware that added response headers after controller execution were failing with `Cannot set headers after they are sent to the client`. 14 | 15 | This was fixed, however nestjs-endpoints can no longer support endpoint output schemas that are nullable. NestJS does not send `null` when using Express.js. [More info](https://github.com/nestjs/nest/issues/10415). 16 | 17 | So instead of: 18 | 19 | ```ts 20 | endpoint({ 21 | output: personSchema.nullable(), 22 | ... 23 | }); 24 | ``` 25 | 26 | now do: 27 | 28 | ```ts 29 | endpoint({ 30 | output: z.object({ 31 | person: personSchema.nullable() 32 | }), 33 | ... 34 | }) 35 | ``` 36 | 37 | ## 2.0.1 (2025-10-19) 38 | 39 | ### Minor changes 40 | 41 | - Deprecated `injectAtRequest` in favor of `injectOnRequest` 42 | 43 | ## 2.0.0 (2025-09-15) 44 | 45 | ### Breaking Changes 46 | 47 | - **Upgraded to Zod v4**: Zod v3 schemas will have to be upgraded to v4. 48 | - **Upgraded to zod-openapi v5**: See the [v5 migration guide](https://github.com/samchungy/zod-openapi/blob/HEAD/docs/v5.md) if you used `.openapi()` extensions. 49 | - **OpenAPI version upgrade**: Now generates OpenAPI 3.1.1 schemas (previously 3.0.0). 50 | 51 | ### Minor changes 52 | 53 | - Deprecated `injectMethod` parameter in favor of `injectAtRequest` for clarity. 54 | 55 | ## 1.5.1 (2025-06-28) 56 | 57 | ### Testing 58 | 59 | - Added more tests 60 | 61 | ## 1.5.0 (2025-06-22) 62 | 63 | ### Features 64 | 65 | - Replaced `nestjs-zod` with [zod-openapi](https://github.com/samchungy/zod-openapi). The main benefit is output schemas will now emit OpenApi schemas and consequently TypeScript definitions for endpoint payloads that consider zod transforms. 66 | 67 | Example: 68 | 69 | ```typescript 70 | const schema = z.object({ 71 | age: z.number().default(30), 72 | }); 73 | ``` 74 | 75 | The above schema when used in `input` will still mark `age` as optional, but when used in `output` it will not. 76 | 77 | ```typescript 78 | type ExampleInput = { 79 | age?: number; 80 | }; 81 | 82 | type ExampleOutput = { 83 | age: number; 84 | }; 85 | ``` 86 | 87 | ## 1.4.0 (2025-06-13) 88 | 89 | ### Features 90 | 91 | - Provide `rawInput` to handlers with input schemas. This is the request body parsed by NestJS, but before zod. 92 | 93 | ## 1.3.1 (2025-05-26) 94 | 95 | ### Bugfixes 96 | 97 | - Support OPTIONS HTTP method 98 | - Use my fork of @nestjs/zod until this is merged: https://github.com/BenLorantfy/nestjs-zod/pull/151 99 | 100 | ## 1.3.0 (2025-04-12) 101 | 102 | ### Features 103 | 104 | - Added support for traditional controller imports + explicit HTTP paths 105 | - Can now call `invoke()` on endpoint instances. Useful for integration testing. [Example](https://github.com/rhyek/nestjs-endpoints/blob/1b1242348ebc77abad5ad0c67ab372690102d736/packages/test/test-app-express-cjs/test/app.e2e-spec.ts#L467). 106 | 107 | ## 1.2.0 (2025-04-05) 108 | 109 | ### Features 110 | 111 | - Integrated orval setup for axios and react-query clients using `setupCodegen`. No longer necessary for users to set this up themselves. 112 | 113 | ## 1.1.0 (2025-03-29) 114 | 115 | ### Breaking Changes 116 | 117 | - Replaced `EndpointsRouterModule.forRoot()` with `EndpointsRouterModule.register()` 118 | - Removed `EndpointsModule` decorator 119 | 120 | ### Features 121 | 122 | - Added support for multiple router registrations in different modules 123 | - Each registration will have its own `rootDirectory` and `baseBath` 124 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from 'node:async_hooks'; 2 | import path from 'node:path'; 3 | import { applyDecorators } from '@nestjs/common'; 4 | import { ApiQuery, ApiQueryOptions } from '@nestjs/swagger'; 5 | import callsites from 'callsites'; 6 | import { z } from 'zod'; 7 | import { createSchema } from 'zod-openapi'; 8 | import { zodToOpenApi } from './zod-to-openapi'; 9 | 10 | function isDirPathSegment(dir: string) { 11 | const segment = path.basename(dir); 12 | if (!segment.startsWith('_')) { 13 | return true; 14 | } 15 | return false; 16 | } 17 | const shortCircuitDirs: Record = { 18 | [process.cwd()]: true, 19 | }; 20 | export function getEndpointHttpPath( 21 | rootDirectory: string, 22 | basePath: string, 23 | file: string, 24 | ) { 25 | shortCircuitDirs[rootDirectory] = true; 26 | let pathSegments: string[] = []; 27 | let start = path.dirname(file); 28 | let lastDirPathSegment: string | null = null; 29 | 30 | while (true) { 31 | if ( 32 | Object.keys(shortCircuitDirs).some( 33 | (d) => 34 | path.normalize(d + path.sep) === 35 | path.normalize(start + path.sep), 36 | ) || 37 | start === path.parse(start).root 38 | ) { 39 | break; 40 | } 41 | if (isDirPathSegment(start)) { 42 | pathSegments.push(path.basename(start)); 43 | lastDirPathSegment = start; 44 | } 45 | start = path.dirname(start); 46 | } 47 | if (lastDirPathSegment) { 48 | shortCircuitDirs[path.dirname(lastDirPathSegment)] = true; 49 | } 50 | const basePathSegments = basePath.split('/').filter(Boolean); 51 | pathSegments = [...basePathSegments, ...pathSegments.reverse()]; 52 | 53 | const basename = path.basename(file, path.extname(file)); 54 | if (basename !== 'endpoint') { 55 | const leaf = basename.split('.endpoint')[0]; 56 | pathSegments.push(leaf); 57 | } 58 | const httpPath = path.join(...pathSegments); 59 | const httpPathPascalName = getHttpPathPascalName(httpPath); 60 | 61 | return { httpPath, httpPathPascalName, httpPathSegments: pathSegments }; 62 | } 63 | 64 | export function getHttpPathPascalName(httpPath: string) { 65 | return httpPath 66 | .replace(/^[a-z]/, (letter) => letter.toUpperCase()) 67 | .replace(/[/-]([a-z])/g, (_, letter: string) => letter.toUpperCase()); 68 | } 69 | 70 | export const ApiQueries = (zodObject: T) => { 71 | const optionsList = Object.keys(zodObject.shape).reduce< 72 | Array< 73 | ApiQueryOptions & { 74 | schema: ReturnType['schema']; 75 | } 76 | > 77 | >((acc, name) => { 78 | const zodType = zodObject.shape[name]; 79 | if (zodType) { 80 | const { openApiSchema } = zodToOpenApi({ 81 | schema: zodType, 82 | schemaType: 'input', 83 | }); 84 | acc.push({ 85 | name, 86 | required: !zodType.isOptional(), 87 | schema: openApiSchema, 88 | }); 89 | } 90 | 91 | return acc; 92 | }, []); 93 | 94 | return applyDecorators( 95 | ...optionsList.map((options) => ApiQuery(options)), 96 | ); 97 | }; 98 | 99 | export function shouldJson(value: unknown) { 100 | return typeof value !== 'string'; 101 | } 102 | 103 | export function getCallsiteFile() { 104 | const callsite = callsites()[2]; 105 | if (!callsite) { 106 | throw new Error('Callsite not found'); 107 | } 108 | const result = callsite.getFileName()?.replace(/^file:\/\//, ''); 109 | if (!result) { 110 | throw new Error('Callsite file not found'); 111 | } 112 | return result; 113 | } 114 | 115 | export const moduleAls = new AsyncLocalStorage(); 116 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/test/zod-openapi.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Writable } from 'stream'; 2 | import { Module } from '@nestjs/common'; 3 | import { Test, TestingModule } from '@nestjs/testing'; 4 | import { endpoint, setupOpenAPI, z } from 'nestjs-endpoints'; 5 | import request from 'supertest'; 6 | import { createApp } from './create-app'; 7 | 8 | describe('zod-openapi', () => { 9 | test('payloads are correct', async () => { 10 | const testEndpoint = endpoint({ 11 | method: 'post', 12 | path: '/test1', 13 | input: z.object({ 14 | name: z.string(), 15 | age: z.number().default(30), 16 | }), 17 | output: z.object({ 18 | name: z.string(), 19 | age: z.number(), 20 | height: z.number().default(175), 21 | }), 22 | handler: ({ input }) => { 23 | return input; 24 | }, 25 | }); 26 | @Module({ 27 | controllers: [testEndpoint], 28 | }) 29 | class TestModule {} 30 | 31 | const moduleFixture: TestingModule = await Test.createTestingModule({ 32 | imports: [TestModule], 33 | }).compile(); 34 | 35 | const { app } = await createApp(moduleFixture); 36 | try { 37 | const req = request(app.getHttpServer()); 38 | 39 | await req 40 | .post('/test1') 41 | .send({ 42 | name: 'John', 43 | }) 44 | .expect(200) 45 | .then((resp) => { 46 | expect(resp.body).toEqual({ 47 | name: 'John', 48 | age: 30, 49 | height: 175, 50 | }); 51 | }); 52 | } finally { 53 | await app.close(); 54 | } 55 | }); 56 | test('openapi schema is generated correctly', async () => { 57 | const testEndpoint = endpoint({ 58 | method: 'post', 59 | path: '/test2', 60 | input: z.object({ 61 | name: z.string(), 62 | age: z.number().default(30), 63 | }), 64 | output: z.object({ 65 | name: z.string(), 66 | age: z.number(), 67 | height: z.number().default(175), 68 | }), 69 | handler: ({ input }) => { 70 | return input; 71 | }, 72 | }); 73 | @Module({ 74 | controllers: [testEndpoint], 75 | }) 76 | class TestModule {} 77 | 78 | const moduleFixture: TestingModule = await Test.createTestingModule({ 79 | imports: [TestModule], 80 | }).compile(); 81 | 82 | const { app } = await createApp(moduleFixture); 83 | let spec = ''; 84 | const stream = new Writable({ 85 | write(chunk, _, callback) { 86 | spec += chunk.toString(); 87 | callback(); 88 | }, 89 | }); 90 | await setupOpenAPI(app, { 91 | outputFile: stream, 92 | }); 93 | try { 94 | expect(spec).toBeTruthy(); 95 | const parsed = JSON.parse(spec); 96 | expect(parsed).toMatchObject({ 97 | openapi: '3.0.0', 98 | servers: [], 99 | tags: [], 100 | info: { 101 | contact: {}, 102 | description: '', 103 | 'nestjs-endpoints': { 104 | version: '0.0.0', 105 | }, 106 | title: '', 107 | version: '1.0.0', 108 | }, 109 | paths: { 110 | '/test2': { 111 | post: { 112 | operationId: 'Test2', 113 | parameters: [], 114 | requestBody: { 115 | content: { 116 | 'application/json': { 117 | schema: { 118 | $ref: '#/components/schemas/Test2Input', 119 | }, 120 | }, 121 | }, 122 | required: true, 123 | }, 124 | responses: { 125 | '200': { 126 | content: { 127 | 'application/json': { 128 | schema: { 129 | $ref: '#/components/schemas/Test2Output', 130 | }, 131 | }, 132 | }, 133 | description: '', 134 | }, 135 | }, 136 | summary: '', 137 | tags: [], 138 | }, 139 | }, 140 | }, 141 | components: { 142 | schemas: { 143 | Test2Input: { 144 | type: 'object', 145 | properties: { 146 | name: { 147 | type: 'string', 148 | }, 149 | age: { 150 | type: 'number', 151 | default: 30, 152 | }, 153 | }, 154 | required: ['name'], 155 | }, 156 | Test2Output: { 157 | type: 'object', 158 | properties: { 159 | name: { 160 | type: 'string', 161 | }, 162 | age: { 163 | type: 'number', 164 | }, 165 | height: { 166 | type: 'number', 167 | default: 175, 168 | }, 169 | }, 170 | additionalProperties: false, 171 | required: ['name', 'age', 'height'], 172 | }, 173 | }, 174 | }, 175 | }); 176 | } finally { 177 | await app.close(); 178 | } 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/src/codegen/builder/react-query.ts: -------------------------------------------------------------------------------- 1 | import type { OutputClientFunc } from 'orval'; 2 | 3 | export const reactQuery = (): OutputClientFunc => { 4 | const fns: string[] = []; 5 | return (clients) => { 6 | return { 7 | ...clients['react-query'], 8 | dependencies: (...args) => { 9 | const deps = clients['react-query'].dependencies!(...args); 10 | deps.unshift({ 11 | dependency: 'react', 12 | exports: [ 13 | { 14 | name: 'React', 15 | default: true, 16 | values: true, 17 | syntheticDefaultImport: true, 18 | }, 19 | ], 20 | }); 21 | deps 22 | .find((dep) => dep.dependency === 'axios') 23 | ?.exports.push( 24 | { 25 | name: 'AxiosInstance', 26 | }, 27 | { 28 | name: 'CreateAxiosDefaults', 29 | }, 30 | ); 31 | return deps; 32 | }, 33 | header: (params) => { 34 | const operationNames = Object.values(params.verbOptions).map( 35 | (verb) => verb.operationName, 36 | ); 37 | return ` 38 | const Axios = axios; 39 | export const createApiClient = (config?: CreateAxiosDefaults | AxiosInstance) => { 40 | const axios = 41 | config && 42 | 'defaults' in config && 43 | 'interceptors' in config && 44 | typeof config.request === 'function' 45 | ? config 46 | : Axios.create(config as CreateAxiosDefaults); 47 | ${fns.join('\n')} 48 | return { 49 | ${operationNames.map((name) => ` ${name},`).join('\n')} 50 | axios 51 | }; 52 | }; 53 | 54 | export type ApiClient = ReturnType; 55 | 56 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 57 | export const ApiClientContext = React.createContext(null as any); 58 | export const ApiClientProvider = ({ client, children }: { client: ApiClient; children: React.ReactNode }) => { 59 | return {children} 60 | }; 61 | 62 | export const useApiClient = () => { 63 | const client = React.useContext(ApiClientContext); 64 | if (!client) throw new Error('useApiClient must be used within a ApiClientProvider'); 65 | return client; 66 | }; 67 | `; 68 | }, 69 | client: async (verbOptions, options, outputClient, output) => { 70 | const result = await clients['react-query'].client( 71 | verbOptions, 72 | options, 73 | outputClient, 74 | output, 75 | ); 76 | 77 | const lines = result.implementation.split('\n'); 78 | let queryKeyLine: number | null = null; 79 | for (let i = 0; i < lines.length; i++) { 80 | if (lines[i].includes('QueryKey')) { 81 | queryKeyLine = i; 82 | break; 83 | } 84 | } 85 | if (queryKeyLine === null) { 86 | for (let i = 0; i < lines.length; i++) { 87 | if (lines[i].includes('MutationOptions')) { 88 | queryKeyLine = i; 89 | break; 90 | } 91 | } 92 | } 93 | if (queryKeyLine === null) { 94 | throw new Error('No query key found in implementation'); 95 | } 96 | const fn = lines 97 | .slice(0, queryKeyLine) 98 | .join('\n') 99 | .replace(/export /, ''); 100 | fns.push(fn); 101 | result.implementation = lines.slice(queryKeyLine).join('\n'); 102 | result.implementation = result.implementation.replace( 103 | /const mutationOptions\s+=\s+(.+)\(options\);/, 104 | (_, captured) => { 105 | return ` 106 | const client = useApiClient(); 107 | const mutationOptions = ${captured}(Object.assign({ client }, options)); 108 | `; 109 | }, 110 | ); 111 | result.implementation = result.implementation.replace( 112 | /const queryOptions\s+=\s+(.+)\(((?:params,)?options)\)/, 113 | (_, c1, c2) => { 114 | return ` 115 | const client = useApiClient(); 116 | const queryOptions = ${c1}(${c2.replace('options', 'Object.assign({ client }, options)')}); 117 | `; 118 | }, 119 | ); 120 | result.implementation = result.implementation.replace( 121 | /options\?: {.+axios\?: AxiosRequestConfig/, 122 | (match) => { 123 | return `${match.replace('options?', 'options')}, client: ApiClient`; 124 | }, 125 | ); 126 | result.implementation = result.implementation.replace( 127 | /return\s+(.+)\((?:data,)?axiosOptions\)/, 128 | (match, captured) => 129 | `${match.replace(captured, `options.client.${captured}`)}.then((res) => res.data);`, 130 | ); 131 | result.implementation = result.implementation.replace( 132 | /const queryFn.+=>\s+(.+\().+\)/, 133 | (match, captured) => 134 | `${match.replace(captured, `options.client.${captured}`)}.then((res) => res.data)`, 135 | ); 136 | result.implementation = result.implementation.replaceAll( 137 | /Awaited>/g, 138 | (match, captured) => 139 | `Awaited>['data']`, 140 | ); 141 | return result; 142 | }, 143 | }; 144 | }; 145 | }; 146 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/src/zod-to-openapi.spec.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod'; 2 | import { zodToOpenApi } from './zod-to-openapi'; 3 | 4 | describe('zodToOpenApi', () => { 5 | test.each([ 6 | { 7 | params: { 8 | schema: z.object({ 9 | name: z.string(), 10 | birthDate: z.coerce.date().optional(), 11 | age: z.number().default(18), 12 | }), 13 | ref: 'User1', 14 | schemaType: 'input' as const, 15 | }, 16 | expected: { 17 | openApiSchema: { 18 | $ref: '#/components/schemas/User1', 19 | }, 20 | schemaComponents: { 21 | User1: { 22 | type: 'object', 23 | properties: { 24 | name: { type: 'string' }, 25 | birthDate: { type: 'string' }, 26 | age: { type: 'number', default: 18 }, 27 | }, 28 | required: ['name'], 29 | }, 30 | }, 31 | }, 32 | }, 33 | { 34 | params: { 35 | schema: z.object({ 36 | birthDate: z 37 | .date() 38 | .transform((date) => date.toISOString()) 39 | .meta({ 40 | type: 'string', 41 | format: 'date-time', 42 | }), 43 | age: z.number().default(18), 44 | }), 45 | ref: 'UserOutput', 46 | schemaType: 'output' as const, 47 | }, 48 | expected: { 49 | openApiSchema: { 50 | $ref: '#/components/schemas/UserOutput', 51 | }, 52 | schemaComponents: { 53 | UserOutput: { 54 | type: 'object', 55 | properties: { 56 | birthDate: { type: 'string', format: 'date-time' }, 57 | age: { type: 'number', default: 18 }, 58 | }, 59 | required: ['birthDate', 'age'], 60 | additionalProperties: false, 61 | }, 62 | }, 63 | }, 64 | }, 65 | { 66 | params: { 67 | schema: z.object({ 68 | name: z.string(), 69 | }), 70 | schemaType: 'input' as const, 71 | }, 72 | expected: { 73 | openApiSchema: { 74 | type: 'object', 75 | properties: { 76 | name: { type: 'string' }, 77 | }, 78 | required: ['name'], 79 | }, 80 | schemaComponents: {}, 81 | }, 82 | }, 83 | { 84 | params: { 85 | schema: z.discriminatedUnion('type', [ 86 | z.object({ 87 | type: z.literal('admin'), 88 | admin: z.object({ 89 | name: z.string(), 90 | }), 91 | }), 92 | z.object({ 93 | type: z.literal('user'), 94 | user: z.object({ 95 | name: z.string(), 96 | }), 97 | }), 98 | ]), 99 | schemaType: 'input' as const, 100 | }, 101 | expected: { 102 | openApiSchema: { 103 | oneOf: [ 104 | { 105 | properties: { 106 | admin: { 107 | properties: { 108 | name: { 109 | type: 'string', 110 | }, 111 | }, 112 | required: ['name'], 113 | type: 'object', 114 | }, 115 | type: { 116 | const: 'admin', 117 | type: 'string', 118 | }, 119 | }, 120 | required: ['type', 'admin'], 121 | type: 'object', 122 | }, 123 | { 124 | properties: { 125 | type: { 126 | const: 'user', 127 | type: 'string', 128 | }, 129 | user: { 130 | properties: { 131 | name: { 132 | type: 'string', 133 | }, 134 | }, 135 | required: ['name'], 136 | type: 'object', 137 | }, 138 | }, 139 | required: ['type', 'user'], 140 | type: 'object', 141 | }, 142 | ], 143 | type: 'object', 144 | }, 145 | schemaComponents: {}, 146 | }, 147 | }, 148 | { 149 | params: { 150 | schema: z 151 | .object({ email: z.email() }) 152 | .and( 153 | z.object({ 154 | age: z.number().min(18), 155 | }), 156 | ) 157 | .and( 158 | z.discriminatedUnion('type', [ 159 | z.object({ 160 | type: z.literal('admin'), 161 | admin: z.object({ 162 | name: z.string(), 163 | }), 164 | }), 165 | z.object({ 166 | type: z.literal('user'), 167 | user: z.object({ 168 | name: z.string(), 169 | }), 170 | }), 171 | ]), 172 | ), 173 | schemaType: 'input' as const, 174 | ref: 'User2', 175 | }, 176 | expected: { 177 | openApiSchema: { 178 | $ref: '#/components/schemas/User2', 179 | }, 180 | schemaComponents: { 181 | User2: { 182 | allOf: [ 183 | { 184 | type: 'object', 185 | properties: { 186 | email: { 187 | format: 'email', 188 | 189 | pattern: 190 | "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", 191 | type: 'string', 192 | }, 193 | }, 194 | required: ['email'], 195 | }, 196 | { 197 | type: 'object', 198 | properties: { 199 | age: { 200 | type: 'number', 201 | minimum: 18, 202 | }, 203 | }, 204 | required: ['age'], 205 | }, 206 | { 207 | type: 'object', 208 | oneOf: [ 209 | { 210 | type: 'object', 211 | properties: { 212 | type: { 213 | type: 'string', 214 | const: 'admin', 215 | }, 216 | admin: { 217 | type: 'object', 218 | properties: { 219 | name: { 220 | type: 'string', 221 | }, 222 | }, 223 | required: ['name'], 224 | }, 225 | }, 226 | required: ['type', 'admin'], 227 | }, 228 | { 229 | type: 'object', 230 | properties: { 231 | type: { 232 | type: 'string', 233 | const: 'user', 234 | }, 235 | user: { 236 | type: 'object', 237 | properties: { 238 | name: { 239 | type: 'string', 240 | }, 241 | }, 242 | required: ['name'], 243 | }, 244 | }, 245 | required: ['type', 'user'], 246 | }, 247 | ], 248 | }, 249 | ], 250 | }, 251 | }, 252 | }, 253 | }, 254 | { 255 | params: { 256 | schema: z.object({ 257 | dict: z.record(z.string(), z.string()), 258 | }), 259 | schemaType: 'input' as const, 260 | }, 261 | expected: { 262 | openApiSchema: { 263 | type: 'object', 264 | properties: { 265 | dict: { 266 | type: 'object', 267 | propertyNames: { 268 | type: 'string', 269 | }, 270 | additionalProperties: { 271 | type: 'string', 272 | }, 273 | }, 274 | }, 275 | required: ['dict'], 276 | }, 277 | schemaComponents: {}, 278 | }, 279 | }, 280 | { 281 | params: { 282 | schema: z.union([z.literal('a'), z.literal('b')]), 283 | schemaType: 'input' as const, 284 | }, 285 | expected: { 286 | openApiSchema: { 287 | oneOf: [ 288 | { type: 'string', const: 'a' }, 289 | { type: 'string', const: 'b' }, 290 | ], 291 | }, 292 | schemaComponents: {}, 293 | }, 294 | }, 295 | { 296 | params: { 297 | schema: z.string().transform((s) => s.toUpperCase()), 298 | schemaType: 'input' as const, 299 | }, 300 | expected: { 301 | openApiSchema: { 302 | type: 'string', 303 | }, 304 | schemaComponents: {}, 305 | }, 306 | }, 307 | { 308 | params: { 309 | schema: z.string().overwrite((s) => s.toUpperCase()), 310 | schemaType: 'output' as const, 311 | }, 312 | expected: { 313 | openApiSchema: { 314 | type: 'string', 315 | }, 316 | schemaComponents: {}, 317 | }, 318 | }, 319 | { 320 | params: { 321 | schema: z 322 | .string() 323 | .transform((s) => s.toUpperCase()) 324 | .meta({ 325 | type: 'string', 326 | }), 327 | schemaType: 'output' as const, 328 | }, 329 | expected: { 330 | openApiSchema: { 331 | type: 'string', 332 | }, 333 | schemaComponents: {}, 334 | }, 335 | }, 336 | { 337 | params: { 338 | schema: z.object({ 339 | person: z.preprocess( 340 | (data: any) => data, 341 | z 342 | .object({ 343 | name: z.string(), 344 | }) 345 | .nullish() 346 | .default(null), 347 | ), 348 | }), 349 | schemaType: 'input' as const, 350 | }, 351 | expected: { 352 | openApiSchema: { 353 | properties: { 354 | person: { 355 | default: null, 356 | oneOf: [ 357 | { 358 | properties: { 359 | name: { 360 | type: 'string', 361 | }, 362 | }, 363 | required: ['name'], 364 | type: 'object', 365 | }, 366 | { 367 | type: 'null', 368 | }, 369 | ], 370 | }, 371 | }, 372 | required: ['person'], 373 | type: 'object', 374 | }, 375 | schemaComponents: {}, 376 | }, 377 | }, 378 | { 379 | params: { 380 | schema: z.object({ 381 | person: z 382 | .object({ 383 | name: z.string(), 384 | }) 385 | .nullish() 386 | .default(null), 387 | }), 388 | schemaType: 'input' as const, 389 | }, 390 | expected: { 391 | openApiSchema: { 392 | properties: { 393 | person: { 394 | default: null, 395 | oneOf: [ 396 | { 397 | properties: { 398 | name: { 399 | type: 'string', 400 | }, 401 | }, 402 | required: ['name'], 403 | type: 'object', 404 | }, 405 | { 406 | type: 'null', 407 | }, 408 | ], 409 | }, 410 | }, 411 | type: 'object', 412 | }, 413 | schemaComponents: {}, 414 | }, 415 | }, 416 | ])( 417 | 'should return the correct openapi schema for %$', 418 | ({ params, expected }) => { 419 | const result = zodToOpenApi(params); 420 | expect(result).toEqual(expected); 421 | }, 422 | ); 423 | }); 424 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nestjs-endpoints 2 | 3 | ![PR workflow](https://github.com/rhyek/nestjs-endpoints/actions/workflows/pr.yaml/badge.svg) 4 | 5 | ## Introduction 6 | 7 | **nestjs-endpoints** is a lightweight tool for writing clean, succinct, end-to-end type-safe HTTP APIs with NestJS that encourages the [REPR](https://www.apitemplatepack.com/docs/introduction/repr-pattern/) design pattern, code colocation, and the Single Responsibility Principle. 8 | 9 | It's inspired by the [Fast Endpoints](https://fast-endpoints.com/) .NET library, [tRPC](https://trpc.io/), and Next.js' file-based routing. 10 | 11 | An endpoint can be as simple as this: 12 | 13 | `src/greet.endpoint.ts` 14 | 15 | ```ts 16 | export default endpoint({ 17 | input: z.object({ 18 | name: z.string(), 19 | }), 20 | output: z.string(), 21 | inject: { 22 | helloService: HelloService, 23 | }, 24 | handler: ({ input, helloService }) => helloService.greet(input.name), 25 | }); 26 | ``` 27 | 28 | ```bash 29 | ❯ curl 'http://localhost:3000/greet?name=Satie' 30 | Hello, Satie!% 31 | ``` 32 | 33 | ```ts 34 | // axios client 35 | const greeting = await client.greet({ name: 'Satie' }); 36 | 37 | // react-query client 38 | const { data: greeting, error, status } = useGreet({ name: 'Satie' }); 39 | ``` 40 | 41 | ## Features 42 | 43 | - **Stable:** Produces regular **NestJS Controllers** under the hood. 44 | - **Traditional setup**: Explicitly set HTTP paths and import endpoints like regular controllers. 45 | - **Automatic setup**: 46 | - Scans your project for endpoint files. 47 | - **File-based routing:** Endpoints' HTTP paths are based on their path on disk. 48 | - **Schema validation:** Compile and run-time validation of input and output values using Zod schemas. 49 | - **OpenAPI 3.1.1**: Generates complete spec files while transforming Zod schemas into OpenAPI request/response payload components using `@nestjs/swagger` and [zod-openapi](https://github.com/samchungy/zod-openapi). 50 | - **End-to-end type safety:** Auto-generates `axios` and `@tanstack/react-query` client libraries from the autogenerated OpenAPI specs using [orval](https://orval.dev/). 51 | - **HTTP adapter agnostic:** Works with both Express and Fastify NestJS applications. 52 | - **Supports CommonJS and ESM** 53 | 54 | ## New in v2 55 | 56 | - **Zod v4** 57 | - **zod-openapi v5** 58 | - **OpenAPI 3.1.1** 59 | 60 | ## Requirements 61 | 62 | - Node.js >= 20 63 | - Zod >= 4.1 64 | 65 | ## Installation 66 | 67 | ```bash 68 | npm install nestjs-endpoints @nestjs/swagger zod 69 | ``` 70 | 71 | ## Setup 72 | 73 | You can opt for either an automatic setup with endpoint scanning + file-based routing or a traditional one with manual imports and HTTP paths. You can also use both in a single project. 74 | 75 | ### Option 1. Traditional 76 | 77 | You can import endpoints like regular NestJS controllers. No setup required in this case. 78 | 79 | `src/health-check.ts` 80 | 81 | ```typescript 82 | import { endpoint } from 'nestjs-endpoints'; 83 | 84 | export const healthCheck = endpoint({ 85 | path: '/status/health', 86 | inject: { 87 | health: HealthService, 88 | }, 89 | handler: ({ health }) => health.check(), 90 | }); 91 | ``` 92 | 93 | `src/app.module.ts` 94 | 95 | ```typescript 96 | import { Module } from '@nestjs/common'; 97 | import { healthCheck } from './health-check'; 98 | 99 | @Module({ 100 | controllers: [healthCheck], 101 | providers: [HealthService], 102 | }) 103 | class AppModule {} 104 | ``` 105 | 106 | Endpoint available at `/status/health`. 107 | 108 | ### Option 2. Automatic (file-based routing) 109 | 110 | `src/endpoints/status/health.ts` 111 | 112 | ```typescript 113 | import { endpoint } from 'nestjs-endpoints'; 114 | 115 | export default endpoint({ 116 | inject: { 117 | health: HealthService, 118 | }, 119 | handler: ({ health }) => health.check(), 120 | }); 121 | ``` 122 | 123 | `src/app.module.ts` 124 | 125 | ```typescript 126 | import { EndpointsRouterModule } from 'nestjs-endpoints'; 127 | 128 | @Module({ 129 | imports: [ 130 | // Setup 131 | EndpointsRouterModule.register({ 132 | rootDirectory: './endpoints', 133 | providers: [HealthService], 134 | }), 135 | ], 136 | }) 137 | export class AppModule {} 138 | ``` 139 | 140 | Endpoint available at `/status/health`. 141 | 142 | ### Complex query parameters 143 | 144 | When declaring GET endpoints with complex query parameters (zod input schema with nested object properties), you must additionally [configure](https://docs.nestjs.com/controllers#query-parameters) the Express or Fastify NestJS adapters accordingly. 145 | 146 | ## Usage 147 | 148 | `src/endpoints/user/find.endpoint.ts` 149 | 150 | ```typescript 151 | import { endpoint, z } from 'nestjs-endpoints'; 152 | 153 | export default endpoint({ 154 | input: z.object({ 155 | // GET endpoints use query params for input, 156 | // so we need to coerce the string to a number 157 | id: z.coerce.number(), 158 | }), 159 | output: z 160 | .object({ 161 | id: z.number(), 162 | name: z.string(), 163 | email: z.string().email(), 164 | }) 165 | .nullable(), 166 | inject: { 167 | db: DbService, 168 | }, 169 | injectOnRequest: { 170 | session: decorated(Session()), 171 | } 172 | // The handler's parameters are fully typed, and its 173 | // return value is type-checked against the output schema 174 | handler: async ({ input, db, session }) => { 175 | if (session.isAuthorized()) { 176 | return await db.user.find(input.id); 177 | } 178 | return null; 179 | } 180 | }); 181 | ``` 182 | 183 | `src/endpoints/user/create.endpoint.ts` 184 | 185 | ```typescript 186 | import { endpoint, z } from 'nestjs-endpoints'; 187 | 188 | export default endpoint({ 189 | method: 'post', 190 | input: z.object({ 191 | name: z.string(), 192 | email: z.string().email(), 193 | }), 194 | output: z.object({ 195 | id: z.number(), 196 | }), 197 | inject: { 198 | db: DbService, 199 | }, 200 | handler: async ({ input, db }) => { 201 | const user = await db.user.create(input); 202 | return { 203 | id: user.id, 204 | // Stripped during zod validation 205 | name: user.name, 206 | }; 207 | }, 208 | }); 209 | ``` 210 | 211 | You call the above using: 212 | 213 | ```bash 214 | ❯ curl 'http://localhost:3000/user/find?id=1' 215 | null% 216 | 217 | # bad input 218 | ❯ curl -s -X 'POST' 'http://localhost:3000/user/create' \ 219 | -H 'Content-Type: application/json' \ 220 | -d '{"name": "Art", "emailTYPO": "art@gmail.com"}' | jq 221 | { 222 | "statusCode": 400, 223 | "message": "Validation failed", 224 | "errors": [ 225 | { 226 | "code": "invalid_type", 227 | "expected": "string", 228 | "received": "undefined", 229 | "path": [ 230 | "email" 231 | ], 232 | "message": "Required" 233 | } 234 | ] 235 | } 236 | 237 | # success 238 | ❯ curl -X 'POST' 'http://localhost:3000/user/create' \ 239 | -H 'Content-Type: application/json' \ 240 | -d '{"name": "Art", "email": "art@vandelayindustries.com"}' 241 | {"id":1}% 242 | ``` 243 | 244 | ### File-based routing 245 | 246 | HTTP paths for endpoints are derived from the file's path on disk: 247 | 248 | - `rootDirectory` is trimmed from the start 249 | - Optional `basePath` is prepended 250 | - Path segments that begin with an underscore (`_`) are removed 251 | - Filenames must either end in `.endpoint.ts` or be `endpoint.ts` 252 | - `js`, `cjs`, `mjs`, `mts` are also supported. 253 | - Route parameters are **not** supported (`user/:userId`) 254 | 255 | Examples (assume `rootDirectory` is `./endpoints`): 256 | 257 | - `src/endpoints/user/find-all.endpoint.ts` -> `user/find-all` 258 | - `src/endpoints/user/_mutations/create/endpoint.ts` -> `user/create` 259 | 260 | > _**Note:**_ Bundled projects via Webpack or similar are not supported. 261 | 262 | ## Codegen (optional) 263 | 264 | You can automatically generate a client SDK for your API that can be used in other backend or frontend projects with the benefit of end-to-end type safety. It uses [orval](https://orval.dev/) internally and works with both scanned and manually imported endpoints. 265 | 266 | ### Using `setupCodegen` 267 | 268 | This is the preferred way of configuring codegen with nestjs-endpoints. 269 | 270 | `src/main.ts` 271 | 272 | ```typescript 273 | import { setupCodegen } from 'nestjs-endpoints'; 274 | 275 | async function bootstrap() { 276 | const app = await NestFactory.create(AppModule); 277 | await setupCodegen(app, { 278 | clients: [ 279 | { 280 | type: 'axios', 281 | outputFile: process.cwd() + '/generated/axios-client.ts', 282 | }, 283 | { 284 | type: 'react-query', 285 | outputFile: process.cwd() + '/generated/react-query-client.tsx', 286 | }, 287 | ], 288 | }); 289 | await app.listen(3000); 290 | } 291 | ``` 292 | 293 | ### axios 294 | 295 | ```ts 296 | import { createApiClient } from './generated/axios-client'; 297 | 298 | const client = createApiClient({ 299 | baseURL: process.env.API_BASE_URL, 300 | headers: { 301 | 'x-test': 'test-1', 302 | }, 303 | }); 304 | // Access to axios instance 305 | client.axios.defaults.headers.common['x-test'] = 'test-2'; 306 | 307 | const { id } = await client.userCreate({ 308 | name: 'Tom', 309 | email: 'tom@gmail.com', 310 | }); 311 | ``` 312 | 313 | ### react-query 314 | 315 | ```typescript 316 | import { 317 | ApiClientProvider, 318 | createApiClient, 319 | } from './generated/react-query-client'; 320 | 321 | export function App() { 322 | const queryClient = useMemo(() => new QueryClient({}), []); 323 | const apiClient = useMemo( 324 | () => 325 | createApiClient({ 326 | baseURL: import.meta.env.VITE_API_BASE_URL, 327 | }), 328 | [], 329 | ); 330 | 331 | return ( 332 | 333 | 334 | 335 | 336 | 337 | ); 338 | } 339 | -- 340 | import { 341 | useUserCreate, 342 | useApiClient, 343 | } from './generated/react-query-client'; 344 | 345 | export function UserPage() { 346 | // react-query mutation hook 347 | const userCreate = useUserCreate(); 348 | const handler = () => userCreate.mutateAsync({ ... }); 349 | 350 | // You can also use the api client, directly 351 | const client = useApiClient(); 352 | const handler = () => client.userCreate({ ... }); 353 | ... 354 | } 355 | ``` 356 | 357 | More examples: 358 | 359 | - [axios](https://github.com/rhyek/nestjs-endpoints/blob/f9fc77c0af9439e35e2ed3f26aa3e645795ed44f/packages/test/test-app-express-cjs/test/client.e2e-spec.ts#L15) 360 | - [react-query](https://github.com/rhyek/nestjs-endpoints/tree/main/packages/test/test-react-query-client) 361 | 362 | ### Manual codegen with OpenAPI spec file 363 | 364 | If you just need the OpenAPI spec file or prefer to configure orval or some other tool yourself, you can do the following: 365 | 366 | `src/main.ts` 367 | 368 | ```typescript 369 | import { setupOpenAPI } from 'nestjs-endpoints'; 370 | 371 | async function bootstrap() { 372 | const app = await NestFactory.create(AppModule); 373 | const { document, changed } = await setupOpenAPI(app, { 374 | configure: (builder) => builder.setTitle('My Api'), 375 | outputFile: process.cwd() + '/openapi.json', 376 | }); 377 | if (changed) { 378 | void import('orval').then(({ generate }) => generate()); 379 | } 380 | await app.listen(3000); 381 | } 382 | ``` 383 | 384 | ## Advanced Usage 385 | 386 | When you need access to more of NestJS' features like Interceptors, Guards, access to the request object, etc, or if you'd rather have contained NestJS modules per feature with their own endpoints 387 | and providers, here is a more complete example (view full example [here](https://github.com/rhyek/nestjs-endpoints/tree/main/packages/test/test-app-express-cjs)): 388 | 389 | > _**Note:**_ You are also welcome to use both NestJS Controllers and endpoints in the same project. 390 | 391 | `src/app.module.ts` 392 | 393 | ```typescript 394 | import { Module } from '@nestjs/common'; 395 | import { EndpointsRouterModule } from 'nestjs-endpoints'; 396 | 397 | @Module({ 398 | imports: [ 399 | // Contained user module with endpoints 400 | UserModule, 401 | // Other endpoints 402 | EndpointsRouterModule.register({ 403 | rootDirectory: './endpoints', 404 | providers: [DbService], 405 | }), 406 | ], 407 | controllers: [healthCheck], // Mixed with manual imports 408 | }) 409 | export class AppModule {} 410 | ``` 411 | 412 | `src/user/user.module.ts` 413 | 414 | ```typescript 415 | import { Module } from '@nestjs/common'; 416 | import { EndpointsRouterModule } from 'nestjs-endpoints'; 417 | 418 | @Module({ 419 | imports: [ 420 | EndpointsRouterModule.register({ 421 | rootDirectory: './', 422 | basePath: 'user', 423 | providers: [ 424 | DbService, 425 | { 426 | provide: AppointmentRepositoryToken, 427 | useClass: AppointmentRepository as IAppointmentRepository, 428 | }, 429 | ], 430 | }), 431 | ], 432 | }) 433 | export class UserModule {} 434 | ``` 435 | 436 | `src/user/appointment/_endpoints/create/endpoint.ts` 437 | 438 | ```typescript 439 | import { Inject, Req, UseGuards } from '@nestjs/common'; 440 | import type { Request } from 'express'; 441 | import { decorated, endpoint, schema, z } from 'nestjs-endpoints'; 442 | 443 | export default endpoint({ 444 | method: 'post', 445 | summary: 'Create an appointment', 446 | input: z.object({ 447 | userId: z.number(), 448 | date: z.coerce.date(), 449 | }), 450 | output: { 451 | 201: schema( 452 | z.object({ 453 | id: z.number(), 454 | date: z.date().transform((date) => date.toISOString()), 455 | address: z.string(), 456 | }), 457 | { 458 | description: 'Appointment created', 459 | }, 460 | ), 461 | 400: z.union([ 462 | z.string(), 463 | z.object({ 464 | message: z.string(), 465 | errorCode: z.string(), 466 | }), 467 | ]), 468 | }, 469 | decorators: [UseGuards(AuthGuard)], 470 | inject: { 471 | db: DbService, 472 | appointmentsRepository: decorated( 473 | Inject(AppointmentRepositoryToken), 474 | ), 475 | }, 476 | injectOnRequest: { 477 | req: decorated(Req()), 478 | }, 479 | handler: async ({ 480 | input, 481 | db, 482 | appointmentsRepository, 483 | req, 484 | response, 485 | }) => { 486 | const user = await db.find(input.userId); 487 | if (!user) { 488 | // Need to use response fn when multiple output status codes 489 | // are defined 490 | return response(400, 'User not found'); 491 | } 492 | if (await appointmentsRepository.hasConflict(input.date)) { 493 | return response(400, { 494 | message: 'Appointment has conflict', 495 | errorCode: 'APPOINTMENT_CONFLICT', 496 | }); 497 | } 498 | return response( 499 | 201, 500 | await appointmentsRepository.create( 501 | input.userId, 502 | input.date, 503 | req.ip, 504 | ), 505 | ); 506 | }, 507 | }); 508 | ``` 509 | 510 | To call this endpoint: 511 | 512 | ```bash 513 | ❯ curl -X 'POST' 'http://localhost:3000/user/appointment/create' \ 514 | -H 'Content-Type: application/json' \ 515 | -H 'Authorization: secret' \ 516 | -d '{"userId": 1, "date": "2021-11-03"}' 517 | {"id":1,"date":"2021-11-03T00:00:00.000Z","address":"::1"}% 518 | ``` 519 | 520 | ## Handling ZodEffects in output schemas 521 | 522 | The following configuration will not work: 523 | 524 | ```typescript 525 | export default endpoint({ 526 | ... 527 | output: z.object({ 528 | name: z.string() 529 | .transform((s) => s.toUpperCase()) 530 | }), 531 | ... 532 | }) 533 | ``` 534 | 535 | The `.transform` creates a `ZodEffect` whose output type in some cases cannot be known at run-time (only compile-time), and an OpenApi schema cannot be inferred. More info [here](https://github.com/samchungy/zod-openapi?tab=readme-ov-file#effecttype). 536 | 537 | To fix it, do the following: 538 | 539 | ```typescript 540 | export default endpoint({ 541 | ... 542 | output: z.object({ 543 | name: z.string() 544 | .overwrite((s) => s.toUpperCase()) 545 | }), 546 | ... 547 | }) 548 | ``` 549 | 550 | or 551 | 552 | ```typescript 553 | export default endpoint({ 554 | ... 555 | output: z.object({ 556 | name: z.string() 557 | .transform((s) => s.toUpperCase()) 558 | .meta({ type: 'string' }), 559 | }), 560 | ... 561 | }) 562 | ``` 563 | 564 | ## Testing 565 | 566 | You can write end-to-end or integration tests for your endpoints. 567 | 568 | ### End-to-end tests 569 | 570 | Use either the generated client libraries or regular HTTP requests using `supertest`. 571 | 572 | ```ts 573 | test('client library', async () => { 574 | const moduleFixture: TestingModule = await Test.createTestingModule({ 575 | imports: [AppModule], 576 | }).compile(); 577 | const app = moduleFixture.createNestApplication(); 578 | await app.init(); 579 | await app.listen(0); 580 | const client = createApiClient({ 581 | baseURL: await app.getUrl(), 582 | }); 583 | await expect(client.userFind({ id: 1 })).resolves.toMatchObject({ 584 | data: { 585 | id: 1, 586 | email: 'john@hotmail.com', 587 | }, 588 | }); 589 | }); 590 | 591 | test('supertest', async () => { 592 | const moduleFixture: TestingModule = await Test.createTestingModule({ 593 | imports: [AppModule], 594 | }).compile(); 595 | const app = moduleFixture.createNestApplication(); 596 | await request(app.getHttpServer()) 597 | .get('/user/find?id=1') 598 | .expect(200) 599 | .then((resp) => { 600 | expect(resp.body).toMatchObject({ 601 | id: 1, 602 | email: 'john@hotmail.com', 603 | }); 604 | }); 605 | }); 606 | ``` 607 | 608 | ### Integration tests 609 | 610 | You can also load individual endpoints without having to import your entire application. 611 | 612 | ```ts 613 | import userFindEndpoint from 'src/endpoints/user/find.endpoint'; 614 | 615 | test('integration', async () => { 616 | const moduleFixture: TestingModule = await Test.createTestingModule({ 617 | controllers: [userFindEndpoint], 618 | providers: [DbService], 619 | }).compile(); 620 | const app = moduleFixture.createNestApplication(); 621 | await app.init(); 622 | const userFind = app.get(userFindEndpoint); 623 | await expect(userFind.invoke({ id: 1 })).resolves.toMatchObject({ 624 | id: 1, 625 | email: 'john@hotmail.com', 626 | }); 627 | }); 628 | ``` 629 | -------------------------------------------------------------------------------- /packages/nestjs-endpoints/src/endpoint-fn.ts: -------------------------------------------------------------------------------- 1 | import { 2 | applyDecorators, 3 | Body, 4 | Controller, 5 | Delete, 6 | Get, 7 | Head, 8 | Inject, 9 | Options, 10 | Patch, 11 | Post, 12 | Put, 13 | Query, 14 | Res, 15 | Type, 16 | } from '@nestjs/common'; 17 | import { HttpAdapterHost } from '@nestjs/core'; 18 | import { ApiBody, ApiOperation, ApiResponse } from '@nestjs/swagger'; 19 | import { z, ZodNull, ZodNullable, ZodType } from 'zod'; 20 | import { settings } from './consts'; 21 | import { 22 | ZodSerializationException, 23 | ZodValidationException, 24 | } from './exceptions'; 25 | import { 26 | ApiQueries, 27 | getCallsiteFile, 28 | getEndpointHttpPath, 29 | getHttpPathPascalName, 30 | moduleAls, 31 | } from './helpers'; 32 | import { zodToOpenApi } from './zod-to-openapi'; 33 | 34 | type HttpMethod = 35 | | 'get' 36 | | 'post' 37 | | 'put' 38 | | 'delete' 39 | | 'patch' 40 | | 'head' 41 | | 'options'; 42 | 43 | const httpMethodDecorators = { 44 | get: Get, 45 | post: Post, 46 | put: Put, 47 | delete: Delete, 48 | patch: Patch, 49 | head: Head, 50 | options: Options, 51 | } satisfies Record MethodDecorator>; 52 | 53 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 54 | class WithDecorator<_T> { 55 | constructor(public decorator: PropertyDecorator | ParameterDecorator) {} 56 | } 57 | 58 | export function decorated( 59 | decorator: PropertyDecorator | ParameterDecorator, 60 | ) { 61 | return new WithDecorator(decorator); 62 | } 63 | 64 | type MaybePromise = T | Promise; 65 | 66 | type Schema = ZodType; 67 | 68 | class SchemaDef { 69 | constructor( 70 | public schema: S, 71 | public description: string | undefined, 72 | ) {} 73 | } 74 | type ExtractSchemaFromSchemaDef> = 75 | S extends SchemaDef ? _S : S; 76 | 77 | export function schema( 78 | schema: S, 79 | spec?: { description?: string }, 80 | ) { 81 | return new SchemaDef(schema, spec?.description); 82 | } 83 | type OutputSchemaUnion = 84 | | Schema 85 | | SchemaDef 86 | | Record 87 | | undefined; 88 | 89 | type OutputMap = OS extends undefined 90 | ? never 91 | : OS extends Schema | SchemaDef 92 | ? { 200: ExtractSchemaFromSchemaDef } 93 | : OS extends Record 94 | ? { [k in keyof OS & number]: ExtractSchemaFromSchemaDef } 95 | : never; 96 | 97 | type OutputMapKey = keyof OutputMap & 98 | number; 99 | 100 | type OutputMapValue< 101 | OS extends OutputSchemaUnion, 102 | K extends OutputMapKey, 103 | > = OS extends undefined 104 | ? any 105 | : OutputMap[K] extends Schema 106 | ? z.input[K]> 107 | : never; 108 | 109 | export class EndpointResponse { 110 | constructor( 111 | public status: Status, 112 | public body: Body, 113 | ) {} 114 | } 115 | 116 | type OutputMapResponseUnion = { 117 | [Status in OutputMapKey]: EndpointResponse< 118 | Status, 119 | OutputMapValue 120 | >; 121 | }[keyof OutputMap & number]; 122 | 123 | type HandlerMethod< 124 | InjectProviders extends 125 | | Record | WithDecorator> 126 | | undefined = undefined, 127 | InjectOnRequestParameters extends 128 | | Record> 129 | | undefined = undefined, 130 | InputSchema extends Schema | SchemaDef | undefined = undefined, 131 | OutputSchema extends OutputSchemaUnion = undefined, 132 | > = ( 133 | params: (InjectProviders extends undefined 134 | ? object 135 | : { 136 | [p in keyof InjectProviders]: InjectProviders[p] extends WithDecorator< 137 | infer ParameterType 138 | > 139 | ? ParameterType 140 | : InjectProviders[p] extends Type 141 | ? ParameterType 142 | : never; 143 | }) & 144 | (InjectOnRequestParameters extends undefined 145 | ? object 146 | : { 147 | [p in keyof InjectOnRequestParameters]: InjectOnRequestParameters[p] extends WithDecorator< 148 | infer ParameterType 149 | > 150 | ? ParameterType 151 | : never; 152 | }) & 153 | (InputSchema extends undefined 154 | ? object 155 | : { 156 | input: z.output< 157 | ExtractSchemaFromSchemaDef> 158 | >; 159 | rawInput: unknown; 160 | }) & { 161 | response: < 162 | Status extends OutputMapKey, 163 | Body extends OutputMapValue, 164 | >( 165 | status: Status, 166 | body: Body, 167 | ) => EndpointResponse; 168 | } & { 169 | schemas: object & 170 | (InputSchema extends undefined 171 | ? object 172 | : { 173 | input: ExtractSchemaFromSchemaDef>; 174 | }); 175 | }, 176 | ) => OutputSchema extends undefined 177 | ? MaybePromise 178 | : OutputSchema extends Record 179 | ? MaybePromise> 180 | : OutputSchema extends Schema 181 | ? MaybePromise< 182 | | z.input> 183 | | OutputMapResponseUnion 184 | > 185 | : never; 186 | 187 | type InvokeMethod< 188 | InputSchema extends Schema | SchemaDef | undefined = undefined, 189 | OutputSchema extends OutputSchemaUnion = undefined, 190 | > = InputSchema extends undefined 191 | ? () => MaybePromise> 192 | : ( 193 | rawInput: z.input< 194 | ExtractSchemaFromSchemaDef> 195 | >, 196 | ) => MaybePromise>; 197 | 198 | type EndpointControllerClass< 199 | InjectProviders extends 200 | | Record | WithDecorator> 201 | | undefined = undefined, 202 | InjectOnRequestParameters extends 203 | | Record> 204 | | undefined = undefined, 205 | InputSchema extends Schema | SchemaDef | undefined = undefined, 206 | OutputSchema extends OutputSchemaUnion = undefined, 207 | > = Type<{ 208 | handler: HandlerMethod< 209 | InjectProviders, 210 | InjectOnRequestParameters, 211 | InputSchema, 212 | OutputSchema 213 | >; 214 | /** 215 | * Invoke the endpoint with raw input. Useful for testing. 216 | */ 217 | invoke: InvokeMethod; 218 | }>; 219 | 220 | export function endpoint< 221 | InjectProviders extends 222 | | Record | WithDecorator> 223 | | undefined = undefined, 224 | InjectOnRequestParameters extends 225 | | Record> 226 | | undefined = undefined, 227 | InputSchema extends Schema | SchemaDef | undefined = undefined, 228 | OutputSchema extends OutputSchemaUnion = undefined, 229 | >(params: { 230 | /** 231 | * HTTP method. 232 | * 233 | * @default 'get' 234 | */ 235 | method?: 'get' | 'post' | 'put' | 'delete' | 'patch'; 236 | /** 237 | * HTTP path. By default, inferred from file path. 238 | */ 239 | path?: string; 240 | /** 241 | * OpenAPI endpoint summary. 242 | */ 243 | summary?: string; 244 | /** 245 | * OpenAPI endpoint tags. 246 | */ 247 | tags?: string[]; 248 | /** 249 | * Input Zod schema. 250 | * 251 | * ```ts 252 | * endpoint({ 253 | * input: z.object({ 254 | * name: z.string(), 255 | * }), 256 | * handler: ({ input: { name } }) => {} 257 | * }) 258 | * ``` 259 | */ 260 | input?: InputSchema; 261 | /** 262 | * Output Zod schema. 263 | * 264 | * Cannot use nullable output type, since NestJS will not actually responde with `null`. 265 | * More info: https://github.com/nestjs/nest/issues/10415 266 | * 267 | * ```ts 268 | * endpoint({ 269 | * output: z.object({ 270 | * name: z.string(), 271 | * }), 272 | * handler: () => ({ name: 'John' }) 273 | * }) 274 | * ``` 275 | */ 276 | output?: OutputSchema extends ZodNull | ZodNullable 277 | ? never 278 | : OutputSchema; 279 | /** 280 | * Inject controller providers at class instance level. 281 | * 282 | * ```ts 283 | * // NestJS controller: 284 | * ⁣@Controller() 285 | * class UserController { 286 | * constructor( 287 | * private readonly userService: UserService, 288 | * ) {} 289 | * } 290 | * 291 | * // nestjs-endpoints: 292 | * endpoint({ 293 | * inject: { 294 | * userService: UserService, 295 | * }, 296 | * handler: ({ userService }) => {}, 297 | * }) 298 | * ``` 299 | */ 300 | inject?: InjectProviders; 301 | /** 302 | * Inject parameters at request time (e.g. `@Req()`, `@Session()`). 303 | * 304 | * ```ts 305 | * // NestJS controller: 306 | * ⁣@Controller() 307 | * class UserController { 308 | * ⁣@Get('/user') 309 | * async getUser(@Req() req: Request) {} 310 | * } 311 | * 312 | * // nestjs-endpoints: 313 | * endpoint({ 314 | * injectOnRequest: { 315 | * req: decorated(Req()), 316 | * }, 317 | * handler: async ({ req }) => {}, 318 | * }) 319 | * ``` 320 | */ 321 | injectOnRequest?: InjectOnRequestParameters; 322 | /** 323 | * @deprecated Use `injectOnRequest` instead. 324 | */ 325 | injectMethod?: InjectOnRequestParameters; 326 | /** 327 | * @deprecated Use `injectOnRequest` instead. 328 | */ 329 | injectAtRequest?: InjectOnRequestParameters; 330 | /** 331 | * Method decorators. 332 | * 333 | * ```ts 334 | * // NestJS controller: 335 | * ⁣@Controller() 336 | * class UserController { 337 | * ⁣@UseGuards(AuthGuard) 338 | * ⁣@Get('/user') 339 | * async getUser() {} 340 | * } 341 | * 342 | * // nestjs-endpoints: 343 | * endpoint({ 344 | * decorators: [UseGuards(AuthGuard)], 345 | * handler: async () => {}, 346 | * }) 347 | * ``` 348 | */ 349 | decorators?: MethodDecorator[]; 350 | handler: HandlerMethod< 351 | InjectProviders, 352 | InjectOnRequestParameters, 353 | InputSchema, 354 | OutputSchema 355 | >; 356 | }): EndpointControllerClass< 357 | InjectProviders, 358 | InjectOnRequestParameters, 359 | InputSchema, 360 | OutputSchema 361 | > { 362 | const { 363 | method: httpMethod = 'get', 364 | path: explicitPath, 365 | summary, 366 | tags, 367 | input, 368 | output, 369 | inject, 370 | injectMethod, 371 | injectAtRequest, 372 | decorators, 373 | handler, 374 | } = params; 375 | let { injectOnRequest } = params; 376 | class cls {} 377 | const file = getCallsiteFile(); 378 | const setupFn = ({ 379 | rootDirectory, 380 | basePath, 381 | }: { 382 | rootDirectory: string; 383 | basePath: string; 384 | }) => { 385 | const { httpPath, httpPathPascalName, httpPathSegments } = (() => { 386 | if (explicitPath) { 387 | return { 388 | httpPath: explicitPath, 389 | httpPathSegments: explicitPath.split('/').filter(Boolean), 390 | httpPathPascalName: getHttpPathPascalName(explicitPath), 391 | }; 392 | } 393 | return getEndpointHttpPath(rootDirectory, basePath, file); 394 | })(); 395 | let outputSchemas: Record | null = null; 396 | if (output) { 397 | if ( 398 | output.constructor === Object && 399 | Object.keys(output).length > 0 && 400 | Object.keys(output).every((k) => Number.isInteger(Number(k))) 401 | ) { 402 | outputSchemas = output as any; 403 | } else { 404 | outputSchemas = { 200: output as any }; 405 | } 406 | } 407 | 408 | // class 409 | Object.defineProperty(cls, 'name', { 410 | value: `${httpPathPascalName}Endpoint`, 411 | }); 412 | const controllerDecorator = applyDecorators(Controller(httpPath)); 413 | controllerDecorator(cls); 414 | const httpAdapterHostKey = Symbol('httpAdapterHost'); 415 | Inject(HttpAdapterHost)(cls.prototype, httpAdapterHostKey); 416 | if (inject) { 417 | for (const [key, token] of Object.entries(inject)) { 418 | if (token instanceof WithDecorator) { 419 | (token.decorator as PropertyDecorator)(cls.prototype, key); 420 | } else { 421 | Inject(token)(cls.prototype, key); 422 | } 423 | } 424 | } 425 | 426 | // define method parameters 427 | const inputKey = Symbol('input'); 428 | const resKey = Symbol('res'); 429 | const methodParamDecorators: Record< 430 | string | symbol, 431 | ParameterDecorator 432 | >[] = [{ [resKey]: Res({ passthrough: true }) }]; 433 | if (input) { 434 | methodParamDecorators.push({ 435 | [inputKey]: httpMethod === 'get' ? Query() : Body(), 436 | }); 437 | } 438 | injectOnRequest ??= injectMethod ?? injectAtRequest; 439 | if (injectOnRequest) { 440 | for (const [key, wd] of Object.entries(injectOnRequest)) { 441 | methodParamDecorators.push({ 442 | [key]: wd.decorator as ParameterDecorator, 443 | }); 444 | } 445 | } 446 | 447 | const validateOutput = (endpointResponse: EndpointResponse) => { 448 | if (outputSchemas) { 449 | const schema = outputSchemas[endpointResponse.status]; 450 | if (!schema) { 451 | throw new Error( 452 | `Did not find schema for status code ${endpointResponse.status}`, 453 | ); 454 | } 455 | const s = schema instanceof SchemaDef ? schema.schema : schema; 456 | const parsed = s.safeParse(endpointResponse.body); 457 | if (parsed.error) { 458 | throw new ZodSerializationException(parsed.error); 459 | } 460 | endpointResponse.body = parsed.data; 461 | } 462 | }; 463 | 464 | const response = (s: number, b: any) => new EndpointResponse(s, b); 465 | 466 | const commonHandlerLogic = async function ( 467 | this: any, 468 | handlerParams: Record, 469 | rawInput: any, 470 | ) { 471 | if (inject) { 472 | for (const key of Object.keys(inject)) { 473 | handlerParams[key] = this[key]; 474 | } 475 | } 476 | if (input) { 477 | const schema: ZodType = 478 | input instanceof SchemaDef ? input.schema : input; 479 | const parsed = schema.safeParse(rawInput); 480 | if (parsed.error) { 481 | throw new ZodValidationException(parsed.error); 482 | } 483 | handlerParams.input = parsed.data; 484 | handlerParams.rawInput = rawInput; 485 | handlerParams.schemas.input = schema; 486 | } 487 | // eslint-disable-next-line @typescript-eslint/await-thenable 488 | const result: any = await handler(handlerParams as any); 489 | return result; 490 | }; 491 | 492 | // invoke method 493 | (cls.prototype as any).invoke = async function (rawInput: any) { 494 | const handlerParams: Record = { 495 | response, 496 | schemas: {}, 497 | }; 498 | const result = await commonHandlerLogic.call( 499 | this, 500 | handlerParams, 501 | rawInput, 502 | ); 503 | if (result instanceof EndpointResponse) { 504 | validateOutput(result); 505 | return result; 506 | } else { 507 | const endpointResponse = new EndpointResponse(200, result); 508 | validateOutput(endpointResponse); 509 | return endpointResponse.body; 510 | } 511 | }; 512 | 513 | // handler method 514 | (cls.prototype as any).handler = async function (...params: any[]) { 515 | const injectedMethodParams: Record = 516 | Object.fromEntries( 517 | methodParamDecorators.map((p, i) => { 518 | const key = Reflect.ownKeys(p)[0]; 519 | return [key, params[i]] as const; 520 | }), 521 | ); 522 | const handlerParams: Record = { 523 | response, 524 | schemas: {}, 525 | }; 526 | if (injectOnRequest) { 527 | for (const key of Object.keys(injectOnRequest)) { 528 | handlerParams[key] = injectedMethodParams[key]; 529 | } 530 | } 531 | const rawInput = injectedMethodParams[inputKey]; 532 | const result = await commonHandlerLogic.call( 533 | this, 534 | handlerParams, 535 | rawInput, 536 | ); 537 | let endpointResponse: EndpointResponse; 538 | if (result instanceof EndpointResponse) { 539 | endpointResponse = result; 540 | } else { 541 | endpointResponse = new EndpointResponse(200, result); 542 | } 543 | validateOutput(endpointResponse); 544 | const res = injectedMethodParams[resKey]; 545 | const httpAdapterHost: HttpAdapterHost = this[httpAdapterHostKey]; 546 | const httpAdapter = httpAdapterHost.httpAdapter; 547 | const { status, body } = endpointResponse; 548 | httpAdapter.status(res, status); 549 | if (typeof body !== 'string') { 550 | httpAdapter.setHeader(res, 'Content-Type', 'application/json'); 551 | } 552 | // The following affects middleware that adds headers after controller execution. 553 | // Best to let NestJS do the default. Hence, null outputs are no longer supported. 554 | // http://github.com/nestjs/nest/issues/10415 555 | // if (body === null) { 556 | // httpAdapter.reply(res, JSON.stringify(null)); 557 | // return; 558 | // } 559 | return body; 560 | }; 561 | // configure method parameters 562 | for (let i = 0; i < methodParamDecorators.length; i++) { 563 | const paramDecorator = methodParamDecorators[i]; 564 | const key = Reflect.ownKeys(paramDecorator)[0]; 565 | const decorator = paramDecorator[key]; 566 | decorator(cls.prototype, 'handler', i); 567 | } 568 | 569 | // method 570 | const _tags: string[] = []; 571 | for (let i = 0; i < httpPathSegments.length - 1; i++) { 572 | const tag = httpPathSegments.slice(0, i + 1).join('/'); 573 | _tags.push(tag); 574 | } 575 | const methodDecorators: MethodDecorator[] = [ 576 | ApiOperation({ 577 | operationId: httpPathPascalName, 578 | tags: [..._tags, ...(tags ?? [])], 579 | summary: summary ?? '', 580 | }), 581 | httpMethodDecorators[httpMethod](''), 582 | ...(decorators ?? []), 583 | ]; 584 | if (input) { 585 | const schema: ZodType = 586 | input instanceof SchemaDef ? input.schema : input; 587 | const schemaName = httpPathPascalName + 'Input'; 588 | if (httpMethod === 'get') { 589 | methodDecorators.push(ApiQueries(schema as any)); 590 | } else { 591 | const { openApiSchema } = zodToOpenApi({ 592 | schema, 593 | schemaType: 'input', 594 | ref: schemaName, 595 | }); 596 | methodDecorators.push(ApiBody({ schema: openApiSchema })); 597 | } 598 | } 599 | if (outputSchemas) { 600 | for (const [status, schema] of Object.entries(outputSchemas)) { 601 | const s: ZodType = 602 | schema instanceof SchemaDef ? schema.schema : schema; 603 | const schemaName = 604 | httpPathPascalName + 605 | `${status === '200' ? '' : status}` + 606 | 'Output'; 607 | const { openApiSchema } = zodToOpenApi({ 608 | schema: s, 609 | schemaType: 'output', 610 | ref: schemaName, 611 | }); 612 | methodDecorators.push( 613 | ApiResponse({ 614 | status: Number(status), 615 | schema: openApiSchema, 616 | description: 617 | schema instanceof SchemaDef ? schema.description : undefined, 618 | }), 619 | ); 620 | } 621 | } 622 | const methodDecorator = applyDecorators(...methodDecorators); 623 | const descriptor = Object.getOwnPropertyDescriptor( 624 | cls.prototype, 625 | 'handler', 626 | ); 627 | methodDecorator(cls.prototype, 'handler', descriptor); 628 | Reflect.defineMetadata('endpoints:path', httpPath, cls); 629 | }; 630 | if (moduleAls.getStore()) { 631 | settings.endpoints.push({ 632 | file, 633 | setupFn, 634 | }); 635 | } else { 636 | setupFn({ rootDirectory: process.cwd(), basePath: '' }); 637 | } 638 | 639 | return cls as any; 640 | } 641 | -------------------------------------------------------------------------------- /packages/test/test-app-express-cjs/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Writable } from 'node:stream'; 2 | import { ArgumentsHost, Catch } from '@nestjs/common'; 3 | import { APP_FILTER, BaseExceptionFilter } from '@nestjs/core'; 4 | import { Test, TestingModule } from '@nestjs/testing'; 5 | import { 6 | setupOpenAPI, 7 | ZodSerializationException, 8 | ZodValidationException, 9 | } from 'nestjs-endpoints'; 10 | import request from 'supertest'; 11 | import { AppModule } from '../src/app.module'; 12 | import { AppointmentRepositoryToken } from '../src/endpoints/user/appointment/appointment-repository.interface'; 13 | import { AppointmentRepository } from '../src/endpoints/user/appointment/appointment.repository'; 14 | import { UserService } from '../src/endpoints/user/user.service'; 15 | import { createApp } from './create-app'; 16 | 17 | describe('api', { concurrent: true }, () => { 18 | async function setup() { 19 | const validationExceptionSpy = vitest.fn(); 20 | const serializationExceptionSpy = vitest.fn(); 21 | 22 | @Catch(ZodValidationException) 23 | class ZodValidationExceptionFilter extends BaseExceptionFilter { 24 | catch(exception: ZodValidationException, host: ArgumentsHost) { 25 | validationExceptionSpy(exception); 26 | super.catch(exception, host); 27 | } 28 | } 29 | 30 | @Catch(ZodSerializationException) 31 | class ZodSerializationExceptionFilter extends BaseExceptionFilter { 32 | catch(exception: ZodSerializationException, host: ArgumentsHost) { 33 | serializationExceptionSpy(exception); 34 | super.catch(exception, host); 35 | } 36 | } 37 | 38 | const moduleFixture: TestingModule = await Test.createTestingModule({ 39 | imports: [AppModule], 40 | providers: [ 41 | { 42 | provide: APP_FILTER, 43 | useClass: ZodValidationExceptionFilter, 44 | }, 45 | { 46 | provide: APP_FILTER, 47 | useClass: ZodSerializationExceptionFilter, 48 | }, 49 | ], 50 | }).compile(); 51 | 52 | const { app, httpAdapter } = await createApp(moduleFixture); 53 | app.useLogger(false); 54 | 55 | const userService = app.get(UserService); 56 | const appointmentsRepository = app.get( 57 | AppointmentRepositoryToken, 58 | ); 59 | const req = request(app.getHttpServer()); 60 | 61 | return { 62 | app, 63 | httpAdapter, 64 | req, 65 | userService, 66 | appointmentsRepository, 67 | validationExceptionSpy, 68 | serializationExceptionSpy, 69 | }; 70 | } 71 | 72 | test('error endpoint throws', async () => { 73 | const { req, validationExceptionSpy, serializationExceptionSpy } = 74 | await setup(); 75 | await req.get('/test/error').expect(500, { 76 | statusCode: 500, 77 | message: 'Internal server error', 78 | }); 79 | expect(validationExceptionSpy).toHaveBeenCalledTimes(0); 80 | expect(serializationExceptionSpy).toHaveBeenCalledTimes(0); 81 | }); 82 | 83 | test('user get inexistent user returns 404', async () => { 84 | const { req } = await setup(); 85 | await req.get('/user/get?id=1').expect(404, { 86 | statusCode: 404, 87 | message: 'User not found', 88 | error: 'Not Found', 89 | }); 90 | }); 91 | 92 | test('user find input validation throws', async () => { 93 | const { req, validationExceptionSpy, serializationExceptionSpy } = 94 | await setup(); 95 | validationExceptionSpy.mockImplementationOnce((exception) => { 96 | expect(exception).toBeInstanceOf(ZodValidationException); 97 | expect(exception.message).toBe('Validation failed'); 98 | expect(exception.getZodError().issues).toMatchObject([ 99 | { 100 | expected: 'number', 101 | code: 'invalid_type', 102 | received: 'NaN', 103 | path: ['id'], 104 | message: 'Invalid input: expected number, received NaN', 105 | }, 106 | ]); 107 | }); 108 | await req.get('/user/find').expect(400, { 109 | statusCode: 400, 110 | message: 'Validation failed', 111 | errors: [ 112 | { 113 | expected: 'number', 114 | code: 'invalid_type', 115 | received: 'NaN', 116 | path: ['id'], 117 | message: 'Invalid input: expected number, received NaN', 118 | }, 119 | ], 120 | }); 121 | expect(validationExceptionSpy).toHaveBeenCalledTimes(1); 122 | expect(serializationExceptionSpy).toHaveBeenCalledTimes(0); 123 | }); 124 | 125 | test('user find can return null value', async () => { 126 | const { req, httpAdapter } = await setup(); 127 | // await req.get('/user/find?id=1').expect(200, null); 128 | // http://github.com/nestjs/nest/issues/10415 129 | await req 130 | .get('/user/find?id=1') 131 | .expect(200) 132 | .then((resp) => { 133 | expect(resp.body).toBe(httpAdapter === 'express' ? '' : null); 134 | }); 135 | }); 136 | 137 | test('can return created user', async () => { 138 | const { req } = await setup(); 139 | await req 140 | .post('/user/create') 141 | .send({ name: 'John', email: 'john@example.com' }) 142 | .expect(200, { id: 1 }); 143 | await req.get('/user/find?id=1').expect(200, { 144 | id: 1, 145 | name: 'John', 146 | email: 'john@example.com', 147 | }); 148 | }); 149 | 150 | test('user create input validation throws', async () => { 151 | const { req, validationExceptionSpy, serializationExceptionSpy } = 152 | await setup(); 153 | await req 154 | .post('/user/create') 155 | .send({ namez: 'John', email: 'john@example.com' }) 156 | .expect(400, { 157 | statusCode: 400, 158 | message: 'Validation failed', 159 | errors: [ 160 | { 161 | expected: 'string', 162 | code: 'invalid_type', 163 | path: ['name'], 164 | message: 'Invalid input: expected string, received undefined', 165 | }, 166 | ], 167 | }); 168 | expect(validationExceptionSpy).toHaveBeenCalledTimes(1); 169 | expect(serializationExceptionSpy).toHaveBeenCalledTimes(0); 170 | }); 171 | 172 | test('user find output validation throws ZodSerializationException', async () => { 173 | const { 174 | req, 175 | userService, 176 | validationExceptionSpy, 177 | serializationExceptionSpy, 178 | } = await setup(); 179 | vitest.spyOn(userService, 'find').mockReturnValueOnce({ 180 | id: 1, 181 | namez: 'John', 182 | email: 'john@example.com', 183 | } as any); 184 | let exception: any; 185 | serializationExceptionSpy.mockImplementation((_exception) => { 186 | exception = _exception; 187 | }); 188 | await req.get('/user/find?id=1').expect(500); 189 | expect(validationExceptionSpy).toHaveBeenCalledTimes(0); 190 | expect(serializationExceptionSpy).toHaveBeenCalledTimes(1); 191 | expect(exception).toBeInstanceOf(ZodSerializationException); 192 | expect(exception.message).toBe('Internal Server Error'); 193 | expect( 194 | (exception as ZodSerializationException).getZodError().issues, 195 | ).toMatchObject([ 196 | { 197 | expected: 'string', 198 | code: 'invalid_type', 199 | path: ['name'], 200 | message: 'Invalid input: expected string, received undefined', 201 | }, 202 | ]); 203 | }); 204 | 205 | test('appointment create requires auth', async () => { 206 | const { req } = await setup(); 207 | await req 208 | .post('/user/appointment/create') 209 | .send({ 210 | userId: 1, 211 | date: new Date().toISOString(), 212 | }) 213 | .expect(401); 214 | }); 215 | 216 | test('appointment create input validation throws', async () => { 217 | const { req, validationExceptionSpy, serializationExceptionSpy } = 218 | await setup(); 219 | await req 220 | .post('/user/appointment/create') 221 | .set('Authorization', 'secret') 222 | .send({ 223 | userId: 1, 224 | }) 225 | .expect(400, { 226 | statusCode: 400, 227 | message: 'Validation failed', 228 | errors: [ 229 | { 230 | expected: 'date', 231 | code: 'invalid_type', 232 | received: 'Invalid Date', 233 | path: ['date'], 234 | message: 'Invalid input: expected date, received Date', 235 | }, 236 | ], 237 | }); 238 | expect(validationExceptionSpy).toHaveBeenCalledTimes(1); 239 | expect(serializationExceptionSpy).toHaveBeenCalledTimes(0); 240 | }); 241 | 242 | test('appointment create can return 400 with first schema of union', async () => { 243 | const { req, validationExceptionSpy, serializationExceptionSpy } = 244 | await setup(); 245 | const date1 = new Date(); 246 | await req 247 | .post('/user/appointment/create') 248 | .set('Authorization', 'secret') 249 | .send({ 250 | userId: 2, 251 | date: date1.toISOString(), 252 | }) 253 | .expect(400, 'User not found'); 254 | expect(validationExceptionSpy).toHaveBeenCalledTimes(0); 255 | expect(serializationExceptionSpy).toHaveBeenCalledTimes(0); 256 | }); 257 | 258 | test('appointment create can return 200 with only schema', async () => { 259 | const { req } = await setup(); 260 | await req 261 | .post('/user/create') 262 | .send({ 263 | name: 'John', 264 | email: 'john@example.com', 265 | }) 266 | .expect(200); 267 | const date = new Date(2025, 3, 7); 268 | const data = await req 269 | .post('/user/appointment/create') 270 | .set('Authorization', 'secret') 271 | .send({ 272 | userId: 1, 273 | date: date.toISOString(), 274 | }) 275 | .expect(201); 276 | expect(data.body).toMatchObject({ 277 | id: 1, 278 | date: date.toISOString(), 279 | address: expect.stringContaining('127.0.0.1'), 280 | }); 281 | }); 282 | 283 | test('appointment create can return 400 with second schema of union', async () => { 284 | const { req, validationExceptionSpy, serializationExceptionSpy } = 285 | await setup(); 286 | await req 287 | .post('/user/create') 288 | .send({ 289 | name: 'John', 290 | email: 'john@example.com', 291 | }) 292 | .expect(200); 293 | const date = new Date(2025, 3, 7); 294 | await req 295 | .post('/user/appointment/create') 296 | .set('Authorization', 'secret') 297 | .send({ 298 | userId: 1, 299 | date: date.toISOString(), 300 | }) 301 | .expect(201); 302 | await req 303 | .post('/user/appointment/create') 304 | .set('Authorization', 'secret') 305 | .send({ 306 | userId: 1, 307 | date: date.toISOString(), 308 | }) 309 | .expect(400, { 310 | message: 'Appointment has conflict', 311 | errorCode: 'APPOINTMENT_CONFLICT', 312 | }); 313 | expect(validationExceptionSpy).toHaveBeenCalledTimes(0); 314 | expect(serializationExceptionSpy).toHaveBeenCalledTimes(0); 315 | }); 316 | 317 | test('appointment create output validation throws ZodSerializationException', async () => { 318 | const { 319 | req, 320 | appointmentsRepository, 321 | validationExceptionSpy, 322 | serializationExceptionSpy, 323 | } = await setup(); 324 | await req 325 | .post('/user/create') 326 | .send({ 327 | name: 'John', 328 | email: 'john@example.com', 329 | }) 330 | .expect(200); 331 | const date = new Date(); 332 | vitest.spyOn(appointmentsRepository, 'create').mockReturnValueOnce({ 333 | id: 1, 334 | userId: 1, 335 | date: date.toISOString(), // should fail since zod output schema expects a Date 336 | address: '127.0.0.1', 337 | } as any); 338 | let exception: any; 339 | serializationExceptionSpy.mockImplementationOnce((_exception) => { 340 | exception = _exception; 341 | }); 342 | await req 343 | .post('/user/appointment/create') 344 | .set('Authorization', 'secret') 345 | .send({ 346 | userId: 1, 347 | date: date.toISOString(), 348 | }) 349 | .expect(500, { 350 | statusCode: 500, 351 | message: 'Internal Server Error', 352 | }); 353 | expect(validationExceptionSpy).toHaveBeenCalledTimes(0); 354 | expect(serializationExceptionSpy).toHaveBeenCalledTimes(1); 355 | expect(exception).toBeInstanceOf(ZodSerializationException); 356 | expect( 357 | (exception as ZodSerializationException).getZodError().issues, 358 | ).toMatchObject([ 359 | { 360 | expected: 'date', 361 | code: 'invalid_type', 362 | path: ['date'], 363 | message: 'Invalid input: expected date, received string', 364 | }, 365 | ]); 366 | }); 367 | 368 | test('appointment count returns number', async () => { 369 | const { req } = await setup(); 370 | await req 371 | .get('/user/appointment/count?userId=1') 372 | .expect('Content-Type', /json/) 373 | .expect(200) 374 | .expect((resp) => { 375 | expect(resp.body).toBe(0); 376 | }); 377 | await req 378 | .post('/user/create') 379 | .send({ 380 | name: 'John', 381 | email: 'john@example.com', 382 | }) 383 | .expect(200); 384 | await req 385 | .post('/user/appointment/create') 386 | .set('Authorization', 'secret') 387 | .send({ 388 | userId: 1, 389 | date: new Date().toISOString(), 390 | }) 391 | .expect(201); 392 | await req 393 | .get('/user/appointment/count?userId=1') 394 | .expect('Content-Type', /json/) 395 | .expect(200) 396 | .expect((resp) => { 397 | expect(resp.body).toBe(1); 398 | }); 399 | }); 400 | 401 | test('can access input schema in handler', async () => { 402 | const { req } = await setup(); 403 | await req 404 | .post('/user/create') 405 | .send({ name: 'error', email: 'john@example.com' }) 406 | .expect(400, { 407 | statusCode: 400, 408 | message: 'Validation failed', 409 | errors: [ 410 | { 411 | code: 'custom', 412 | message: 'The name triggered me', 413 | path: ['name'], 414 | }, 415 | ], 416 | }); 417 | }); 418 | 419 | test('can override a provider', async () => { 420 | const moduleFixture: TestingModule = await Test.createTestingModule({ 421 | imports: [AppModule], 422 | }) 423 | .overrideProvider(UserService) 424 | .useValue({ 425 | find: () => ({ 426 | id: 34, 427 | name: 'John', 428 | email: 'john@hotmail.com', 429 | }), 430 | }) 431 | .compile(); 432 | const { app } = await createApp(moduleFixture); 433 | app.useLogger(false); 434 | const req = request(app.getHttpServer()); 435 | await req.get('/user/find?id=1').expect(200, { 436 | id: 34, 437 | name: 'John', 438 | email: 'john@hotmail.com', 439 | }); 440 | await req 441 | .get('/user/find?id=1') 442 | .expect(200) 443 | .then((resp) => { 444 | expect(resp.body).toMatchObject({ 445 | id: 34, 446 | email: 'john@hotmail.com', 447 | }); 448 | }); 449 | }); 450 | 451 | test('can access auth endpoints', async () => { 452 | const { req } = await setup(); 453 | await req 454 | .post('/auth/login') 455 | .send({ 456 | email: 'john@example.com', 457 | password: 'password', 458 | }) 459 | .expect(200) 460 | .then((resp) => { 461 | expect(resp.body).toMatchObject({ 462 | token: expect.any(String), 463 | }); 464 | }); 465 | }); 466 | 467 | test('greet endpoint returns greeting', async () => { 468 | const { req } = await setup(); 469 | await req 470 | .get('/greet') 471 | .query({ name: 'John' }) 472 | .expect(200, 'Hello, John!'); 473 | }); 474 | 475 | test('greet endpoint input validation throws', async () => { 476 | const { req, validationExceptionSpy, serializationExceptionSpy } = 477 | await setup(); 478 | await req 479 | .get('/greet') 480 | .query({ wrongField: 'John' }) 481 | .expect(400, { 482 | statusCode: 400, 483 | message: 'Validation failed', 484 | errors: [ 485 | { 486 | expected: 'string', 487 | code: 'invalid_type', 488 | path: ['name'], 489 | message: 'Invalid input: expected string, received undefined', 490 | }, 491 | ], 492 | }); 493 | expect(validationExceptionSpy).toHaveBeenCalledTimes(1); 494 | expect(serializationExceptionSpy).toHaveBeenCalledTimes(0); 495 | }); 496 | 497 | test('complex query parameters work', async () => { 498 | const { req } = await setup(); 499 | await req 500 | .get('/complex-get') 501 | .query({ 'add[a]': 5, 'add[b]': 7 }) 502 | .expect(200) 503 | .then((resp) => { 504 | expect(resp.body).toBe(12); 505 | }); 506 | }); 507 | }); 508 | 509 | test('spec works', async () => { 510 | const moduleFixture: TestingModule = await Test.createTestingModule({ 511 | imports: [AppModule], 512 | }).compile(); 513 | 514 | const app = moduleFixture.createNestApplication(); 515 | app.useLogger(false); 516 | await app.init(); 517 | 518 | let spec = ''; 519 | const stream = new Writable({ 520 | write(chunk, _, callback) { 521 | spec += chunk.toString(); 522 | callback(); 523 | }, 524 | }); 525 | await setupOpenAPI(app, { 526 | configure: (builder) => builder.setTitle('Test Api'), 527 | outputFile: stream, 528 | }); 529 | 530 | expect(spec).toBeTruthy(); 531 | const parsed = JSON.parse(spec); 532 | expect(parsed).toMatchObject({ 533 | openapi: '3.0.0', 534 | paths: { 535 | '/auth/login': { 536 | post: { 537 | operationId: 'AuthLogin', 538 | parameters: [], 539 | requestBody: { 540 | required: true, 541 | content: { 542 | 'application/json': { 543 | schema: { 544 | $ref: '#/components/schemas/AuthLoginInput', 545 | }, 546 | }, 547 | }, 548 | }, 549 | responses: { 550 | '200': { 551 | description: '', 552 | content: { 553 | 'application/json': { 554 | schema: { 555 | $ref: '#/components/schemas/AuthLoginOutput', 556 | }, 557 | }, 558 | }, 559 | }, 560 | }, 561 | summary: '', 562 | tags: ['auth'], 563 | }, 564 | }, 565 | '/user/create': { 566 | post: { 567 | operationId: 'UserCreate', 568 | summary: '', 569 | tags: ['user'], 570 | parameters: [], 571 | requestBody: { 572 | required: true, 573 | content: { 574 | 'application/json': { 575 | schema: { 576 | $ref: '#/components/schemas/UserCreateInput', 577 | }, 578 | }, 579 | }, 580 | }, 581 | responses: { 582 | '200': { 583 | description: '', 584 | content: { 585 | 'application/json': { 586 | schema: { 587 | $ref: '#/components/schemas/UserCreateOutput', 588 | }, 589 | }, 590 | }, 591 | }, 592 | }, 593 | }, 594 | }, 595 | '/user/find': { 596 | get: { 597 | operationId: 'UserFind', 598 | summary: '', 599 | tags: ['user'], 600 | parameters: [ 601 | { 602 | name: 'id', 603 | required: true, 604 | in: 'query', 605 | schema: { 606 | type: 'number', 607 | }, 608 | }, 609 | ], 610 | responses: { 611 | '200': { 612 | description: '', 613 | content: { 614 | 'application/json': { 615 | schema: { 616 | $ref: '#/components/schemas/UserFindOutput', 617 | }, 618 | }, 619 | }, 620 | }, 621 | }, 622 | }, 623 | }, 624 | '/user/appointment/create': { 625 | post: { 626 | operationId: 'UserAppointmentCreate', 627 | summary: 'Create an appointment', 628 | tags: ['user', 'user/appointment'], 629 | parameters: [], 630 | requestBody: { 631 | required: true, 632 | content: { 633 | 'application/json': { 634 | schema: { 635 | $ref: '#/components/schemas/UserAppointmentCreateInput', 636 | }, 637 | }, 638 | }, 639 | }, 640 | responses: { 641 | '201': { 642 | description: 'Appointment created', 643 | content: { 644 | 'application/json': { 645 | schema: { 646 | $ref: '#/components/schemas/UserAppointmentCreate201Output', 647 | }, 648 | }, 649 | }, 650 | }, 651 | '400': { 652 | description: '', 653 | content: { 654 | 'application/json': { 655 | schema: { 656 | $ref: '#/components/schemas/UserAppointmentCreate400Output', 657 | }, 658 | }, 659 | }, 660 | }, 661 | }, 662 | }, 663 | }, 664 | '/user/appointment/count': { 665 | get: { 666 | operationId: 'UserAppointmentCount', 667 | summary: '', 668 | tags: ['user', 'user/appointment'], 669 | parameters: [ 670 | { 671 | name: 'userId', 672 | required: true, 673 | in: 'query', 674 | schema: { 675 | type: 'number', 676 | }, 677 | }, 678 | ], 679 | responses: { 680 | '200': { 681 | description: '', 682 | content: { 683 | 'application/json': { 684 | schema: { 685 | $ref: '#/components/schemas/UserAppointmentCountOutput', 686 | }, 687 | }, 688 | }, 689 | }, 690 | }, 691 | }, 692 | }, 693 | }, 694 | info: { 695 | title: 'Test Api', 696 | description: '', 697 | version: '1.0.0', 698 | contact: {}, 699 | }, 700 | tags: [], 701 | servers: [], 702 | components: { 703 | schemas: { 704 | UserCreateInput: { 705 | type: 'object', 706 | properties: { 707 | name: { 708 | type: 'string', 709 | }, 710 | email: { 711 | type: 'string', 712 | format: 'email', 713 | }, 714 | }, 715 | required: ['name', 'email'], 716 | }, 717 | UserCreateOutput: { 718 | type: 'object', 719 | properties: { 720 | id: { 721 | type: 'number', 722 | }, 723 | }, 724 | required: ['id'], 725 | }, 726 | UserFindOutput: { 727 | oneOf: [ 728 | { 729 | type: 'object', 730 | additionalProperties: false, 731 | properties: { 732 | id: { 733 | type: 'number', 734 | }, 735 | name: { 736 | type: 'string', 737 | }, 738 | email: { 739 | type: 'string', 740 | }, 741 | }, 742 | required: ['id', 'name', 'email'], 743 | }, 744 | { 745 | type: 'null', 746 | }, 747 | ], 748 | }, 749 | UserAppointmentCreateInput: { 750 | type: 'object', 751 | properties: { 752 | userId: { 753 | type: 'number', 754 | }, 755 | date: {}, 756 | }, 757 | required: ['userId', 'date'], 758 | }, 759 | UserAppointmentCreate201Output: { 760 | type: 'object', 761 | properties: { 762 | id: { 763 | type: 'number', 764 | }, 765 | date: {}, 766 | address: { 767 | type: 'string', 768 | }, 769 | }, 770 | required: ['id', 'date', 'address'], 771 | }, 772 | UserAppointmentCreate400Output: { 773 | oneOf: [ 774 | { 775 | type: 'string', 776 | }, 777 | { 778 | type: 'object', 779 | properties: { 780 | message: { 781 | type: 'string', 782 | }, 783 | errorCode: { 784 | type: 'string', 785 | }, 786 | }, 787 | required: ['message', 'errorCode'], 788 | }, 789 | ], 790 | }, 791 | UserAppointmentCountOutput: { 792 | type: 'number', 793 | }, 794 | }, 795 | }, 796 | }); 797 | expect(parsed).toMatchObject({ 798 | paths: { 799 | '/user/list-for-router-with-path': { 800 | get: { 801 | operationId: 'UserListForRouterWithPath', 802 | parameters: [], 803 | responses: { 804 | '200': { 805 | description: '', 806 | content: { 807 | 'application/json': { 808 | schema: { 809 | $ref: '#/components/schemas/UserListForRouterWithPathOutput', 810 | }, 811 | }, 812 | }, 813 | }, 814 | }, 815 | summary: '', 816 | tags: ['user'], 817 | }, 818 | }, 819 | '/src/endpoints/user/list/user-list-no-path': { 820 | get: { 821 | operationId: 'SrcEndpointsUserListUserListNoPath', 822 | parameters: [], 823 | responses: { 824 | '200': { 825 | description: '', 826 | content: { 827 | 'application/json': { 828 | schema: { 829 | $ref: '#/components/schemas/SrcEndpointsUserListUserListNoPathOutput', 830 | }, 831 | }, 832 | }, 833 | }, 834 | }, 835 | summary: '', 836 | tags: [ 837 | 'src', 838 | 'src/endpoints', 839 | 'src/endpoints/user', 840 | 'src/endpoints/user/list', 841 | ], 842 | }, 843 | }, 844 | '/user/list-with-path': { 845 | get: { 846 | operationId: 'UserListWithPath', 847 | parameters: [], 848 | responses: { 849 | '200': { 850 | description: '', 851 | content: { 852 | 'application/json': { 853 | schema: { 854 | $ref: '#/components/schemas/UserListWithPathOutput', 855 | }, 856 | }, 857 | }, 858 | }, 859 | }, 860 | summary: '', 861 | tags: ['user'], 862 | }, 863 | }, 864 | '/user/list-with-path-no-suffix': { 865 | get: { 866 | operationId: 'UserListWithPathNoSuffix', 867 | parameters: [], 868 | responses: { 869 | '200': { 870 | description: '', 871 | content: { 872 | 'application/json': { 873 | schema: { 874 | $ref: '#/components/schemas/UserListWithPathNoSuffixOutput', 875 | }, 876 | }, 877 | }, 878 | }, 879 | }, 880 | summary: '', 881 | tags: ['user'], 882 | }, 883 | }, 884 | }, 885 | }); 886 | }); 887 | --------------------------------------------------------------------------------