alepha@docs:~/docs/guides/server$
cat 1-building-an-api.md
5 min read
Last commit:

#Building an API

So you have the server running. Now you need to actually do something with it.

In Alepha, we don't write controllers full of decorators, and we don't write route handlers that are just untyped middleware functions. We write Actions.

#The $action Primitive

An $action is a type-safe HTTP endpoint. It bundles everything together:

  1. Route Configuration — Path, method, group
  2. Validation Schema — What goes in (body, query, params) and what comes out (response)
  3. Handler — The function that runs

It uses $route under the hood.

src/api/controllers/UserController.ts
 1import { t } from "alepha"; 2import { $action, NotFoundError } from "alepha/server"; 3  4class UserController { 5  users = [{ name: "John Doe", id: 1 }] 6  7  getUser = $action({ 8    path: "/users/:id", 9    schema: {10      params: t.object({ id: t.integer() }),11      response: t.object({12        id: t.integer(),13        name: t.text(),14      }),15    },16    handler: async ({ params }) => {17      const user = this.users.find(u => u.id === params.id);18      if (!user) {19        throw new NotFoundError("User not found");20      }21      return user;22    },23  });24}

The /api Prefix

All $action paths are automatically prefixed with /api. So path: "/users" becomes /api/users. This keeps your API cleanly separated from pages and static files. Configure it via SERVER_API_PREFIX environment variable if needed.

#The Schema Object

The schema is where the magic happens. It defines everything about your request and response — and it's not just validation.

Your schema tells Alepha:

  • How to parse data — JSON body? Form data? Raw text? Alepha figures it out from your schema.
  • Which HTTP status to use — Return void? That's a 204 No Content. Return an object? That's 200 OK.
  • Which headers to expect — Define a headers schema and Alepha validates them before your handler runs.
  • How to serialize the response — The response schema strips undeclared fields. No accidental data leaks.

#params — URL Path Variables

When your URL has :something, that's a param.

src/api/controllers/UserController.ts
 1getUser = $action({ 2  path: "/users/:id", 3  schema: { 4    params: t.object({ 5      id: t.uuid(),  // /api/users/550e8400-e29b-41d4-a716-446655440000 6    }), 7  }, 8  handler: async ({ params }) => { 9    // params.id is typed as string, validated as UUID10  },11});
src/api/controllers/PostController.ts
 1// Multiple params work too 2getComment = $action({ 3  path: "/posts/:postId/comments/:commentId", 4  schema: { 5    params: t.object({ 6      postId: t.uuid(), 7      commentId: t.uuid(), 8    }), 9  },10  handler: async ({ params }) => {11    // params.postId, params.commentId12  },13});

#query — URL Query Parameters

The stuff after the ?. Perfect for filtering, pagination, sorting.

src/api/controllers/UserController.ts
 1listUsers = $action({ 2  path: "/users", 3  schema: { 4    query: t.object({ 5      page: t.optional(t.integer({ minimum: 1, default: 1 })), 6      limit: t.optional(t.integer({ minimum: 1, maximum: 100, default: 20 })), 7      search: t.optional(t.text()), 8      role: t.optional(t.enum(["admin", "user", "guest"])), 9    }),10    response: t.object({11      users: t.array(userSchema),12      total: t.integer(),13    }),14  },15  handler: async ({ query }) => {16    // query.page defaults to 1 if not provided17    // query.limit defaults to 2018    // query.search is string | undefined19    // query.role is "admin" | "user" | "guest" | undefined20    return await db.users.findMany({21      skip: (query.page - 1) * query.limit,22      take: query.limit,23      where: { name: { contains: query.search }, role: query.role },24    });25  },26});

#body — Request Body

The payload. When you define a body schema, Alepha:

  • Defaults the method to POST (you can override this)
  • Parses the JSON body
  • Validates it against the schema
  • Throws 400 Bad Request if validation fails — before your handler even runs
src/api/controllers/UserController.ts
 1createUser = $action({ 2  path: "/users", 3  // method: "POST" is implicit when body is defined 4  schema: { 5    body: t.object({ 6      name: t.text({ minLength: 2, maxLength: 100 }), 7      email: t.email(), 8      password: t.text({ minLength: 8 }), 9      role: t.optional(t.enum(["user", "admin"])),10    }),11    response: t.object({12      id: t.uuid(),13      name: t.text(),14      email: t.email(),15    }),16  },17  handler: async ({ body }) => {18    // body is fully validated here19    // body.name has 2-100 chars, body.email is a valid email20    // body.password has at least 8 chars21    // body.role is "user" | "admin" | undefined22    const user = await db.users.create(body);23    return user;24  },25});

#headers — Request Headers

Sometimes you need to read custom headers.

src/api/controllers/WebhookController.ts
 1webhookHandler = $action({ 2  path: "/webhooks/stripe", 3  method: "POST", 4  schema: { 5    headers: t.object({ 6      "stripe-signature": t.text(), 7    }), 8    body: t.string(), // raw body for signature verification 9  },10  handler: async ({ headers, body }) => {11    const signature = headers["stripe-signature"];12    // verify webhook signature...13  },14});

#response — The Most Important Schema

Here's where developers often cut corners. Don't.

The response schema isn't just documentation. It's active serialization.

src/api/controllers/UserController.ts
 1getUser = $action({ 2  schema: { 3    params: t.object({ id: t.uuid() }), 4    response: t.object({ 5      id: t.uuid(), 6      name: t.text(), 7      email: t.email(), 8      // notice: no password field 9    }),10  },11  handler: async ({ params }) => {12    const user = await db.users.findById(params.id);13    // user has { id, name, email, password, createdAt, ... }14    return user;15    // Alepha strips everything not in response schema16    // Client receives { id, name, email } only17  },18});

Why this matters:

  1. Security — Accidentally returning password, internalNotes, or deletedAt? The response schema strips them out. You can't leak what you don't declare.

  2. Contract Enforcement — Your API promises a shape. The schema enforces it. If your handler returns something wrong, you'll know immediately.

  3. Client Type Safety — When using $client, the response type is inferred from the schema. Your frontend code gets autocomplete.

  4. OpenAPI Documentation — Swagger UI shows exactly what clients will receive.

Without a response schema, you're flying blind. With one, you have a contract.

#Path is Optional

Let's be honest: REST can be annoying.

You spend more time debating "should it be /users/:id/posts or /posts?userId=:id" than actually building features. PUT vs PATCH wars. Plural vs singular nouns. It's exhausting.

Alepha makes all that optional. Don't want to think about paths? Don't. Alepha generates them from your property names. Don't want to specify HTTP methods? Don't. Alepha infers them from your schema. You can build a fully functional API without writing a single path or method — and it just works.

Of course, if you want clean RESTful URLs, you can have them. But you're not forced to. Ship first, bikeshed later.

If you don't specify a path, Alepha generates one from the property name:

src/api/controllers/UserController.ts
 1class UserController { 2  // path: "/getUser" → GET /api/getUser 3  getUser = $action({ 4    handler: async () => ({ name: "John" }), 5  }); 6  7  // path: "/createUser" → POST /api/createUser (because of body) 8  createUser = $action({ 9    schema: { body: t.object({ name: t.text() }) },10    handler: async ({ body }) => ({ id: "123", name: body.name }),11  });12}

Even better — if you have a params schema, the path auto-appends them:

src/api/controllers/UserController.ts
 1class UserController { 2  // Auto-generates path: "/getUser/:id" 3  getUser = $action({ 4    schema: { 5      params: t.object({ id: t.uuid() }), 6    }, 7    handler: async ({ params }) => { 8      return await db.users.findById(params.id); 9    },10  });11}

This convention-over-configuration approach means less boilerplate. But explicit paths are always clearer for complex routes.

#Method is Optional

Alepha infers the HTTP method:

  • No body schemaGET
  • Has body schemaPOST
src/api/controllers/UserController.ts
 1// GET /api/users 2listUsers = $action({ 3  path: "/users", 4  handler: async () => [], 5}); 6  7// POST /api/users (implicit because of body) 8createUser = $action({ 9  path: "/users",10  schema: { body: t.object({ name: t.text() }) },11  handler: async ({ body }) => ({ id: "1", name: body.name }),12});13 14// PUT /api/users/:id (explicit override)15updateUser = $action({16  method: "PUT",17  path: "/users/:id",18  schema: {19    params: t.object({ id: t.uuid() }),20    body: t.object({ name: t.text() }),21  },22  handler: async ({ params, body }) => {23    return await db.users.update(params.id, body);24  },25});26 27// DELETE /api/users/:id28deleteUser = $action({29  method: "DELETE",30  path: "/users/:id",31  schema: { params: t.object({ id: t.uuid() }) },32  handler: async ({ params }) => {33    await db.users.delete(params.id);34  },35});

#Grouping Actions

The group property organizes related actions together.

src/api/controllers/OrderController.ts
 1class OrderController { 2  group = "orders"; 3  4  list = $action({ 5    group: this.group, 6    path: "/orders", 7    handler: async () => [], 8  }); 9 10  create = $action({11    group: this.group,12    path: "/orders",13    schema: { body: orderSchema },14    handler: async ({ body }) => {},15  });16 17  get = $action({18    group: this.group,19    path: "/orders/:id",20    schema: { params: t.object({ id: t.uuid() }) },21    handler: async ({ params }) => {},22  });23}

What group does:

  1. Swagger/OpenAPI Tags — Actions with the same group appear together in the docs. Makes the API explorer navigable.

  2. Permission Names — When using alepha/server/security, permissions are generated as group:action. So orders:create, orders:list, etc.

If you don't set group, it defaults to the class name (OrderController).

#Other Options

#description

Document what the action does. Shows up in Swagger:

typescript
1createUser = $action({2  description: "Creates a new user account. Sends welcome email.",3  schema: { ... },4  handler: async ({ body }) => {},5});

#disabled

Kill switch. The route won't be registered, but the action can still be called directly via run():

typescript
1dangerousAction = $action({2  disabled: this.alepha.isProduction(),3  handler: async () => {4    // Only available in development5  },6});

#Quick Reference

Option Required Default Purpose
handler Yes The function that runs
path No /${propertyName} URL path
method No GET (or POST if body) HTTP method
schema.params No URL path variables
schema.query No URL query parameters
schema.body No Request body
schema.headers No Request headers
schema.response No Response body (serialization!)
group No Class name Tag for docs & permissions
name No Property name Action identifier
description No Swagger description
disabled No false Disable HTTP registration
On This Page
No headings found...
ready
mainTypeScript
UTF-8guides_server_building_an_api.md