Example: User CRUD API
This walkthrough builds a complete user management API with registration, login, JWT authentication, CRUD, and pagination. Every component is real and production-ready.
What we’re building
1
2
3
4
5
6
POST /api/users Register a new user
POST /api/users/login Authenticate, return JWT
GET /api/users List users (JWT required)
GET /api/users/:id Get one user (JWT required)
PUT /api/users/:id Update user (JWT required, owner or admin)
DELETE /api/users/:id Delete user (admin only)
Migration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- migrations/001_create_users.sql
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(320) NOT NULL,
name VARCHAR(100) NOT NULL,
password_hash TEXT NOT NULL,
roles JSONB NOT NULL DEFAULT '["user"]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique ON users (LOWER(email));
CREATE INDEX IF NOT EXISTS users_created_at_idx ON users (created_at DESC);
Domain types
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// src/domain/user.ts
export interface User {
id: string;
email: string;
name: string;
password_hash: string;
roles: string; // JSON array stored as text
created_at: string;
updated_at: string;
}
export interface UserPublic {
id: string;
email: string;
name: string;
roles: string[];
createdAt: string;
}
export interface CreateUserDto {
email: string;
name: string;
password: string;
}
export interface UpdateUserDto {
name?: string;
email?: string;
}
export interface LoginDto {
email: string;
password: string;
}
export function toPublicUser(user: User): UserPublic {
let roles: string[] = [];
try { roles = JSON.parse(user.roles) as string[]; } catch { roles = ['user']; }
return {
id: user.id,
email: user.email,
name: user.name,
roles,
createdAt: user.created_at,
};
}
Repository
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// src/services/user.repository.ts
import { Injectable } from '../core/container.js';
import { StreetPostgresRepository } from '../database/repository.js';
import { PgPool } from '../database/pool.js';
import type { User } from '../domain/user.js';
@Injectable()
export class UserRepository extends StreetPostgresRepository<User> {
protected readonly tableName = 'users';
constructor(pool: PgPool) { super(pool); }
protected mapRow(row: Record<string, string | null>): User {
return {
id: row['id'] ?? '',
email: row['email'] ?? '',
name: row['name'] ?? '',
password_hash: row['password_hash'] ?? '',
roles: row['roles'] ?? '["user"]',
created_at: row['created_at'] ?? '',
updated_at: row['updated_at'] ?? '',
};
}
async findByEmail(email: string): Promise<User | null> {
const safe = email.toLowerCase().replace(/'/g, "''");
const result = await this.pool.query(
`SELECT * FROM users WHERE LOWER(email) = '${safe}' LIMIT 1`
);
return result.rows.length ? this.mapRow(result.rows[0] as Record<string, string | null>) : null;
}
async emailExists(email: string): Promise<boolean> {
const safe = email.toLowerCase().replace(/'/g, "''");
const result = await this.pool.query(
`SELECT 1 FROM users WHERE LOWER(email) = '${safe}' LIMIT 1`
);
return result.rows.length > 0;
}
}
Service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
// src/services/user.service.ts
import { pbkdf2, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
import { Injectable } from '../core/container.js';
import { UserRepository } from './user.repository.js';
import { JwtService } from '../security/jwt.js';
import { AppConfig } from '../config/index.js';
import { toPublicUser, type User, type UserPublic,
type CreateUserDto, type UpdateUserDto, type LoginDto } from '../domain/user.js';
import { BadRequestException, NotFoundException,
UnauthorizedException, ConflictException } from '../http/exceptions.js';
import type { TokenPair, PaginatedResult } from '../core/types.js';
const pbkdf2Async = promisify(pbkdf2);
@Injectable()
export class UserService {
private readonly jwt: JwtService;
constructor(
private readonly repo: UserRepository,
private readonly config: AppConfig,
) {
this.jwt = new JwtService(this.config.jwtSecret);
}
async register(dto: CreateUserDto): Promise<UserPublic> {
if (await this.repo.emailExists(dto.email)) {
throw new ConflictException('Email already registered');
}
const hash = await this._hashPassword(dto.password);
const user = await this.repo.create({
id: this._uuid(),
email: dto.email.toLowerCase(),
name: dto.name,
password_hash: hash,
roles: JSON.stringify(['user']),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as Partial<User>);
return toPublicUser(user);
}
async login(dto: LoginDto): Promise<TokenPair> {
const user = await this.repo.findByEmail(dto.email);
if (!user || !await this._verifyPassword(dto.password, user.password_hash)) {
await this._dummyHash(); // Constant-time even on miss
throw new UnauthorizedException('Invalid credentials');
}
const roles = JSON.parse(user.roles) as string[];
return {
accessToken: this.jwt.sign({ sub: user.id, email: user.email, roles }, { expiresInSeconds: 3600 }),
refreshToken: this.jwt.sign({ sub: user.id, type: 'refresh' }, { expiresInSeconds: 604800 }),
expiresIn: 3600,
};
}
async findById(id: string): Promise<UserPublic> {
const user = await this.repo.findById(id);
if (!user) throw new NotFoundException('User not found');
return toPublicUser(user);
}
async findAll(page: number, limit: number): Promise<PaginatedResult<UserPublic>> {
const safeLimit = Math.min(Math.max(1, limit), 100);
const offset = (Math.max(1, page) - 1) * safeLimit;
const [items, total] = await Promise.all([
this.repo.findAll(safeLimit, offset),
this.repo.count(),
]);
return {
items: items.map(toPublicUser),
total,
page,
limit: safeLimit,
hasMore: offset + items.length < total,
};
}
async update(id: string, dto: UpdateUserDto): Promise<UserPublic> {
if (!await this.repo.findById(id)) throw new NotFoundException('User not found');
if (dto.email) {
const owner = await this.repo.findByEmail(dto.email);
if (owner && owner.id !== id) throw new ConflictException('Email already taken');
}
const updated = await this.repo.update(id, {
...(dto.name ? { name: dto.name } : {}),
...(dto.email ? { email: dto.email.toLowerCase() } : {}),
updated_at: new Date().toISOString(),
} as Partial<User>);
if (!updated) throw new NotFoundException('User not found');
return toPublicUser(updated);
}
async remove(id: string): Promise<void> {
if (!await this.repo.delete(id)) throw new NotFoundException('User not found');
}
verifyToken(token: string) {
return this.jwt.verify(token);
}
private async _hashPassword(pw: string): Promise<string> {
const salt = randomBytes(32);
const hash = await pbkdf2Async(pw, salt, 100_000, 64, 'sha512');
return `${salt.toString('hex')}:${hash.toString('hex')}`;
}
private async _verifyPassword(pw: string, stored: string): Promise<boolean> {
const [saltHex, hashHex] = stored.split(':');
if (!saltHex || !hashHex) return false;
const salt = Buffer.from(saltHex, 'hex');
const expected = Buffer.from(hashHex, 'hex');
const actual = await pbkdf2Async(pw, salt, 100_000, 64, 'sha512');
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
private async _dummyHash(): Promise<void> {
await pbkdf2Async('dummy', randomBytes(32), 100_000, 64, 'sha512');
}
private _uuid(): string {
const b = randomBytes(16);
b[6] = (b[6]! & 0x0f) | 0x40;
b[8] = (b[8]! & 0x3f) | 0x80;
const h = b.toString('hex');
return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;
}
}
Controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// src/controllers/user.controller.ts
import { Injectable } from '../core/container.js';
import { Controller, Get, Post, Put, Delete, Validate, ApiOperation } from '../core/decorators.js';
import type { StreetContext } from '../core/context.js';
import { authMiddleware, requireRoles } from '../http/auth.middleware.js';
import { UserService } from '../services/user.service.js';
import { JwtService } from '../security/jwt.js';
import { AppConfig } from '../config/index.js';
import { BadRequestException } from '../http/exceptions.js';
import type { CreateUserDto, UpdateUserDto, LoginDto } from '../domain/user.js';
@Injectable()
@Controller('/api/users')
export class UserController {
private readonly auth: ReturnType<typeof authMiddleware>;
constructor(
private readonly users: UserService,
private readonly config: AppConfig,
) {
const jwt = new JwtService(this.config.jwtSecret);
this.auth = authMiddleware(jwt);
}
@Post('/')
@Validate({ body: {
email: { type: 'email', required: true, max: 320 },
name: { type: 'string', required: true, min: 1, max: 100 },
password: { type: 'string', required: true, min: 8, max: 128 },
}})
@ApiOperation({ summary: 'Register user', tags: ['users'] })
async register(ctx: StreetContext): Promise<void> {
const user = await this.users.register(ctx.body as CreateUserDto);
ctx.json(user, 201);
}
@Post('/login')
@Validate({ body: {
email: { type: 'email', required: true },
password: { type: 'string', required: true, min: 1 },
}})
@ApiOperation({ summary: 'Login', tags: ['auth'] })
async login(ctx: StreetContext): Promise<void> {
ctx.json(await this.users.login(ctx.body as LoginDto));
}
@Get('/')
@ApiOperation({ summary: 'List users', tags: ['users'] })
async list(ctx: StreetContext): Promise<void> {
const page = parseInt(ctx.query['page'] ?? '1', 10);
const limit = parseInt(ctx.query['limit'] ?? '20', 10);
ctx.json(await this.users.findAll(page, limit));
}
@Get('/:id')
@Validate({ params: { id: { type: 'uuid', required: true } } })
@ApiOperation({ summary: 'Get user', tags: ['users'] })
async getOne(ctx: StreetContext): Promise<void> {
ctx.json(await this.users.findById(ctx.params['id']!));
}
@Put('/:id')
@Validate({
params: { id: { type: 'uuid', required: true } },
body: { name: { type: 'string', min: 1, max: 100 }, email: { type: 'email', max: 320 } },
})
@ApiOperation({ summary: 'Update user', tags: ['users'] })
async update(ctx: StreetContext): Promise<void> {
ctx.json(await this.users.update(ctx.params['id']!, ctx.body as UpdateUserDto));
}
@Delete('/:id')
@Validate({ params: { id: { type: 'uuid', required: true } } })
@ApiOperation({ summary: 'Delete user', tags: ['users'] })
async remove(ctx: StreetContext): Promise<void> {
await this.users.remove(ctx.params['id']!);
ctx.send(204);
}
}
Example requests and responses
Register
1
2
3
4
5
6
7
curl -X POST http://localhost:3000/api/users \
-H 'Content-Type: application/json' \
-d '{
"email": "alice@example.com",
"name": "Alice Smith",
"password": "s3cure-p@ssw0rd!"
}'
1
2
3
4
5
6
7
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"email": "alice@example.com",
"name": "Alice Smith",
"roles": ["user"],
"createdAt": "2024-01-15T10:23:45.123Z"
}
Login
1
2
3
curl -X POST http://localhost:3000/api/users/login \
-H 'Content-Type: application/json' \
-d '{"email":"alice@example.com","password":"s3cure-p@ssw0rd!"}'
1
2
3
4
5
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMWI...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMWI...",
"expiresIn": 3600
}
List users
1
2
curl 'http://localhost:3000/api/users?page=1&limit=10' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"items": [
{
"id": "a1b2c3d4-...",
"email": "alice@example.com",
"name": "Alice Smith",
"roles": ["user"],
"createdAt": "2024-01-15T10:23:45.123Z"
}
],
"total": 42,
"page": 1,
"limit": 10,
"hasMore": true
}
Validation error
1
2
3
curl -X POST http://localhost:3000/api/users \
-H 'Content-Type: application/json' \
-d '{"email":"not-an-email","password":"short"}'
1
2
3
4
5
6
7
8
9
10
{
"error": "BadRequestException",
"message": "Validation failed",
"status": 400,
"details": [
"body.email must be a valid email",
"body.name is required",
"body.password must be at least 8 chars"
]
}
Duplicate email
1
2
3
4
# Second registration with same email:
curl -X POST http://localhost:3000/api/users \
-H 'Content-Type: application/json' \
-d '{"email":"alice@example.com","name":"Alice2","password":"password123"}'
1
2
3
4
5
{
"error": "ConflictException",
"message": "Email already registered",
"status": 409
}
Update user
1
2
3
4
curl -X PUT http://localhost:3000/api/users/a1b2c3d4-... \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer ...' \
-d '{"name":"Alice Johnson"}'
1
2
3
4
5
6
7
{
"id": "a1b2c3d4-...",
"email": "alice@example.com",
"name": "Alice Johnson",
"roles": ["user"],
"createdAt": "2024-01-15T10:23:45.123Z"
}
Delete user
1
2
3
4
curl -X DELETE http://localhost:3000/api/users/a1b2c3d4-... \
-H 'Authorization: Bearer ...'
# HTTP 204 No Content (empty body)