Skip to content

Custom API endpoints

View as Markdown

Generated table routes name CRUD operations. A custom endpoint names an application operation: complete, approve, publish, import, or reconcile. Its adapter joins a shared wire contract to authorization and a domain workflow.

Create packages/api/app/complete-task.ts. The adapter reads request-scoped dependencies from Hono, checks the feature ability, and translates expected domain failures. Database rules remain in the workflow rather than accumulating in the route file.

import { forbidUnless, TsRestApi, type SapportaEnv } from "@sapporta/server";
import { completeTaskContract } from "task-app-shared";
import {
completeTask,
taskCompletionErrorResponse,
} from "../modules/tasks/complete-task.js";
const api = new TsRestApi<SapportaEnv>();
api.register(
"completeTask",
completeTaskContract.completeTask,
({ c, request }) => {
try {
const auth = c.get("auth");
forbidUnless(c, auth.ability.can("run", "task_completion"));
return {
status: 200,
body: completeTask({ db: c.get("db"), auth }, request.params.id),
};
} catch (error) {
return taskCompletionErrorResponse(error);
}
},
);
export default api;

The operation ID passed to register() becomes the OpenAPI operation name. The contract path remains /tasks/:id/complete; the runtime URL gains /api when the application mounts its API tree.

This is the only catch for the expected workflow family. taskCompletionErrorResponse() returns the declared 404 or 409 pair and rethrows anything outside TaskCompletionError. The adapter-generated 400 comes from request parsing before this handler runs. Shared infrastructure 401 and 403 responses are documented outside the feature contract because authentication or forbidUnless() can terminate the request before the workflow.

The named task_completion ability is useful for roles that should receive only this operation. A generated project owner already has manage on all, which subsumes that named check; adding a duplicate owner grant is illustrative, not cumulative configuration.

A file under packages/api/app/ is inert until loadApp() imports it. app.route() mounts Hono handlers. app.extend() copies the sub-app’s current contract emitters into the combined OpenAPI document. The calls are independent: one can create a working but undocumented route, or a documented route that returns 404.

Update packages/api/app.ts:

import type {
ProjectDbConnection,
SapportaEnv,
TsRestApi,
} from "@sapporta/server";
import completeTaskApi from "./app/complete-task.js";
import type { SapportaMailer } from "./mailer.js";
export interface LoadAppOptions {
conn: ProjectDbConnection;
mailer: SapportaMailer;
}
function mountApi(app: TsRestApi<SapportaEnv>, api: TsRestApi<SapportaEnv>) {
app.route("/", api);
app.extend(api);
}
export function loadApp(app: TsRestApi<SapportaEnv>, _options: LoadAppOptions) {
mountApi(app, completeTaskApi);
}

Keep the generated sample mounts or other application routes alongside this one. Register every child route before loadApp() mounts and extends that child, and finish both operations before the application mounts OpenAPI. extend() snapshots the child’s current documentation emitters; routes added afterward will be missing from that snapshot.

Add the endpoint to publicApiRoutes only when anonymous callers should be able to reach it. A public entry bypasses only the anonymous gate; the handler must still check an ability and apply row security.

Build first, then start the app and inspect the exact mounted route:

Terminal window
pnpm build
pnpm dev

In another terminal:

Terminal window
pnpm exec sapporta endpoints show "POST /api/tasks/{id}/complete"
pnpm exec sapporta api post "/api/tasks/$TASK_ID/complete" --body '{}'

The generic CLI command receives the mounted path, including /api. A successful response has the contract’s wire shape:

{
"task_id": 1,
"event_id": 4,
"status": "completed"
}

Calling the same task again after the first transaction commits returns the declared conflict:

{
"error": "Task is already completed",
"code": "TASK_ALREADY_COMPLETED"
}

Use an authenticated member without run access for a boundary check, and use a task from another workspace for a row-visibility check. The first should fail at the ability edge. The second should be indistinguishable from a missing task because the workflow scopes its database read.

That 409 is a sequential already-completed result. The workflow does not promise the same feature response for simultaneous writers in different processes or database connections.

The adapter translates requests into workflow input and semantic failures into declared HTTP responses. The workflow owns the transaction.