How to add a new endpoint

This guide covers the step-by-step process to add a new API endpoint to the Dopamine NestJS application.

Overview

The Dopamine API follows NestJS patterns with:

  • Controllers for HTTP request handling
  • Guards for authentication/authorization
  • Zod schemas for request validation

Game plan

  • Create a new controller and add the module to the AppModule
  • Protect the endpoint with the guard
    • ServerAuthGuard: To ensure only authenticated web app requests can access this endpoint
    • WorkspaceMemberGuard: To ensure only workspace members can access this endpoint
  • Use Decorators to access the data
    • @CurrentUser(): Injects the authenticated user object
    • @CurrentWorkspace(): Injects the current workspace object
  • Use Pipes to validate and transform the data
    • @ZodValidationPipe(): Validates the request with the Zod schema
    • @IdOr404Pipe(): To access the id of the resource or return 404 if not found
  • Create Zod schemas to validate the data

Step 1: Create the Controller and Module

Let's create a Task management system as our concrete example. Start with the basic controller structure:

// apps/api/src/task/task.controller.ts
import { Controller, Get, Post, Put, Delete, Param, Body } from '@nestjs/common';
import { TaskService } from './task.service';
 
@Controller('tasks')
export class TaskController {
  constructor(private readonly taskService: TaskService) {}
 
  // Get a specific task
  @Get('/:id')
  async findOne(@Param('id') id: string) {
    return this.taskService.findOne(id);
  }
 
  // Create a new task
  @Post('/')
  async create(@Body() body: any) {
    return this.taskService.create(body);
  }
 
  // Update task status
  @Put('/:id/status')
  async updateStatus(@Param('id') id: string, @Body() body: any) {
    return this.taskService.updateStatus(id, body.status);
  }
 
  // Delete a task
  @Delete('/:id')
  async delete(@Param('id') id: string) {
    return this.taskService.delete(id);
  }
}

Now create the module and register it:

// apps/api/src/task/task.module.ts
import { Module } from '@nestjs/common';
import { TaskController } from './task.controller';
import { TaskService } from './task.service';
 
@Module({
  controllers: [TaskController],
  providers: [TaskService],
  exports: [TaskService], // If other modules need to use this service
})
export class TaskModule {}

Don't forget to import your Task module in the main AppModule:

// apps/api/src/app.module.ts
import { TaskModule } from './task/task.module';
 
@Module({
  imports: [
    // ... other imports
    TaskModule,
  ],
})
export class AppModule {}

Step 2: Protect the Endpoint with Guards

Add authentication and authorization to secure your endpoints.

Available Guards:

  • ServerAuthGuard -> Primary JWT authentication guard
  • WorkspaceMemberGuard -> Ensures user is a member of the workspace
// apps/api/src/task/task.controller.ts
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { ServerAuthGuard } from '../auth/guards/server-auth.guard';
import { WorkspaceMemberGuard } from '../auth/guards/workspace-member.guard';
import { TaskService } from './task.service';
 
@Controller('tasks')
@UseGuards(ServerAuthGuard) // Apply authentication to all endpoints
export class TaskController {
  constructor(private readonly taskService: TaskService) {}
 
  @Get('/:id')
  @UseGuards(WorkspaceMemberGuard) // Apply workspace membership guard
  async findOne(@Param('id') id: string) {
    return this.taskService.findOne(id);
  }
 
  @Post('/')
  @UseGuards(WorkspaceMemberGuard) // Apply workspace membership guard
  async create(@Body() body: any) {
    return this.taskService.create(body);
  }
 
  @Put('/:id/status')
  @UseGuards(WorkspaceMemberGuard) // Apply workspace membership guard
  async updateStatus(@Param('id') id: string, @Body() body: any) {
    return this.taskService.updateStatus(id, body.status);
  }
 
  @Delete('/:id')
  @UseGuards(WorkspaceMemberGuard) // Apply workspace membership guard
  async delete(@Param('id') id: string) {
    return this.taskService.delete(id);
  }
}

Step 3: Use Decorators to Access Data

Add decorators to inject user and workspace context.

Available Decorators:

  • @CurrentUser() -> Injects the authenticated user object
  • @CurrentWorkspace() -> Injects the current workspace object
// apps/api/src/task/task.controller.ts
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { ServerAuthGuard } from '../auth/guards/server-auth.guard';
import { WorkspaceMemberGuard } from '../auth/guards/workspace-member.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { CurrentWorkspace } from '../auth/decorators/current-workspace.decorator';
import { TaskService } from './task.service';
import type { User, Workspace } from '@repo/shared-models';
 
@Controller('tasks')
@UseGuards(ServerAuthGuard)
export class TaskController {
  constructor(private readonly taskService: TaskService) {}
 
  // Add @CurrentUser() and @CurrentWorkspace() decorators
  @Get('/:id')
  @UseGuards(WorkspaceMemberGuard)
  async findOne(@Param('id') id: string, @CurrentUser() user: User, @CurrentWorkspace() workspace: Workspace) {
    return this.taskService.findOne(id, user.id, workspace.id);
  }
 
  @Post('/')
  @UseGuards(WorkspaceMemberGuard)
  async create(@Body() body: any, @CurrentUser() user: User, @CurrentWorkspace() workspace: Workspace) {
    return this.taskService.create(body, user.id, workspace.id);
  }
 
  @Put('/:id/status')
  @UseGuards(WorkspaceMemberGuard)
  async updateStatus(@Param('id') id: string, @Body() body: any, @CurrentUser() user: User, @CurrentWorkspace() workspace: Workspace) {
    return this.taskService.updateStatus(id, body.status, user.id, workspace.id);
  }
 
  @Delete('/:id')
  @UseGuards(WorkspaceMemberGuard)
  async delete(@Param('id') id: string, @CurrentUser() user: User, @CurrentWorkspace() workspace: Workspace) {
    return this.taskService.delete(id, user.id, workspace.id);
  }
}

Step 4: Create Zod Schemas to Validate Data

Define validation schemas for our Task system in the shared-models package.

// packages/shared-models/src/task/schemas.ts
import { z } from 'zod';
 
// Enums
export const TaskStatus = z.enum(['TODO', 'DOING', 'DONE']);
 
// Create Task Schema
export const createTaskSchema = z.object({
  title: z.string().min(1, 'Title is required').max(200, 'Title too long'),
  description: z.string().max(1000, 'Description too long').optional(),
  status: TaskStatus.default('TODO'),
});
 
// Update Status Schema
export const updateTaskStatusSchema = z.object({
  status: TaskStatus,
});
 
// Types
export type CreateTaskDto = z.infer<typeof createTaskSchema>;
export type UpdateTaskStatusDto = z.infer<typeof updateTaskStatusSchema>;
export type TaskStatusType = z.infer<typeof TaskStatus>;

Step 5: Use Pipes to Validate and Transform Data

Now add the pipes to validate and transform the data with our Zod schemas.

Available Pipes:

  • ZodValidationPipe -> Validates request data against Zod schemas
  • IdOr404Pipe -> Transforms and validates resource IDs, returns 404 if not found
// apps/api/src/task/task.controller.ts
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { ServerAuthGuard } from '../auth/guards/server-auth.guard';
import { WorkspaceMemberGuard } from '../auth/guards/workspace-member.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { CurrentWorkspace } from '../auth/decorators/current-workspace.decorator';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { IdOr404Pipe } from '../common/pipes/id-or-404.pipe';
import { TaskService } from './task.service';
import { createTaskSchema, updateTaskStatusSchema } from '@repo/shared-models';
import type { CreateTaskDto, UpdateTaskStatusDto, User, Workspace } from '@repo/shared-models';
 
@Controller('tasks')
@UseGuards(ServerAuthGuard)
export class TaskController {
  constructor(private readonly taskService: TaskService) {}
 
  // Get a specific task (with ID validation)
  @Get('/:id')
  @UseGuards(WorkspaceMemberGuard)
  async findOne(@Param('id', IdOr404Pipe) id: string, @CurrentUser() user: User, @CurrentWorkspace() workspace: Workspace) {
    return this.taskService.findOne(id, user.id, workspace.id);
  }
 
  // Create a new task (with request validation)
  @Post('/')
  @UseGuards(WorkspaceMemberGuard)
  async create(@Body(new ZodValidationPipe(createTaskSchema)) body: CreateTaskDto, @CurrentUser() user: User, @CurrentWorkspace() workspace: Workspace) {
    return this.taskService.create(body, user.id, workspace.id);
  }
 
  // Update task status (with ID and body validation)
  @Put('/:id/status')
  @UseGuards(WorkspaceMemberGuard)
  async updateStatus(
    @Param('id', IdOr404Pipe) id: string,
    @Body(new ZodValidationPipe(updateTaskStatusSchema)) body: UpdateTaskStatusDto,
    @CurrentUser() user: User,
    @CurrentWorkspace() workspace: Workspace
  ) {
    return this.taskService.updateStatus(id, body.status, user.id, workspace.id);
  }
 
  // Delete a task (with ID validation)
  @Delete('/:id')
  @UseGuards(WorkspaceMemberGuard)
  async delete(@Param('id', IdOr404Pipe) id: string, @CurrentUser() user: User, @CurrentWorkspace() workspace: Workspace) {
    return this.taskService.delete(id, user.id, workspace.id);
  }
}

Conclusion

This complete Task management example demonstrates all the key patterns for building secure, well-structured endpoints in the Dopamine API, including authentication, workspace context, validation and business logic.