Skip to main content

Creating Endpoints

Endpoints are files. Where you put the file decides the URL; what you export decides the behavior. This page covers both stacks side by side, the routing rules are shared, the route definition differs.

File-based routing (both stacks)

A folder is a path segment. An index file maps to its folder. A [param] file becomes a route parameter. The file extension is dropped (.ts in development, .js in production, selected by isProduction).

routes/
├── users/
│ ├── index.ts # /users
│ ├── [id].ts # /users/:id
│ └── [id]/
│ └── posts.ts # /users/:id/posts
└── health.ts # /health

With a mount prefix of /api/v1, routes/users/[id].ts serves /api/v1/users/:id. See Filesystem Routing for the exact rules and an interactive map.

Dynamic parameters

A [id].ts file exposes id as a route parameter.

  • Express: read it from req.params.id. Its documented type comes from the dynamicParameterType config ("string" by default).
  • Elysia: the segment becomes :id; read it from the typed params context.

Defining a route

A route file export defaults a class extending BaseApiService. The class name does not matter; the constructor must call super(__filename) so Efesto can resolve the file's path parameters.

Each HTTP verb is two members: a handler _<verb>(req, res, next) and an optional documentation object _<verb>Swagger. The handler is plain Express, return a value or call res.json(...).

import { BaseApiService, SwaggerModel, SwaggerOptions } from "efesto";

class Users extends BaseApiService {
constructor() {
super(__filename);
}

// Shared schemas for this route, grouped under a model "category".
swaggerModel: SwaggerModel = {
modelName: "User",
schemas: [
{
name: "User",
properties: {
id: "number",
name: "string",
email: "string::email",
},
},
],
};

// GET /users
_getSwagger: SwaggerOptions = {
operationId: "getUsers",
summary: "List users",
responses: { 200: "@User[]" },
};
async _get(req, res) {
return res.json(await listUsers());
}

// POST /users
_postSwagger: SwaggerOptions = {
operationId: "createUser",
requestBody: "@User",
responses: { 201: "@User" },
};
async _post(req, res) {
return res.status(201).json(await createUser(req.body));
}
}
export default Users;

Supported verbs: _get, _post, _put, _patch, _delete, _head, _options, _trace, _connect. Each can declare its own _<verb>Swagger, _<verb>Validation, and _<verb>OverrideAuth.

GET has no request body

_getSwagger is typed without requestBody (a GET carries no body). Use query parameters instead, declared in parameters.

Cross-cutting concerns

Each concern has a dedicated guide with both stacks. Here is where each is declared:

ConcernExpressBun/ElysiaGuide
Documentation_<verb>Swagger + swaggerModeldocs / native detailSwagger
Validation_<verb>Validation (express-validator)body/query/params (TypeBox)Validation
Permissions_<verb>Swagger.permissionpermission on the verbABAC
Authenticationglobal + _<verb>OverrideAuthsetup (onBeforeHandle)Authentication
Caching_<verb>Swagger.cache/purgeKeynative (Elysia plugins)Caching
File uploads_<verb>Multer flagsnative t.File()File Uploads

A complete Express service

Putting documentation, validation, permission, and caching on one route:

import { BaseApiService, SwaggerModel, SwaggerOptions } from "efesto";
import { body, query } from "express-validator";

class Users extends BaseApiService {
constructor() {
super(__filename);
}

swaggerModel: SwaggerModel = {
modelName: "User",
schemas: [
{
name: "User",
properties: {
id: "number",
name: "string!", // required
email: "string::email!", // required, email format
age: "number?", // optional
},
},
],
};

_getValidation = [query("page").optional().isInt({ min: 1 })];
_getSwagger: SwaggerOptions = {
operationId: "getUsers",
summary: "List users",
permission: ["readAll", "User"],
cache: { key: "`users`", expiresInSeconds: 120 },
responses: { 200: "@User[]" },
};
async _get(req, res) {
return res.json(await listUsers(req.query));
}

_postValidation = [body("name").isString(), body("email").isEmail()];
_postSwagger: SwaggerOptions = {
operationId: "createUser",
permission: ["create", "User"],
purgeKey: ["`users`"], // invalidate the list cache on create
requestBody: "@User",
responses: { 201: "@User" },
};
async _post(req, res) {
return res.status(201).json(await createUser(req.body));
}
}
export default Users;

A complete Elysia service

The same shape on the Elysia stack, with TypeBox doing validation and typing:

import { BaseApiService, type Context, RouteValidation, SwaggerOptions, t } from "efesto/elysia";

const User = t.Object({
name: t.String(),
email: t.String({ format: "email" }),
age: t.Optional(t.Number()),
});

export default class extends BaseApiService {
_getSwagger: SwaggerOptions = { summary: "List users", permission: ["readAll", "User"] };
_getValidation: RouteValidation = { query: t.Object({ page: t.Optional(t.Numeric()) }) };
_get({ query }: Context) {
return listUsers(query);
}

_postSwagger: SwaggerOptions = { summary: "Create user", permission: ["create", "User"] };
_postValidation: RouteValidation = { body: User };
_post({ body }: Context) {
return createUser(body);
}
}

Best practices

  • Set modelName. Without it, endpoints are grouped under the n-a category in the generated docs. Use a clear noun ("User", "Product").
  • Reuse schemas with @. Efesto shares schemas across the whole project, declare a model once and reference it as @User anywhere. See Magic Types.
  • Keep handlers thin. Validation, permission, and documentation are declarative; the handler should be business logic only.