FrançaisPlayground

API contracts

@fluixi/api turns an API route into a contract: what it accepts, what it returns, who may call it. The contract validates the request before your code runs, shapes the response on the way out, describes the route in an OpenAPI document, and types a client that calls it.

// src/api/v1/projects/index.ts
import { route } from '@fluixi/api';
import { z } from 'zod';

export const POST = route({
  summary: 'Create a project',
  body: z.object({ name: z.string().min(1).max(80) }),
  response: Project,
  status: 201,
  handler: ({ body }) => createProject(body.name),
});

handler receives the validated input, already typed from the schemas: body.name is a string here, not unknown.

Input

Field Validates Reaches the handler as
params path parameters, from the file name ([id].ts gives { id }) params
query the query string, repeated keys as arrays query
body the JSON body body

Any Standard Schema works: Zod, Valibot, ArkType. Every problem is reported at once, not the first one only, as a 400:

{
  "error": {
    "code": "validation_failed",
    "message": "The request is not valid.",
    "details": [
      { "path": "body.name", "message": "String must contain at least 1 character(s)" },
      { "path": "query.limit", "message": "Expected number, received string" }
    ]
  }
}

Without a body schema the body is not read, and the handler may read it itself. With one, the body must be JSON (415 unsupported_media_type), must parse (400 invalid_json), and must stay under bodyLimit, 1 MiB by default (413 payload_too_large), checked against the declared length and against the bytes actually read.

Output

response is applied on the way out: unknown fields are dropped and transforms run. A field you keep on the server, such as createdBy or a password hash, never reaches the caller as long as the response schema does not name it.

export const GET = route({
  params: z.object({ id: z.string().uuid() }),
  response: Project,
  handler: ({ params }) => {
    const project = findProject(params.id);
    if (!project) throw notFound('No project with that id.');
    return project;
  },
});

status sets the success status: 200 by default, 204 when the handler returns nothing and the route has no response. A handler may also return a Response of its own, which goes out as it is.

If the handler returns something its own response schema rejects, that is a server fault: the caller gets a 500, never the bad data. Turn the check off with validateResponse: false only for a reason you have measured.

Errors

Every failure has one shape, { error: { code, message, details? } }, and a matching status. The code is for programs, the message for people.

import { ApiError, badRequest, conflict, forbidden, notFound, unauthorized } from '@fluixi/api';

throw notFound('No project with that id.');                  // 404 not_found
throw conflict('That name is taken.');                        // 409 conflict
throw new ApiError(402, 'plan_limit', 'Upgrade to add more.'); // your own

Anything else a handler throws becomes a 500 internal with a generic message: nothing from the server's internals reaches the caller. The error itself goes to console.error, or to your logger or tracker with setErrorReporter((error, request) => ...).

Guards

A guard decides who may call a route. It runs before the input is read, so a caller who may not use the route learns nothing about what it accepts, and what it returns reaches the handler as auth, typed.

import { member, signedIn } from '@fluixi/auth/server';

export const GET = route({
  guard: signedIn(),
  handler: ({ auth }) => auth.user,
});

export const DELETE = route({
  guard: member({ roles: ['owner', 'admin'] }),
  params: z.object({ id: z.string() }),
  handler: ({ auth, params }) => projects.remove(auth.member.organizationId, params.id),
});

signedIn() refuses with 401 when there is no session. member() also needs an active organisation the caller belongs to, and checks roles (any of them) and permissions (all of them). Its refusals say why: 403 no_active_organization, 403 not_a_member, 403 forbidden. auth.member.organizationId is the tenant: pass it to every query the route makes.

A guard is any object with check(request), which returns what the handler gets or throws a refusal:

const apiKey = {
  async check(request: Request) {
    const key = await keys.find(request.headers.get('x-api-key'));
    if (!key) throw unauthorized('Send a valid x-api-key.');
    return { key };
  },
  security: { name: 'apiKey', scheme: { type: 'apiKey', in: 'header', name: 'x-api-key' } },
  refuses: [401],
};

security and refuses are for the OpenAPI document: the scheme a caller satisfies, under a stable name, and the refusals the route can answer with. A refusal thrown with a code keeps it in the error body, so a client can tell "choose an organisation" from "not allowed".

Telling the browser what changed

revalidates names the cached data a successful call makes stale. The response carries it, and the client refreshes the pages showing it. See Caching data.

export const POST = route({ guard: signedIn(), body: invite, revalidates: ['members'], handler });

A typed client

@fluixi/start writes src/api.gen.ts in the API app: every path template mapped to its route module. A client typed with it knows, per method, the params, query and body each route takes and what it returns. Nothing from the server is bundled; the map is types only.

import { createClient } from '@fluixi/api/client';
import type { ApiRoutes } from '@acme/api/routes'; // the API package exports src/api.gen.ts

const api = createClient<ApiRoutes>();

const project = await api.get('/api/v1/projects/{id}', { params: { id } });
await api.post('/api/v1/projects', { body: { name: 'Atlas' } }); // checked against the body schema

A failure throws ApiClientError, with the status, the code and the details of the error body. Options: baseUrl (empty, the page's own origin, by default), headers (a function runs per call, for instance to forward the visitor's cookie during a server render), credentials, fetch, and onRevalidate.

Protecting the API

apiSecurity puts the usual protections in one middleware. Place it first, so refusals from everything after it carry the headers too.

// src/middleware.ts
import { defineMiddleware } from '@fluixi/start';
import { apiSecurity, postgresRateLimitStore } from '@fluixi/api';

export default defineMiddleware([
  apiSecurity({
    origins: ['https://app.example.com'],
    rateLimits: [
      {
        name: 'auth',
        match: ['/api/auth/sign-in/*', '/api/auth/sign-up/*'],
        methods: ['POST'],
        limit: 10,
        window: 60,
        store: postgresRateLimitStore(sql),
      },
    ],
  }),
]);

It adds security headers, answers CORS for the listed origins, refuses a cookie-carrying write from a page on another site (403 cross_origin_refused), and applies each rate limit in order (429 rate_limited). With no options, every cross-origin call is refused and nothing is limited: list your web app's origins, and limit the routes that guess passwords. Counts live in memory by default; a shared store (postgresRateLimitStore) keeps every instance on the same count.

The pieces are also exported on their own: securityHeaders, cors, originCheck, rateLimit.