Skip to content

Custom API endpoints

View as Markdown

Generated table routes cover ordinary row operations. A custom endpoint owns an application action whose authorization, transaction, response, or external effect does not fit one CRUD call.

This page connects a shared contract to a protected TsRestApi handler, mounts the route under /api, and makes the operation discoverable. The pattern supports approvals, state transitions, batch commands, exports, uploads, and integrations while keeping application rules in normal TypeScript.

Register the shared complete-task contract as a protected endpoint. Check the task-completion ability, delegate to the domain workflow, mount the sub-app under `/api`, include it in OpenAPI, and prove the route with the local CLI.

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,
TaskCompletionError,
} 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"));
const result = completeTask({ db: c.get("db"), auth }, request.params.id);
return { status: 200, body: result };
} catch (error) {
if (error instanceof TaskCompletionError) {
return {
status: error.status,
body: error.toResponseBody(),
};
}
throw 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.

A file under packages/api/app/ is inert until loadApp() imports and mounts it. app.route() mounts the Hono handlers. app.extend() carries the sub-app contracts into the combined OpenAPI document.

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. 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/1/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 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.

The endpoint now has four explicit layers: a shared wire contract, a route-edge permission check, a domain workflow, and an application mount. That separation keeps generated CRUD available for ordinary task editing while giving completion one coherent server action. The next useful steps are to test each declared response, call the operation through the typed client, and add a custom screen only when the workflow needs more UI than the generated task surface provides.