Example: REST API

A complete REST API with CRUD operations, PostgreSQL persistence, JWT authentication, and pagination.


Project structure

1
2
3
4
5
6
7
8
9
10
src/
├── main.ts
├── controllers/
│   └── items.controller.ts
├── services/
│   └── items.service.ts
└── repositories/
    └── items.repository.ts
migrations/
└── 20260101000000_create_items.sql

Migration

1
2
3
4
5
6
7
8
9
10
11
-- migrations/20260101000000_create_items.sql
CREATE TABLE items (
  id          UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  name        VARCHAR(255) NOT NULL,
  description TEXT         NOT NULL DEFAULT '',
  price       NUMERIC(10,2) NOT NULL DEFAULT 0,
  created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE INDEX items_created_at_idx ON items (created_at DESC);

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
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
// src/repositories/items.repository.ts
import { Injectable, container, PgPool } from 'streetjs';
import type { PgRow } from 'streetjs';

export interface Item {
  id: string;
  name: string;
  description: string;
  price: number;
  createdAt: Date;
  updatedAt: Date;
}

function rowToItem(row: PgRow): Item {
  return {
    id:          String(row['id']),
    name:        String(row['name']),
    description: String(row['description'] ?? ''),
    price:       parseFloat(String(row['price'] ?? '0')),
    createdAt:   new Date(String(row['created_at'])),
    updatedAt:   new Date(String(row['updated_at'])),
  };
}

@Injectable()
export class ItemRepository {
  private readonly pool = container.resolve(PgPool);

  async findAll(limit: number, offset: number): Promise<{ items: Item[]; total: number }> {
    const [data, count] = await Promise.all([
      this.pool.query(
        'SELECT * FROM items ORDER BY created_at DESC LIMIT $1 OFFSET $2',
        [limit, offset]
      ),
      this.pool.query('SELECT COUNT(*) AS total FROM items'),
    ]);
    return {
      items: data.rows.map(rowToItem),
      total: parseInt(String(count.rows[0]?.['total'] ?? '0'), 10),
    };
  }

  async findById(id: string): Promise<Item | null> {
    const result = await this.pool.query(
      'SELECT * FROM items WHERE id = $1', [id]
    );
    return result.rows[0] ? rowToItem(result.rows[0] as PgRow) : null;
  }

  async create(item: Item): Promise<void> {
    await this.pool.query(
      `INSERT INTO items (id, name, description, price, created_at, updated_at)
       VALUES ($1, $2, $3, $4, $5, $6)`,
      [item.id, item.name, item.description, item.price,
       item.createdAt.toISOString(), item.updatedAt.toISOString()]
    );
  }

  async update(item: Item): Promise<void> {
    await this.pool.query(
      `UPDATE items SET name=$1, description=$2, price=$3, updated_at=$4
       WHERE id=$5`,
      [item.name, item.description, item.price,
       item.updatedAt.toISOString(), item.id]
    );
  }

  async delete(id: string): Promise<void> {
    await this.pool.query('DELETE FROM items WHERE id = $1', [id]);
  }
}

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
// src/services/items.service.ts
import { Injectable, NotFoundException, BadRequestException } from 'streetjs';
import { ItemRepository } from '../repositories/items.repository.js';
import type { Item } from '../repositories/items.repository.js';

export interface CreateItemInput { name: string; description?: string; price: number; }
export interface UpdateItemInput { name?: string; description?: string; price?: number; }

@Injectable()
export class ItemService {
  constructor(private readonly repo: ItemRepository) {}

  async findAll(page: number, limit: number) {
    const offset = (page - 1) * limit;
    const { items, total } = await this.repo.findAll(limit, offset);
    return { items, total, page, limit, pages: Math.ceil(total / limit) };
  }

  async findById(id: string): Promise<Item> {
    const item = await this.repo.findById(id);
    if (!item) throw new NotFoundException(`Item ${id} not found`);
    return item;
  }

  async create(input: CreateItemInput): Promise<Item> {
    if (!input.name?.trim()) throw new BadRequestException('name is required');
    if (input.price < 0)     throw new BadRequestException('price must be >= 0');
    const now = new Date();
    const item: Item = {
      id: crypto.randomUUID(), name: input.name.trim(),
      description: input.description ?? '', price: input.price,
      createdAt: now, updatedAt: now,
    };
    await this.repo.create(item);
    return item;
  }

  async update(id: string, input: UpdateItemInput): Promise<Item> {
    const existing = await this.findById(id);
    const updated: Item = { ...existing, ...input, updatedAt: new Date() };
    await this.repo.update(updated);
    return updated;
  }

  async delete(id: string): Promise<void> {
    await this.findById(id);
    await this.repo.delete(id);
  }
}

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
// src/controllers/items.controller.ts
import {
  Controller, Get, Post, Put, Delete,
  ApiOperation, container,
} from 'streetjs';
import type { StreetContext } from 'streetjs';
import { ItemService } from '../services/items.service.js';
import type { CreateItemInput, UpdateItemInput } from '../services/items.service.js';

@Controller('/api/items')
export class ItemController {
  private readonly svc = container.resolve(ItemService);

  @Get('/')
  @ApiOperation({ summary: 'List items', tags: ['items'] })
  async list(ctx: StreetContext): Promise<void> {
    const page  = Math.max(1, parseInt(ctx.query['page']  ?? '1',  10));
    const limit = Math.min(100, parseInt(ctx.query['limit'] ?? '20', 10));
    ctx.json(await this.svc.findAll(page, limit));
  }

  @Get('/:id')
  @ApiOperation({ summary: 'Get item by ID', tags: ['items'] })
  async getOne(ctx: StreetContext): Promise<void> {
    ctx.json(await this.svc.findById(ctx.params['id']!));
  }

  @Post('/')
  @ApiOperation({ summary: 'Create item', tags: ['items'] })
  async create(ctx: StreetContext): Promise<void> {
    const body = ctx.body as CreateItemInput;
    ctx.json(await this.svc.create(body), 201);
  }

  @Put('/:id')
  @ApiOperation({ summary: 'Update item', tags: ['items'] })
  async update(ctx: StreetContext): Promise<void> {
    const body = ctx.body as UpdateItemInput;
    ctx.json(await this.svc.update(ctx.params['id']!, body));
  }

  @Delete('/:id')
  @ApiOperation({ summary: 'Delete item', tags: ['items'] })
  async remove(ctx: StreetContext): Promise<void> {
    await this.svc.delete(ctx.params['id']!);
    ctx.send(204);
  }
}

Entry point

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
// src/main.ts
import 'reflect-metadata';
import {
  streetApp, PgPool, StreetMigrationRunner, container,
  securityHeaders, corsMiddleware, xssMiddleware,
  RateLimiter, TelemetryTracker, telemetryMiddleware,
} from 'streetjs';
import { ItemController } from './controllers/items.controller.js';

async function bootstrap() {
  const pool = new PgPool({
    host: process.env['PG_HOST'] ?? 'localhost',
    port: parseInt(process.env['PG_PORT'] ?? '5432', 10),
    user: process.env['PG_USER'] ?? 'postgres',
    password: process.env['PG_PASSWORD'] ?? '',
    database: process.env['PG_DATABASE'] ?? 'mydb',
    minConnections: 2, maxConnections: 10,
    idleTimeoutMs: 30_000, acquireTimeoutMs: 5_000,
  });
  await pool.initialize();
  container.register(PgPool, pool);

  // Run migrations on startup
  const runner = new StreetMigrationRunner(pool);
  await runner.run('./migrations');

  const telemetry = new TelemetryTracker(60_000);
  const limiter   = new RateLimiter({ windowMs: 60_000, maxRequests: 300 });
  const app       = streetApp({ port: 3000 });

  app.use(securityHeaders);
  app.use(corsMiddleware(['*']));
  app.use(xssMiddleware);
  app.use(telemetryMiddleware(telemetry));
  app.use(limiter.middleware());
  app.registerController(ItemController);

  const spec = app.openApiSpec();
  app.use(async (ctx, next) => {
    if (ctx.path === '/openapi.json') { ctx.json(spec); return; }
    await next();
  });

  await app.listen();

  process.once('SIGTERM', async () => {
    await app.close();
    await pool.close();
    limiter.destroy();
    process.exit(0);
  });
}

bootstrap().catch((err) => { console.error(err); process.exit(1); });

Test it

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Create
curl -X POST http://localhost:3000/api/items \
  -H 'Content-Type: application/json' \
  -d '{"name":"Widget","description":"A useful widget","price":9.99}'
# {"id":"...","name":"Widget","price":9.99,...}

# List
curl 'http://localhost:3000/api/items?page=1&limit=10'
# {"items":[...],"total":1,"page":1,"limit":10,"pages":1}

# Get one
curl http://localhost:3000/api/items/<id>

# Update
curl -X PUT http://localhost:3000/api/items/<id> \
  -H 'Content-Type: application/json' \
  -d '{"price":12.99}'

# Delete
curl -X DELETE http://localhost:3000/api/items/<id>
# 204 No Content

# OpenAPI spec
curl http://localhost:3000/openapi.json | jq .paths