Skip to content

Latest commit

 

History

History
389 lines (283 loc) · 9.73 KB

File metadata and controls

389 lines (283 loc) · 9.73 KB

API Development Standards

Development standards and patterns for building APIs with the Nerva framework.

TypeScript Standards

Strict Mode

All projects use strict: true in tsconfig.json. No exceptions.

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

No any

Never use any. Use unknown when the type is genuinely not known, then narrow with type guards:

// Bad
function parseBody(body: any) { /* ... */ }

// Good
function parseBody(body: unknown): ParsedBody {
  const parsed = bodySchema.parse(body);
  return parsed;
}

Zod for Runtime Validation

All external input (request bodies, query params, headers) must be validated with Zod schemas:

import { z } from "zod";

export const createTodoSchema = z.object({
  title: z.string().min(1).max(255),
  completed: z.boolean().default(false),
});

export type CreateTodoInput = z.infer<typeof createTodoSchema>;

Drizzle Type Inference

Derive types from Drizzle schemas rather than duplicating definitions:

import { todos } from "../db/schema/todos";
import { InferSelectModel, InferInsertModel } from "drizzle-orm";

export type Todo = InferSelectModel<typeof todos>;
export type NewTodo = InferInsertModel<typeof todos>;

Hono Patterns

Route Grouping

Group routes by resource using Hono.route():

import { Hono } from "hono";
import { todosRoutes } from "./routes/todos";
import { usersRoutes } from "./routes/users";

const app = new Hono();

app.route("/api/v1/todos", todosRoutes);
app.route("/api/v1/users", usersRoutes);

Middleware Composition

Stack middleware in order -- auth before validation before handler:

import { zValidator } from "@hono/zod-validator";
import { jwt } from "hono/jwt";

const todosRoutes = new Hono();

todosRoutes.post(
  "/",
  jwt({ secret: env.JWT_SECRET }),
  zValidator("json", createTodoSchema),
  async (c) => {
    const input = c.req.valid("json");
    const todo = await todoService.create(input);
    return c.json(todo, 201);
  }
);

Error Handling

Throw typed errors in handlers and let a single global handler serialize them to the standard error envelope:

import { NotFoundError, errorHandler } from "../middleware/error-handler";

// In route handlers -- throw typed errors
if (!todo) {
  throw new NotFoundError("Todo", id);
}

// Global error handler in app setup -- serializes AppError, ZodError,
// HTTPException, and unexpected errors to the standard envelope
app.onError(errorHandler);

Context Typing

Type your Hono app with environment bindings:

type Bindings = {
  DB: D1Database;
  JWT_SECRET: string;
};

type Variables = {
  user: { id: string; role: string };
};

const app = new Hono<{ Bindings: Bindings; Variables: Variables }>();

Drizzle ORM Patterns

Schema Design

One file per table in api/src/db/schema/. Export all tables from an index.ts barrel:

// api/src/db/schema/todos.ts
import { pgTable, uuid, text, boolean, timestamp } from "drizzle-orm/pg-core";

export const todos = pgTable("todos", {
  id: uuid("id").defaultRandom().primaryKey(),
  title: text("title").notNull(),
  completed: boolean("completed").notNull().default(false),
  userId: uuid("user_id").references(() => users.id),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

Relations

Define relations explicitly for type-safe joins:

import { relations } from "drizzle-orm";

export const todosRelations = relations(todos, ({ one }) => ({
  user: one(users, {
    fields: [todos.userId],
    references: [users.id],
  }),
}));

Queries

Use the query builder for reads, insert/update/delete for writes:

// Read with relations
const todosWithUser = await db.query.todos.findMany({
  where: eq(todos.completed, false),
  with: { user: true },
  limit: 20,
  offset: 0,
});

// Insert
const [newTodo] = await db.insert(todos).values(input).returning();

// Update
const [updated] = await db
  .update(todos)
  .set({ completed: true, updatedAt: new Date() })
  .where(eq(todos.id, id))
  .returning();

Transactions

Wrap multi-table writes in transactions:

const result = await db.transaction(async (tx) => {
  const [todo] = await tx.insert(todos).values(input).returning();
  await tx.insert(activityLog).values({
    action: "todo.created",
    resourceId: todo.id,
  });
  return todo;
});

Testing Strategy

Unit Tests (Services)

Test business logic in isolation with mocked database:

// api/tests/unit/todo-service.test.ts
describe("TodoService", () => {
  it("should reject empty titles", () => {
    expect(() => todoService.validate({ title: "" })).toThrow();
  });
});

Integration Tests (Routes)

Test the full request/response cycle with a real test database:

// api/tests/integration/todos.test.ts
import { app } from "../../src/app";

describe("POST /api/v1/todos", () => {
  it("should create a todo and return 201", async () => {
    const res = await app.request("/api/v1/todos", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "Test todo" }),
    });
    expect(res.status).toBe(201);
    const body = await res.json();
    expect(body.title).toBe("Test todo");
    expect(body.id).toBeDefined();
  });
});

Contract Tests

Validate responses match the OpenAPI spec:

import { validateResponse } from "../helpers/contract";

it("GET /todos response matches OpenAPI schema", async () => {
  const res = await app.request("/api/v1/todos");
  await validateResponse(res, "get", "/todos", 200);
});

Load Tests

k6 scripts in api/tests/load/:

import http from "k6/http";
import { check } from "k6";

export const options = { vus: 50, duration: "30s" };

export default function () {
  const res = http.get("http://localhost:8787/api/v1/todos");
  check(res, { "status is 200": (r) => r.status === 200 });
}

Security Standards

Input Validation

Every route that accepts input must validate with Zod before processing. Never trust client data.

Authentication Patterns

  • JWT -- Stateless, preferred for Cloudflare Workers (no session storage needed)
  • API Keys -- For service-to-service communication, stored hashed in database
  • OAuth2 -- For third-party integrations, delegate to provider

Rate Limiting

Apply rate limits per route group:

import { rateLimiter } from "../middleware/rate-limit";

app.use("/api/v1/*", rateLimiter({ max: 100, window: "1m" }));
app.use("/api/v1/auth/*", rateLimiter({ max: 10, window: "1m" }));

CORS

Configure CORS explicitly -- never leave origin: * in production:

import { cors } from "hono/cors";

app.use("*", cors({
  origin: ["https://app.example.com"],
  allowMethods: ["GET", "POST", "PUT", "DELETE"],
  allowHeaders: ["Content-Type", "Authorization"],
}));

See the CORS configuration guide for environment-specific setups, credentials mode, preflight caching, and how to debug common CORS errors.

Error Handling

Consistent Response Format

All error responses follow the same shape -- a single top-level error object:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": [
      { "field": "title", "message": "Required" }
    ]
  }
}

Error Codes

Code HTTP Status Description
VALIDATION_ERROR 400 Request body or params failed validation
UNAUTHORIZED 401 Missing or invalid authentication
FORBIDDEN 403 Authenticated but insufficient permissions
NOT_FOUND 404 Resource does not exist
CONFLICT 409 Duplicate resource or state conflict
PAYLOAD_TOO_LARGE 413 Request body exceeds the size limit
RATE_LIMITED 429 Too many requests
INTERNAL_ERROR 500 Unexpected server error

See the error handling guide for the full format definition, error code constants, typed error classes, the global app.onError() handler, per-category examples, and how the format maps to RFC 9457 Problem Details.

Performance

Query Optimization

  • Always select only needed columns: .select({ id: todos.id, title: todos.title })
  • Use pagination with limit and offset or cursor-based pagination for large datasets
  • Add indexes for columns used in WHERE, ORDER BY, and JOIN clauses

Caching

Use Cache-Control headers for GET endpoints. On Cloudflare Workers, leverage the Cache API:

c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=300");

Connection Pooling

For Node.js deployments, configure connection pooling in the Drizzle client:

import { Pool } from "pg";
import { drizzle } from "drizzle-orm/node-postgres";

const pool = new Pool({ connectionString: env.DATABASE_URL, max: 20 });
const db = drizzle(pool);

Naming Conventions

Element Convention Example
Route paths kebab-case /api/v1/user-profiles
File names kebab-case user-profile.ts
Schema/table names (TS) camelCase variable, PascalCase type userProfiles, UserProfile
Database columns snake_case created_at, user_id
Environment variables SCREAMING_SNAKE DATABASE_URL, JWT_SECRET
Zod schemas camelCase with Schema suffix createTodoSchema
Service classes PascalCase with Service suffix TodoService
Test files Same as source with .test.ts todos.test.ts