Skip to content

Latest commit

 

History

History
259 lines (198 loc) · 8.03 KB

File metadata and controls

259 lines (198 loc) · 8.03 KB

Standard JSON Response & Error Format

Reference: #105 - Standard JSON Response & Error Format

All API routes in this project return a consistent JSON envelope so that the frontend and any consumers can handle responses uniformly.


Success shape

{
  "ok": true,
  "data": { ... },
  "meta": { "total": 42, "page": 1 }
}
Field Type Always present Description
ok true Discriminant — always true on success
data T The response payload
meta Record<string, unknown> Optional pagination / extra context

Error shape

{
  "ok": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "Commitment not found.",
    "details": { ... }
  }
}
Field Type Always present Description
ok false Discriminant — always false on error
error.code string Short machine-readable code (see table below)
error.message string Human-readable message safe for UI display
error.details unknown Extra context (never expose sensitive data here)

Error codes

Code HTTP status Error class
BAD_REQUEST 400 BadRequestError
VALIDATION_ERROR 400 ValidationError
UNAUTHORIZED 401 UnauthorizedError
FORBIDDEN 403 ForbiddenError
NOT_FOUND 404 NotFoundError
CONFLICT 409 ConflictError
TOO_MANY_REQUESTS 429 TooManyRequestsError
INTERNAL_ERROR 500 InternalError

Validation errors (VALIDATION_ERROR)

When a request fails Zod validation, the error response uses a canonical fieldErrors shape inside error.details so the UI can render field-level messages consistently:

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request data.",
    "details": {
      "fieldErrors": [
        { "field": "user.profile.name", "message": "Expected string, received number" },
        { "field": "items[0].qty",      "message": "Number must be greater than 0" },
        { "field": "",                  "message": "Password and confirmation do not match" }
      ]
    }
  }
}

Rules for field:

  • Dot notation for nested object keys (user.profile.name).
  • Bracket notation for array indices (items[0].qty, matrix[0][1]).
  • An empty string ("") denotes a root-level issue (for example a refinement applied to the whole body).
  • (field, message) pairs are deduplicated so the same message is never rendered twice for the same field.

Producing a canonical validation error

In any route wrapped with withApiHandler, convert the ZodError using validationErrorFromZod:

import { withApiHandler } from '@/lib/backend/withApiHandler';
import { validationErrorFromZod } from '@/lib/backend/validationErrors';

const BodySchema = z.object({ /* ... */ });

export const POST = withApiHandler(async (req) => {
    const parsed = BodySchema.safeParse(await req.json());
    if (!parsed.success) {
        throw validationErrorFromZod(parsed.error);
    }
    // ... happy path
});

mapZodErrorToFieldErrors(error) is available if you need the FieldError[] list on its own (for logging, composition with other details, etc.).


How to use

Returning a success response

import { ok } from '@/lib/backend/apiResponse';

// Simple success
return ok({ status: 'healthy' });
// → { ok: true, data: { status: 'healthy' } }

// With meta (e.g. pagination)
return ok(items, { total: 100, page: 2, pageSize: 20 });
// → { ok: true, data: [...], meta: { total: 100, page: 2, pageSize: 20 } }

Returning an error response

import { fail } from '@/lib/backend/apiResponse';

return fail('NOT_FOUND', 'Commitment not found.', undefined, 404);
// → { ok: false, error: { code: 'NOT_FOUND', message: 'Commitment not found.' } }

Using typed error classes (recommended)

Throw a typed error inside any route wrapped with withApiHandler — it will be caught and converted into the correct error response automatically.

import { withApiHandler } from '@/lib/backend/withApiHandler';
import { ok } from '@/lib/backend/apiResponse';
import { NotFoundError, ValidationError } from '@/lib/backend/errors';

export const GET = withApiHandler(async (req) => {
    const commitment = await findCommitment(id);
    if (!commitment) {
        throw new NotFoundError('Commitment');
        // → 404: { ok: false, error: { code: 'NOT_FOUND', message: 'Commitment not found.' } }
    }
    return ok(commitment);
});

Available error classes (all from @/lib/backend/errors):

Class Default status
BadRequestError 400
ValidationError 400
UnauthorizedError 401
ForbiddenError 403
NotFoundError 404
ConflictError 409
TooManyRequestsError 429
InternalError 500

Full route example (health check)

// src/app/api/health/route.ts
import { withApiHandler } from '@/lib/backend/withApiHandler';
import { ok } from '@/lib/backend/apiResponse';

export const GET = withApiHandler(async () => {
    return ok({ status: 'healthy' });
    // GET /api/health → 200 { ok: true, data: { status: 'healthy' } }
});

Client-side Fetch Helper (apiClient.ts)

A shared client-side fetch wrapper (src/lib/apiClient.ts) is available to make API requests with built-in:

  • Request Timeout: Aborts automatically after a timeout (default 5000ms).
  • Consistent Envelope Parsing: Automatically unwraps { ok: true, data: T } envelopes or parses standard error envelopes.
  • Typed Errors: Throws a subclass of Error called ApiError containing the API-returned error code, message, and details.

Basic Usage

Use convenience methods like apiGet, apiPost, apiPut, or apiDelete:

import { apiGet, ApiError } from '@/lib/apiClient';

interface Commitment {
  id: string;
  status: string;
}

async function loadData() {
  try {
    const commitments = await apiGet<Commitment[]>('/api/commitments');
    console.log(commitments);
  } catch (err) {
    if (err instanceof ApiError) {
      console.error(`API Error [${err.code}]: ${err.message}`);
    } else {
      console.error('Network or other error:', err);
    }
  }
}

Low-level Custom Requests

You can use the generic apiFetch for other methods or custom configurations:

import { apiFetch } from '@/lib/apiClient';

const data = await apiFetch<MyResponseType>(
  '/api/custom-endpoint',
  {
    method: 'PATCH',
    headers: { 'X-Custom-Header': 'value' },
  },
  10000 // custom 10s timeout
);

Files

File Purpose
src/lib/apiClient.ts Client-side API fetch client with timeout & typed errors
src/lib/backend/apiResponse.ts ok() and fail() response helpers
src/lib/backend/errors.ts Typed error classes with HTTP status codes
src/lib/backend/withApiHandler.ts HOF that catches ApiError and calls fail()
docs/api-response-format.md This document

Created as part of issue #105. Update this document when new error codes are introduced.