@furystack/rest

REST

Type-safe REST API contracts shared between server and client, with endpoint generators driven by DataSet tokens.

Always reflects the latest API. · Last verified

FuryStack splits REST into three packages so the same TypeScript interface drives the server, the client, and any tooling in between:

PackageRole
@furystack/restDefine the API as a TypeScript interface (no runtime).
@furystack/rest-serviceImplement the API server-side, with optional auth and CORS.
@furystack/rest-client-fetchCall the API from the browser (or Node 22+) via native fetch.

Share the API interface from a common workspace; both ends import the same type.

1. Design the API

// common/src/index.ts
import type {
  RestApi,
  GetCollectionEndpoint,
  GetEntityEndpoint,
  PostEndpoint,
  PatchEndpoint,
  DeleteEndpoint,
} from '@furystack/rest';

export interface TodoItem {
  id: string;
  title: string;
  completed: boolean;
}

export interface TodoApi extends RestApi {
  GET: {
    '/todos': GetCollectionEndpoint<TodoItem>;
    '/todos/:id': GetEntityEndpoint<TodoItem, 'id'>;
  };
  POST: {
    '/todos': PostEndpoint<TodoItem, 'id'>;
  };
  PATCH: {
    '/todos/:id': PatchEndpoint<TodoItem, 'id'>;
  };
  DELETE: {
    '/todos/:id': DeleteEndpoint<TodoItem, 'id'>;
  };
}

Each endpoint type spells out body, url, query, headers, and result. The shorthand types (GetCollectionEndpoint, PatchEndpoint, etc.) cover the standard CRUD shapes; for custom endpoints, declare your own type with the fields you need.

2. Implement the server

The endpoint generators take a DataSetToken directly — no need to pass (Model, 'primaryKey') tuples.

// service/src/index.ts
import { createInjector } from '@furystack/inject';
import {
  Authenticate,
  createDeleteEndpoint,
  createGetCollectionEndpoint,
  createGetEntityEndpoint,
  createPatchEndpoint,
  createPostEndpoint,
  useHttpAuthentication,
  useRestService,
} from '@furystack/rest-service';
import { TodoDataSet } from './todo-dataset.js';
import type { TodoApi } from 'common';

const injector = createInjector();
useHttpAuthentication(injector);

await useRestService<TodoApi>({
  injector,
  port: 3000,
  root: '/api',
  cors: { credentials: true, origins: ['http://localhost:8080'] },
  api: {
    GET: {
      '/todos': Authenticate()(createGetCollectionEndpoint(TodoDataSet)),
      '/todos/:id': Authenticate()(createGetEntityEndpoint(TodoDataSet)),
    },
    POST: {
      '/todos': Authenticate()(createPostEndpoint(TodoDataSet)),
    },
    PATCH: {
      '/todos/:id': Authenticate()(createPatchEndpoint(TodoDataSet)),
    },
    DELETE: {
      '/todos/:id': Authenticate()(createDeleteEndpoint(TodoDataSet)),
    },
  },
});

Authorization, modifiers, and events configured on the DataSet apply automatically. Authenticate() short-circuits to a 401 for unauthenticated requests.

Custom endpoints

When the CRUD generators don’t fit, write the handler yourself. The handler receives a per-request injector, request/response objects, and helpers for body, query, url, and headers:

import { JsonResult, useRestService, HttpUserContext } from '@furystack/rest-service';

await useRestService<MyApi>({
  injector,
  port: 3000,
  root: '/api',
  api: {
    POST: {
      '/todos/:id/complete': async ({ getUrlParams, injector }) => {
        const { id } = getUrlParams();
        const httpUser = injector.get(HttpUserContext);
        const user = await httpUser.getCurrentUser();
        const dataSet = injector.get(TodoDataSet);
        await dataSet.update(injector, id, { completed: true, completedBy: user.username });
        return JsonResult({ ok: true });
      },
    },
  },
});

The injector parameter is scoped to the request — disposable, isolated from sibling requests, and the right place to read HttpUserContext.

3. Authentication

useHttpAuthentication binds the HttpAuthenticationSettings and HttpUserContext tokens. Bind the throw-by-default UserStore and SessionStore to your persistent implementations first, and pass the user DataSetToken:

import { useHttpAuthentication, UserStore, SessionStore } from '@furystack/rest-service';
import { AppUserStore, AppSessionStore, AppUserDataSet } from './stores.js';

injector.bind(UserStore, ctx => ctx.inject(AppUserStore));
injector.bind(SessionStore, ctx => ctx.inject(AppSessionStore));

useHttpAuthentication(injector, {
  cookieName: 'sessionId',
  enableBasicAuth: true,
  userDataSet: AppUserDataSet,
});

For JWT-based auth, swap in useJwtAuthentication from @furystack/auth-jwt.

4. Consume the API

// frontend/src/api-client.ts
import { createClient } from '@furystack/rest-client-fetch';
import type { TodoApi } from 'common';

export const apiClient = createClient<TodoApi>({
  endpointUrl: 'http://localhost:3000/api',
});

const result = await apiClient({
  method: 'GET',
  action: '/todos',
});
// result.result is typed as TodoItem[]

IntelliSense walks you through methodaction → required fields (url, body, query, headers) for the picked action, and the response type is inferred from the API interface.

What you get for free

  • End-to-end type safety. Change the API interface, recompile, every mismatch lights up.
  • Telemetry. injector.get(ServerTelemetryToken).subscribe('onApiRequestError', ...) to wire logging or alerting.
  • Static files via useStaticFiles({ injector, baseUrl, path, port }).
  • HTTP and WebSocket proxying via useProxy({ injector, sourceBaseUrl, targetBaseUrl, ... }).

Where to look next

  • Data Validation — runtime validation of request payloads against the API interface.
  • Repository — DataSets that back your endpoint generators.