db3.aiVisit main site

30 September 2026

How to Create TypeScript APIs With Clear Boundaries, Authentication, and Tests

To create TypeScript APIs that stay maintainable as a SaaS product grows, define an HTTP contract, validate incoming data at runtime, authenticate the caller, authorize access to each record, put business decisions in a service, and test the real request boundary. A route should translate between HTTP and application behavior, not become the entire application.

I’ll build that structure around two private note endpoints: POST /api/notes and GET /api/notes/:id. The example keeps persistence and authentication behind interfaces so you can test the architecture without a database. If you need the initial server setup first, follow our minimal HTTP app walkthrough; this guide starts where that first working route leaves off.

Before you start: Have a TypeScript project with Fastify and a test runner such as Vitest. In a db3.ai application, follow the documented installation or starter setup rather than assuming the packages are already published on npm. The existing walkthrough uses Node.js 24 or newer and Fastify. For an actual private API, you will also need an authentication implementation, a database-backed note repository, and a decision about how browser sessions or other credentials reach your server. The small test adapter below is not a production authentication system. Our starter and installation guidance explains the current package setup.

Table of contents

1. Define the request contract and assign each layer a job

Start with behavior a client can observe. POST /api/notes accepts a nonblank title and returns 201 with a public note. GET /api/notes/:id returns a note only if it belongs to the authenticated caller. Missing credentials return 401; a missing or other user’s note returns 404. Choose and document these behaviors before adding more endpoints.

A useful application layout is server/http/notes.ts for schemas, identity checks, and responses; server/features/notes.ts for business behavior and repository contracts; server/database/ for persistence; and tests/notes.test.ts for request tests. These are application files, not new imports from the framework. Our application structure guide keeps product behavior in the consuming app rather than hiding its policy inside a general-purpose package.

The request travels through a predictable sequence: HTTP parsing and validation, verified identity, resource authorization, application logic, persistence, then an intentionally shaped response. A rejected request should stop before private data is exposed. Keep that sequence in mind while reading the code.

Six-stage API request flow from incoming request through validation, identity, authorization, service logic, and response

Check the design: Can you name the layer that rejects a malformed title, the layer that knows who the user is, and the query that limits a read to that user? If the answer to all three is “the route handler,” separate the responsibilities before adding another route.

2. Create a service that owns the record-level rule

Put the repository contract beside the note service. The repository’s findOwned method must enforce both the note ID and trusted owner ID in its lookup. It should not load an arbitrary note and hope a caller remembers a second ownership check. The HTTP layer supplies the authenticated ID; request JSON never supplies an owner ID.

// server/features/notes.ts
export interface Note {
  id: string;
  ownerId: string;
  title: string;
}

export interface NoteRepository {
  create(input: { ownerId: string; title: string }): Promise<Note>;
  findOwned(id: string, ownerId: string): Promise<Note | null>;
}

export class ApiError extends Error {
  constructor(
    readonly status: number,
    readonly code: string,
    message: string,
  ) {
    super(message);
  }
}

export const publicNote = (note: Note) => ({
  id: note.id,
  title: note.title,
});

export function makeNoteService(repository: NoteRepository) {
  return {
    async create(ownerId: string, input: { title: string }) {
      const title = input.title.trim();
      if (!title) throw new ApiError(400, 'invalid_title', 'Title is required.');
      return repository.create({ ownerId, title });
    },
    async get(ownerId: string, id: string) {
      const note = await repository.findOwned(id, ownerId);
      if (!note) throw new ApiError(404, 'not_found', 'Note not found.');
      return note;
    },
  };
}

The trimming check is a business rule as well as a defense against whitespace-only titles. If your product also needs a workspace membership check, place it in the service or the repository query according to where the policy can be enforced consistently; an authenticated account is not automatically authorized for every workspace or record. OWASP identifies missing object-level authorization as a distinct API risk and recommends checking access whenever a client-supplied ID selects a record. OWASP’s object-level authorization guidance is the relevant security reference.

Two user paths showing record owner access granted and another user's access denied

Check the service: Call get('user-b', noteIdOwnedByUserA) against a repository implementation. It should return the same not_found error as an unknown ID, without ever returning user A’s note. The in-memory implementation in step 6 demonstrates this contract; your SQL or ActiveRecord adapter must preserve it.

3. Validate HTTP input and shape the public response

A TypeScript annotation describes what application code expects; it does not inspect a JSON request arriving over the network. The TypeScript handbook explicitly notes that type assertions disappear at compile time. Fastify can validate route bodies and parameters with JSON Schema and serialize responses against response schemas. Use both a runtime schema and TypeScript types, then verify that they agree rather than assuming one automatically generates the other. TypeScript’s type-assertion documentation and Fastify’s validation documentation describe those boundaries.

This server factory is deliberately separate from listen(), so a test can inject HTTP requests without opening a port. Its authenticate dependency must verify a token or session against your real authentication service; the presence of a header is never proof of identity.

// server/http/notes.ts
import Fastify from 'fastify';
import {
  ApiError, makeNoteService, publicNote,
  type NoteRepository,
} from '../features/notes';

export interface Dependencies {
  authenticate(token: string | undefined): Promise<{ id: string } | null>;
  notes: NoteRepository;
}

const noteBody = {
  type: 'object',
  additionalProperties: false,
  required: ['title'],
  properties: {
    title: { type: 'string', minLength: 1, maxLength: 120, pattern: '\\S' },
  },
} as const;

const noteParams = {
  type: 'object',
  required: ['id'],
  properties: { id: { type: 'string', minLength: 1, maxLength: 64 } },
} as const;

const noteResponse = {
  type: 'object',
  additionalProperties: false,
  required: ['id', 'title'],
  properties: { id: { type: 'string' }, title: { type: 'string' } },
} as const;

export function buildApi(deps: Dependencies) {
  const server = Fastify({ ajv: { customOptions: { removeAdditional: false } } });
  const notes = makeNoteService(deps.notes);

  async function userId(authorization: string | undefined): Promise<string> {
    const token = authorization?.match(/^Bearer (.+)$/)?.[1];
    const user = await deps.authenticate(token);
    if (!user) {
      throw new ApiError(401, 'authentication_required', 'Sign in to continue.');
    }
    return user.id;
  }

  server.setErrorHandler((error, request, reply) => {
    if ('validation' in error) {
      return reply.code(400).send({ code: 'invalid_input', message: 'Check the supplied fields.' });
    }
    if (error instanceof ApiError) {
      return reply.code(error.status).send({ code: error.code, message: error.message });
    }
    request.log.error({ err: error }, 'Request failed');
    return reply.code(500).send({ code: 'internal_error', message: 'Request could not be completed.' });
  });

  server.post<{ Body: { title: string } }>(
    '/api/notes',
    { schema: { body: noteBody, response: { 201: noteResponse } } },
    async (request, reply) => {
      const ownerId = await userId(request.headers.authorization);
      const note = await notes.create(ownerId, request.body);
      return reply.code(201).send(publicNote(note));
    },
  );

  server.get<{ Params: { id: string } }>(
    '/api/notes/:id',
    { schema: { params: noteParams, response: { 200: noteResponse } } },
    async request => {
      const ownerId = await userId(request.headers.authorization);
      const note = await notes.get(ownerId, request.params.id);
      return publicNote(note);
    },
  );

  return server;
}

The explicit publicNote mapper prevents ownerId from entering the response object. A response schema adds another output boundary; it is not a substitute for checking what your application intentionally exposes. additionalProperties: false on the body, together with this example’s Ajv configuration, lets tests reject a client-supplied ownerId instead of silently accepting or stripping it. Fastify’s coercion and error defaults depend on its configuration, so test the behavior of your installed version. Fastify documents its schema validation and default error behavior.

Check validation: An empty title, a whitespace-only title, an extra ownerId, or a missing title should get 400; a valid body should reach the service. If your chosen validator transforms input, explicitly test the transformed value rather than assuming JSON and TypeScript types line up automatically.

4. Verify identity, then authorize the requested resource

Authentication answers “who made this request?” Authorization answers “may this person act on this record?” In the factory above, authenticate establishes identity; findOwned(id, ownerId) decides whether that identity can read the requested note. A token parsed from the header is still untrusted until your authentication adapter validates it. Never use a user ID in a request body as a shortcut for either step.

When adapting this example to our Notes + AI starter, follow the login-to-database walkthrough. That starter’s HTTP adapter authenticates an opaque session cookie, uses the framework Auth service, and scopes note queries to their owner; the Auth package does not magically register your product’s login routes. If you keep cookie sessions instead of this example’s Bearer transport, preserve the starter’s origin checks on state-changing browser requests and its cookie protections. Do not accept arbitrary bearer strings just because a test fixture does.

For a multi-tenant API, decide whether membership, role, and ownership are independent checks. The query might constrain both workspaceId and ownerId, or call an explicit policy before a workspace-scoped lookup. Your choice should reflect your data model, not the shape of the URL. A 403 can communicate a known, visible resource for which an authenticated user lacks a permitted operation; a 404 is often appropriate when the product should not disclose whether another tenant’s private record exists. Apply a consistent policy to reads, updates, deletes, exports, and related background work.

Check authorization: Create one note as user A; request it as user B. Both the response and the service lookup must behave as if B cannot see it. Repeat this test for each new operation, not only the initial GET route.

5. Classify errors without exposing internals

The error handler gives clients stable categories: 400 for malformed input, 401 for an unverified caller, 404 for an unavailable note, and 500 for an unexpected failure. It intentionally does not send raw validation internals, database errors, stack traces, or provider responses to clients. Fastify’s default validation responses may include schema details, so an explicit error policy matters when those details should stay private. Fastify explains validation error handling.

In a larger service, add a deliberate 409 for real conflicts, 403 for authorized-but-forbidden operations where disclosure is acceptable, and 429 for rate limiting. Do not turn every exception into 400: a broken database connection is not a bad client request. Pair the safe external error with an internal request ID and redacted structured logging so the team can investigate failures without logging credentials or raw private payloads. For AI endpoints, give provider failures and exhausted limits distinct operational treatment; do not disguise a failed model call as a completed response.

Check error behavior: Force the repository’s create() method to throw. The HTTP response should have 500 and the generic public message, with no original exception text. Configure your production logger’s redaction before sending realistic sessions, personal data, or model prompts through the route.

6. Test the boundary without a database or network listener

Use an in-memory repository only for fast architecture tests, not as a claim that it reproduces a real database’s concurrency, transactions, or query filtering. Fastify’s inject() exercises the registered routes without listening on a TCP port; our minimal HTTP app test uses the same separation of construction and process startup. Fastify’s testing guide documents HTTP injection.

// tests/notes.test.ts
import { expect, it } from 'vitest';
import { buildApi } from '../server/http/notes';
import type { Note, NoteRepository } from '../server/features/notes';

it('validates input, verifies identity, and scopes note reads', async () => {
  const rows: Note[] = [];
  const repository: NoteRepository = {
    async create(input) {
      const note = { id: String(rows.length + 1), ...input };
      rows.push(note);
      return note;
    },
    async findOwned(id, ownerId) {
      return rows.find(note => note.id === id && note.ownerId === ownerId) ?? null;
    },
  };
  const api = buildApi({
    notes: repository,
    authenticate: async token =>
      token === 'owner' ? { id: 'u1' } :
      token === 'stranger' ? { id: 'u2' } : null,
  });
  const owner = { authorization: 'Bearer owner' };
  const stranger = { authorization: 'Bearer stranger' };

  try {
    const unauthenticated = await api.inject({
      method: 'POST', url: '/api/notes', payload: { title: 'Plan' },
    });
    expect(unauthenticated.statusCode).toBe(401);

    const invalid = await api.inject({
      method: 'POST', url: '/api/notes', headers: owner,
      payload: { title: 'Plan', ownerId: 'u2' },
    });
    expect(invalid.statusCode).toBe(400);

    const created = await api.inject({
      method: 'POST', url: '/api/notes', headers: owner,
      payload: { title: '  Plan  ' },
    });
    expect(created.statusCode).toBe(201);
    expect(created.json()).toEqual({ id: '1', title: 'Plan' });
    expect(rows[0].ownerId).toBe('u1');

    const id = created.json().id as string;
    expect((await api.inject({ url: `/api/notes/${id}`, headers: stranger })).statusCode).toBe(404);
    expect((await api.inject({ url: `/api/notes/${id}`, headers: owner })).json())
      .toEqual({ id, title: 'Plan' });
  } finally {
    await api.close();
  }
});

Run npx vitest run tests/notes.test.ts and your project’s TypeScript check. Expect the test to pass only after the files, dependencies, and project configuration are in place. The fake credential strings above are intentionally confined to the test. They must never replace token verification in a deployed adapter.

Then add a second tier of tests against a disposable database and the real Auth implementation: migrate a test schema, register or sign in two accounts, exercise the owner and stranger paths, and close the server and application resources. Our auth walkthrough’s HTTP tests demonstrate that distinction. A passing in-memory test cannot prove a production query actually includes the owner predicate.

7. Move long-running work out of the request when needed

Keep a small, bounded operation synchronous when the client needs its result immediately. For a report, import, email, or potentially long-running model operation, consider persisting application state, dispatching a job, and returning a tracking ID. An HTTP 202 Accepted says processing has been accepted, not completed; offer an authenticated status endpoint if users need to see the outcome. MDN explains the semantics of 202.

For db3.ai applications already configured with Queue, the handoff in a service or route can look like this:

// Inside a booted application with queue tables and a registered job
import { GenerateReportJob } from '../jobs/GenerateReportJob';

const jobId = await application.queue.dispatch(
  new GenerateReportJob({ reportId }),
  { queue: 'reports' },
);
return reply.code(202).send({ reportId, jobId });

This is an integration sketch, not a standalone route: application, reply, and reportId come from your authenticated application handler. Create and authorize the report first; register the job class in the worker’s application bootstrap, configure queue tables, and run a worker for reports. The returned job ID means work was queued, not that a report exists yet. Our Queue walkthrough shows the real job class, registration, migrations, dispatch, and worker command.

A queued AI operation should carry stable IDs, not request objects, browser tokens, or a large prompt with secrets. The worker must recheck the current record and permissions appropriate to the task, handle provider timeouts and rate limits, and make retries safe for external side effects. Queue dispatch and a separate database write are not automatically one atomic transaction; design for partial failures and idempotent processing. Record a correlation ID and the job’s outcome so a client can distinguish queued, running, failed, and complete states.

Check the handoff: Stop the worker, dispatch a report, and verify your API returns 202 with a status identifier while the report remains pending. Restart the worker on the matching named queue and verify its state transition. If no worker is running, a successful dispatch is not successful completion.

8. Review the API before adding a third endpoint

Use this short architecture check as a release gate, not as a substitute for actual tests:

  • Contract: Is each route’s success response documented, and are input and output validated or deliberately mapped?
  • Identity and access: Is identity verified by a trusted server-side adapter, and are lookups scoped to the caller or an explicit workspace policy?
  • Persistence: Do database queries enforce ownership rather than relying on route code to filter a loaded record?
  • Failure: Are validation, authentication, missing-resource, conflict, provider, and unexpected errors distinguishable without leaking internals?
  • Tests: Do both the injected HTTP test and at least one real database/Auth test cover an owner, a stranger, invalid input, and cleanup?
  • Operations: If work leaves the request, can a worker restore it, retry safely, and expose an authorized completion status?

At this point, you have a private, validated, testable API boundary and a clear place to attach real storage and authentication. The most useful next action is to replace the repository and authenticate test adapters with your application’s database and Auth implementations, then rerun the stranger-access test against that real stack. Start from our working HTTP example if you need the server factory, or use the starter’s login and private-note guide if you are ready to connect the entire flow.