Appearance
Adding an API Module
This guide walks through adding a new feature module to the NestJS API.
1. Create the Module Directory
api/src/<module>/
├── <module>.module.ts
├── <module>.controller.ts
├── <module>.service.ts
├── <module>.service.spec.ts
└── dto/
└── <name>.dto.tsEntities live in the
shared/package (shared/src/entities/) so the API, data-fetching, and data-processing services can all import them via@gis-data/shared.
2. Define the Entity
Create shared/src/entities/<name>.entity.ts and add a re-export to shared/src/index.ts:
typescript
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
@Entity('<table_name>')
export class MyEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'boolean', default: true })
isActive: boolean;
@Index()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}For PostGIS geometry columns:
typescript
interface GeoPoint {
type: 'Point';
coordinates: [number, number];
}
@Index({ spatial: true })
@Column({
type: 'geometry',
spatialFeatureType: 'Point',
srid: 4326,
})
geom: GeoPoint;3. Create DTOs
Create api/src/<module>/dto/<name>.dto.ts:
typescript
import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class MyQueryDto {
@ApiPropertyOptional({ description: 'Iskalni niz' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ description: 'Stran', example: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ description: 'Število na stran', example: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Type(() => Number)
limit?: number;
}
export class CreateMyDto {
@ApiPropertyOptional({ description: 'Ime' })
@IsString()
name: string;
@ApiPropertyOptional({ description: 'Opis' })
@IsOptional()
@IsString()
description?: string;
}Remember:
- Swagger descriptions in Slovenian
- Use
@Type(() => Number)for numeric query params - Use
@IsOptional()for optional fields
4. Create the Service
Create api/src/<module>/<module>.service.ts:
typescript
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MyEntity } from './entities/<name>.entity';
import { MyQueryDto, CreateMyDto } from './dto/<name>.dto';
@Injectable()
export class MyService {
constructor(
@InjectRepository(MyEntity)
private readonly myRepository: Repository<MyEntity>,
) {}
async findAll(query: MyQueryDto) {
const page = query.page || 1;
const limit = query.limit || 20;
const qb = this.myRepository.createQueryBuilder('e');
if (query.search) {
qb.andWhere('e.name ILIKE :search', { search: `%${query.search}%` });
}
qb.orderBy('e.createdAt', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [data, total] = await qb.getManyAndCount();
return { data, total, page, limit };
}
async findOne(id: string) {
const entity = await this.myRepository.findOne({ where: { id } });
if (!entity) {
throw new NotFoundException(`Entity with id ${id} not found`);
}
return entity;
}
async create(dto: CreateMyDto) {
const entity = this.myRepository.create(dto);
return this.myRepository.save(entity);
}
}5. Create the Controller
Create api/src/<module>/<module>.controller.ts:
typescript
import { Controller, Get, Post, Body, Param, Query, ParseUUIDPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiNotFoundResponse } from '@nestjs/swagger';
import { MyService } from './<module>.service';
import { MyQueryDto, CreateMyDto } from './dto/<name>.dto';
import { Public } from '../auth/decorators/public.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
@ApiTags('My Module')
@Controller('<module>')
export class MyController {
constructor(private readonly myService: MyService) {}
@Get()
@Public()
@ApiOperation({ summary: 'Vrni seznam elementov' })
findAll(@Query() query: MyQueryDto) {
return this.myService.findAll(query);
}
@Get(':id')
@Public()
@ApiOperation({ summary: 'Vrni podrobnosti elementa' })
@ApiNotFoundResponse({ description: 'Element ni najden' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.myService.findOne(id);
}
@Post()
@Roles('admin', 'superadmin')
@ApiOperation({ summary: 'Ustvari nov element' })
create(@Body() dto: CreateMyDto) {
return this.myService.create(dto);
}
}6. Create the Module
Create api/src/<module>/<module>.module.ts:
typescript
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MyEntity } from '@gis-data/shared';
import { MyController } from './<module>.controller';
import { MyService } from './<module>.service';
@Module({
imports: [TypeOrmModule.forFeature([MyEntity])],
controllers: [MyController],
providers: [MyService],
})
export class MyModule {}7. Register in AppModule
Edit api/src/app.module.ts:
typescript
import { MyModule } from './<module>/<module>.module';
@Module({
imports: [
// ... existing modules
MyModule,
],
})
export class AppModule {}Since autoLoadEntities: true is set, the entity will be automatically discovered.
8. Create a Migration
Generate a migration for the new entity (run from the project root):
bash
pnpm migration:generate migrations/<MigrationName>Then run it:
bash
pnpm migration:run9. Write Tests
Unit Test
Create api/src/<module>/<module>.service.spec.ts:
typescript
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException } from '@nestjs/common';
import { MyService } from './<module>.service';
import { MyEntity } from '@gis-data/shared';
function createMockQueryBuilder(result: any = []) {
const qb: Record<string, jest.Mock> = {};
qb.andWhere = jest.fn().mockReturnValue(qb);
qb.orderBy = jest.fn().mockReturnValue(qb);
qb.skip = jest.fn().mockReturnValue(qb);
qb.take = jest.fn().mockReturnValue(qb);
qb.getManyAndCount = jest.fn().mockResolvedValue([result, result.length]);
return qb;
}
describe('MyService', () => {
let service: MyService;
let mockRepository: Record<string, jest.Mock>;
beforeEach(async () => {
mockRepository = {
createQueryBuilder: jest.fn().mockReturnValue(createMockQueryBuilder()),
findOne: jest.fn(),
create: jest.fn((dto) => dto),
save: jest.fn((entity) => ({ id: 'new-uuid', ...entity })),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
MyService,
{ provide: getRepositoryToken(MyEntity), useValue: mockRepository },
],
}).compile();
service = module.get(MyService);
});
it('should return paginated results', async () => {
const result = await service.findAll({});
expect(result).toHaveProperty('data');
expect(result).toHaveProperty('total');
});
it('should throw NotFoundException for missing entity', async () => {
mockRepository.findOne.mockResolvedValue(null);
await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException);
});
});E2E Test
Create api/test/<module>.e2e-spec.ts:
typescript
import * as request from 'supertest';
import { INestApplication } from '@nestjs/common';
import { createTestApp, getDataSource, loginAsUser } from './setup-e2e';
import { DataSource } from 'typeorm';
describe('My Module (e2e)', () => {
let app: INestApplication;
let ds: DataSource;
beforeAll(async () => {
app = await createTestApp();
ds = getDataSource(app);
});
afterAll(async () => {
if (app) await app.close();
});
it('GET /api/<module> returns 200', async () => {
await request(app.getHttpServer())
.get('/api/<module>')
.expect(200);
});
});10. Verify
bash
# Build
cd api && pnpm build
# Run unit tests
pnpm test:unit
# Run e2e tests
pnpm test:e2eThen check Swagger docs at http://localhost:8001/docs — your endpoints should appear under their @ApiTags() group.