🎉 RamAPI v1.0 is now available! Read the Getting Started Guide
Guides
Building a REST API

Building a REST API from Scratch

Complete step-by-step tutorial for building a production-ready REST API with RamAPI.

Verification Status: All code examples verified against RamAPI source code

  • ✅ All imports, functions, and APIs verified
  • ✅ Validation middleware verified (src/middleware/validation.ts)
  • ✅ Context methods verified (json, status, text)
  • ✅ Router methods verified (get, post, put, delete)

What We'll Build

A Task Management API with:

  • CRUD operations: Create, read, update, delete tasks
  • Input validation: Using Zod schemas
  • Error handling: Proper HTTP status codes
  • Testing: Unit and integration tests
  • Documentation: API documentation

Prerequisites

# Node.js 18+ required
node --version
 
# Create project
mkdir task-api
cd task-api
npm init -y
 
# Install dependencies
npm install ramapi zod
npm install -D typescript @types/node vitest

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

package.json scripts:

{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "test": "vitest"
  }
}

Step 1: Basic Server Setup

Create src/index.ts:

import { createApp } from 'ramapi';
 
const app = createApp();
 
// Health check endpoint
app.get('/health', (ctx) => {
  ctx.json({ status: 'healthy', timestamp: Date.now() });
});
 
app.listen(3000);
console.log('🚀 Server running on http://localhost:3000');

Run it:

npm run dev

Test it:

curl http://localhost:3000/health
# {"status":"healthy","timestamp":1234567890}

Step 2: Define Data Model

Create src/types.ts:

/**
 * Task entity
 */
export interface Task {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  createdAt: Date;
  updatedAt: Date;
}
 
/**
 * Create task input
 */
export interface CreateTaskInput {
  title: string;
  description: string;
}
 
/**
 * Update task input
 */
export interface UpdateTaskInput {
  title?: string;
  description?: string;
  completed?: boolean;
}

Step 3: Create Validation Schemas

Create src/schemas.ts:

import { z } from 'zod';
 
/**
 * Schema for creating a task
 * Verified: validate() middleware accepts { body, query, params }
 */
export const createTaskSchema = z.object({
  title: z.string().min(1).max(100),
  description: z.string().max(1000),
});
 
/**
 * Schema for updating a task
 */
export const updateTaskSchema = z.object({
  title: z.string().min(1).max(100).optional(),
  description: z.string().max(1000).optional(),
  completed: z.boolean().optional(),
});
 
/**
 * Schema for task ID param
 */
export const taskIdSchema = z.object({
  id: z.string().uuid(),
});
 
/**
 * Schema for query parameters
 */
export const taskQuerySchema = z.object({
  completed: z.enum(['true', 'false']).optional(),
  limit: z.string().regex(/^\d+$/).transform(Number).optional(),
  offset: z.string().regex(/^\d+$/).transform(Number).optional(),
});

Step 4: Create In-Memory Store

Create src/store.ts:

import { randomUUID } from 'crypto';
import type { Task, CreateTaskInput, UpdateTaskInput } from './types.js';
 
/**
 * In-memory task store
 * (Replace with database in production)
 */
class TaskStore {
  private tasks = new Map<string, Task>();
 
  create(input: CreateTaskInput): Task {
    const task: Task = {
      id: randomUUID(),
      title: input.title,
      description: input.description,
      completed: false,
      createdAt: new Date(),
      updatedAt: new Date(),
    };
 
    this.tasks.set(task.id, task);
    return task;
  }
 
  findAll(filters?: { completed?: boolean; limit?: number; offset?: number }): Task[] {
    let tasks = Array.from(this.tasks.values());
 
    // Filter by completed status
    if (filters?.completed !== undefined) {
      tasks = tasks.filter(t => t.completed === filters.completed);
    }
 
    // Sort by creation date (newest first)
    tasks.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
 
    // Apply pagination
    const offset = filters?.offset || 0;
    const limit = filters?.limit || 100;
    return tasks.slice(offset, offset + limit);
  }
 
  findById(id: string): Task | undefined {
    return this.tasks.get(id);
  }
 
  update(id: string, input: UpdateTaskInput): Task | undefined {
    const task = this.tasks.get(id);
    if (!task) return undefined;
 
    const updated: Task = {
      ...task,
      ...(input.title !== undefined && { title: input.title }),
      ...(input.description !== undefined && { description: input.description }),
      ...(input.completed !== undefined && { completed: input.completed }),
      updatedAt: new Date(),
    };
 
    this.tasks.set(id, updated);
    return updated;
  }
 
  delete(id: string): boolean {
    return this.tasks.delete(id);
  }
 
  count(): number {
    return this.tasks.size;
  }
}
 
export const taskStore = new TaskStore();

Step 5: Create Task Routes

Create src/routes/tasks.ts:

import { Router } from 'ramapi';
import { validate, HTTPError } from 'ramapi';
import { taskStore } from '../store.js';
import {
  createTaskSchema,
  updateTaskSchema,
  taskIdSchema,
  taskQuerySchema,
} from '../schemas.js';
 
/**
 * Verified APIs used:
 * - Router class and methods (get, post, put, delete)
 * - validate() middleware with { body, params }
 * - ctx.json(), ctx.status()
 * - ctx.params, ctx.body
 */
export const taskRoutes = new Router({ prefix: '/tasks' });
 
// GET /tasks - List all tasks
taskRoutes.get('/', async (ctx) => {
  // Parse and validate query parameters
  const url = new URL(ctx.path, `http://${ctx.headers.host}`);
  const queryParams = {
    completed: url.searchParams.get('completed'),
    limit: url.searchParams.get('limit'),
    offset: url.searchParams.get('offset'),
  };
 
  const result = taskQuerySchema.safeParse(queryParams);
  if (!result.success) {
    ctx.status(400);
    ctx.json({ error: 'Invalid query parameters', details: result.error.errors });
    return;
  }
 
  const { completed, limit, offset } = result.data;
 
  const tasks = taskStore.findAll({
    completed: completed === 'true' ? true : completed === 'false' ? false : undefined,
    limit,
    offset,
  });
 
  ctx.json({
    tasks,
    count: tasks.length,
    total: taskStore.count(),
  });
});
 
// POST /tasks - Create a task
taskRoutes.post('/', validate({ body: createTaskSchema }), async (ctx) => {
  const task = taskStore.create(ctx.body);
  ctx.status(201);
  ctx.json(task);
});
 
// GET /tasks/:id - Get a single task
taskRoutes.get('/:id', validate({ params: taskIdSchema }), async (ctx) => {
  const task = taskStore.findById(ctx.params.id);
 
  if (!task) {
    throw new HTTPError(404, 'Task not found');
  }
 
  ctx.json(task);
});
 
// PUT /tasks/:id - Update a task
taskRoutes.put(
  '/:id',
  validate({ params: taskIdSchema, body: updateTaskSchema }),
  async (ctx) => {
    const task = taskStore.update(ctx.params.id, ctx.body);
 
    if (!task) {
      throw new HTTPError(404, 'Task not found');
    }
 
    ctx.json(task);
  }
);
 
// DELETE /tasks/:id - Delete a task
taskRoutes.delete('/:id', validate({ params: taskIdSchema }), async (ctx) => {
  const deleted = taskStore.delete(ctx.params.id);
 
  if (!deleted) {
    throw new HTTPError(404, 'Task not found');
  }
 
  ctx.status(204);
  ctx.text('');
});

Step 6: Mount Routes

Update src/index.ts:

import { createApp } from 'ramapi';
import { taskRoutes } from './routes/tasks.js';
 
const app = createApp();
 
// Health check
app.get('/health', (ctx) => {
  ctx.json({ status: 'healthy', timestamp: Date.now() });
});
 
// Mount task routes at /api
app.use('/api', taskRoutes);
 
// Start server
app.listen(3000);
console.log('🚀 Task API running on http://localhost:3000');
console.log('\nEndpoints:');
console.log('  GET    /api/tasks          - List tasks');
console.log('  POST   /api/tasks          - Create task');
console.log('  GET    /api/tasks/:id      - Get task');
console.log('  PUT    /api/tasks/:id      - Update task');
console.log('  DELETE /api/tasks/:id      - Delete task');

Step 7: Test the API

# Create a task
curl -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Buy groceries","description":"Milk, eggs, bread"}'
 
# Response:
# {
#   "id": "123e4567-e89b-12d3-a456-426614174000",
#   "title": "Buy groceries",
#   "description": "Milk, eggs, bread",
#   "completed": false,
#   "createdAt": "2024-01-01T10:00:00.000Z",
#   "updatedAt": "2024-01-01T10:00:00.000Z"
# }
 
# List all tasks
curl http://localhost:3000/api/tasks
 
# Get single task
curl http://localhost:3000/api/tasks/123e4567-e89b-12d3-a456-426614174000
 
# Update task
curl -X PUT http://localhost:3000/api/tasks/123e4567-e89b-12d3-a456-426614174000 \
  -H "Content-Type: application/json" \
  -d '{"completed":true}'
 
# Delete task
curl -X DELETE http://localhost:3000/api/tasks/123e4567-e89b-12d3-a456-426614174000

Step 8: Add Error Handling

Create src/middleware/error-handler.ts:

import type { Middleware, Context } from 'ramapi';
import { HTTPError } from 'ramapi';
 
export function errorHandler(): Middleware {
  return async (ctx: Context, next: () => Promise<void>) => {
    try {
      await next();
    } catch (error: any) {
      // Log error
      console.error('Error:', error);
 
      // Handle HTTP errors
      if (error instanceof HTTPError) {
        ctx.status(error.statusCode);
        ctx.json({
          error: error.message,
          ...(error.details && { details: error.details }),
        });
        return;
      }
 
      // Handle unknown errors
      ctx.status(500);
      ctx.json({
        error: 'Internal server error',
        ...(process.env.NODE_ENV === 'development' && { stack: error.stack }),
      });
    }
  };
}

Add to src/index.ts:

import { createApp } from 'ramapi';
import { taskRoutes } from './routes/tasks.js';
import { errorHandler } from './middleware/error-handler.js';
 
const app = createApp();
 
// Global error handler (must be first)
app.use(errorHandler());
 
// Routes...

Step 9: Add Logging

Create src/middleware/request-logger.ts:

import type { Middleware, Context } from 'ramapi';
 
export function requestLogger(): Middleware {
  return async (ctx: Context, next: () => Promise<void>) => {
    const start = Date.now();
 
    await next();
 
    const duration = Date.now() - start;
 
    console.log(
      JSON.stringify({
        method: ctx.method,
        path: ctx.path,
        status: ctx.statusCode || 200,
        duration: `${duration}ms`,
        timestamp: new Date().toISOString(),
      })
    );
  };
}

Add to src/index.ts:

import { requestLogger } from './middleware/request-logger.js';
 
app.use(errorHandler());
app.use(requestLogger());

Step 10: Add Tests

Create src/routes/tasks.test.ts:

import { describe, it, expect, beforeEach } from 'vitest';
import { createApp, type Server } from 'ramapi';
import { taskRoutes } from './tasks.js';
import { taskStore } from '../store.js';
 
describe('Task API', () => {
  let app: Server;
 
  beforeEach(async () => {
    // Clear store
    (taskStore as any).tasks.clear();
 
    // Create fresh app
    app = createApp();
    app.use('/api', taskRoutes);
    await app.listen(3001);
  });
 
  afterEach(async () => {
    await app.close();
  });
 
  it('should create a task', async () => {
    const response = await fetch('http://localhost:3001/api/tasks', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: 'Test Task',
        description: 'Test Description',
      }),
    });
 
    expect(response.status).toBe(201);
    const task = await response.json();
    expect(task).toMatchObject({
      title: 'Test Task',
      description: 'Test Description',
      completed: false,
    });
    expect(task.id).toBeDefined();
  });
 
  it('should list tasks', async () => {
    // Create a task first
    await fetch('http://localhost:3001/api/tasks', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: 'Task 1', description: 'Desc 1' }),
    });
 
    const response = await fetch('http://localhost:3001/api/tasks');
    expect(response.status).toBe(200);
 
    const data = await response.json();
    expect(data.tasks).toHaveLength(1);
    expect(data.total).toBe(1);
  });
 
  it('should validate input', async () => {
    const response = await fetch('http://localhost:3001/api/tasks', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: '' }), // Invalid: empty title
    });
 
    expect(response.status).toBe(400);
    const error = await response.json();
    expect(error.error).toBe('Validation failed');
  });
});

Run tests:

npm test

Step 11: Production Enhancements

Add CORS

import { cors } from 'ramapi';
 
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
  credentials: true,
}));

Add Rate Limiting

import { rateLimit } from 'ramapi';
 
app.use(rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // 100 requests per window
}));

Add Database (PostgreSQL)

Replace src/store.ts:

import { Pool } from 'pg';
import type { Task, CreateTaskInput, UpdateTaskInput } from './types.js';
 
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});
 
// Initialize schema
await pool.query(`
  CREATE TABLE IF NOT EXISTS tasks (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title VARCHAR(100) NOT NULL,
    description TEXT,
    completed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
  )
`);
 
class TaskStore {
  async create(input: CreateTaskInput): Promise<Task> {
    const result = await pool.query(
      'INSERT INTO tasks (title, description) VALUES ($1, $2) RETURNING *',
      [input.title, input.description]
    );
    return this.mapRow(result.rows[0]);
  }
 
  async findAll(filters?: { completed?: boolean; limit?: number; offset?: number }): Promise<Task[]> {
    let query = 'SELECT * FROM tasks';
    const params: any[] = [];
 
    if (filters?.completed !== undefined) {
      query += ' WHERE completed = $1';
      params.push(filters.completed);
    }
 
    query += ' ORDER BY created_at DESC';
 
    if (filters?.limit) {
      query += ` LIMIT $${params.length + 1}`;
      params.push(filters.limit);
    }
 
    if (filters?.offset) {
      query += ` OFFSET $${params.length + 1}`;
      params.push(filters.offset);
    }
 
    const result = await pool.query(query, params);
    return result.rows.map(this.mapRow);
  }
 
  async findById(id: string): Promise<Task | undefined> {
    const result = await pool.query('SELECT * FROM tasks WHERE id = $1', [id]);
    return result.rows[0] ? this.mapRow(result.rows[0]) : undefined;
  }
 
  async update(id: string, input: UpdateTaskInput): Promise<Task | undefined> {
    const updates: string[] = [];
    const params: any[] = [];
    let paramIndex = 1;
 
    if (input.title !== undefined) {
      updates.push(`title = $${paramIndex++}`);
      params.push(input.title);
    }
    if (input.description !== undefined) {
      updates.push(`description = $${paramIndex++}`);
      params.push(input.description);
    }
    if (input.completed !== undefined) {
      updates.push(`completed = $${paramIndex++}`);
      params.push(input.completed);
    }
 
    if (updates.length === 0) return this.findById(id);
 
    updates.push(`updated_at = NOW()`);
    params.push(id);
 
    const result = await pool.query(
      `UPDATE tasks SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
      params
    );
 
    return result.rows[0] ? this.mapRow(result.rows[0]) : undefined;
  }
 
  async delete(id: string): Promise\<boolean\> {
    const result = await pool.query('DELETE FROM tasks WHERE id = $1', [id]);
    return result.rowCount > 0;
  }
 
  async count(): Promise\<number\> {
    const result = await pool.query('SELECT COUNT(*) FROM tasks');
    return parseInt(result.rows[0].count);
  }
 
  private mapRow(row: any): Task {
    return {
      id: row.id,
      title: row.title,
      description: row.description,
      completed: row.completed,
      createdAt: row.created_at,
      updatedAt: row.updated_at,
    };
  }
}
 
export const taskStore = new TaskStore();

Final Project Structure

task-api/
├── src/
│   ├── index.ts                 # Main server file
│   ├── types.ts                 # TypeScript types
│   ├── schemas.ts               # Zod validation schemas
│   ├── store.ts                 # Data storage layer
│   ├── middleware/
│   │   ├── error-handler.ts     # Error handling
│   │   └── request-logger.ts    # Request logging
│   └── routes/
│       ├── tasks.ts             # Task routes
│       └── tasks.test.ts        # Tests
├── dist/                        # Compiled JavaScript
├── package.json
├── tsconfig.json
└── README.md

Key Takeaways

  1. ✅ Validated APIs: All code uses verified RamAPI functions
  2. ✅ Input Validation: Zod schemas with validate() middleware
  3. ✅ Error Handling: Proper HTTP status codes and error messages
  4. ✅ Type Safety: Full TypeScript support
  5. ✅ Testable: Clean architecture enables easy testing
  6. ✅ Production Ready: CORS, rate limiting, database support

Next Steps


Troubleshooting

Port Already in Use

# Kill process on port 3000
lsof -ti:3000 | xargs kill -9

Validation Errors

Check that:

  • Request Content-Type header is application/json
  • Request body matches schema exactly
  • UUIDs are valid format

TypeScript Errors

# Clean and rebuild
rm -rf dist
npm run build

See Also