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.
A [id].ts file exposes id as a route parameter.
- Express: read it from
req.params.id. Its documented type comes from thedynamicParameterTypeconfig ("string"by default). - Elysia: the segment becomes
:id; read it from the typedparamscontext.
Defining a route
- Express
- Bun/Elysia
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.
_getSwagger is typed without requestBody (a GET carries no body). Use query
parameters instead, declared in parameters.
A route file export defaults either a native Elysia instance or a class
extending BaseApiService. Use relative paths inside the instance; Efesto mounts it
at the prefix derived from the file. The two forms can coexist in one project.
Native Elysia — full access to the Elysia API:
import { Elysia, t } from "efesto/elysia";
const users = [{ id: 1, name: "Ada" }];
export default new Elysia()
.get("/", () => users, {
detail: { summary: "List users", tags: ["User"] },
})
.post("/", ({ body }) => ({ id: users.length + 1, ...body }), {
body: t.Object({ name: t.String() }),
detail: { summary: "Create user", tags: ["User"] },
});
Class-based BaseApiService — keeps docs, validation/permission, and implementation
visually separated, written exactly like the Express BaseApiService (only the
handler and validation differ):
import { BaseApiService, type Context, RouteValidation, SwaggerOptions, t } from "efesto/elysia";
const users = [{ id: 1, name: "Ada" }];
export default class extends BaseApiService {
_getSwagger: SwaggerOptions = { operationId: "listUsers", summary: "List users" }; // -> OpenAPI
_get() {
return users;
}
_postSwagger: SwaggerOptions = { operationId: "createUser", summary: "Create user" };
_postValidation: RouteValidation = { body: t.Object({ name: t.String() }) }; // TypeBox
_post({ body }: Context) {
return { id: users.length + 1, name: (body as { name: string }).name };
}
}
Supported verbs: get, post, put, patch, delete, head, options. Per
verb declare _<verb> (handler, method syntax), _<verb>Validation
({ body, query, params, response } TypeBox schemas), _<verb>Swagger (SwaggerOptions:
OpenAPI metadata + permission), and _<verb>Multer / _<verb>MultipleMulter for
uploads (post/patch/put). Efesto translates each into a native Elysia route, so the
class form has no runtime cost over the native form.
Cross-cutting concerns
Each concern has a dedicated guide with both stacks. Here is where each is declared:
| Concern | Express | Bun/Elysia | Guide |
|---|---|---|---|
| Documentation | _<verb>Swagger + swaggerModel | docs / native detail | Swagger |
| Validation | _<verb>Validation (express-validator) | body/query/params (TypeBox) | Validation |
| Permissions | _<verb>Swagger.permission | permission on the verb | ABAC |
| Authentication | global + _<verb>OverrideAuth | setup (onBeforeHandle) | Authentication |
| Caching | _<verb>Swagger.cache/purgeKey | native (Elysia plugins) | Caching |
| File uploads | _<verb>Multer flags | native 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 then-acategory 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@Useranywhere. See Magic Types. - Keep handlers thin. Validation, permission, and documentation are declarative; the handler should be business logic only.