palinxdocs

API endpoints

API endpoints serve JSON. They live under src/api/, use the same dependency injection as the rest of your app, and are served under the /api prefix.

Defining an endpoint

An endpoint is a class that extends Api, decorated with @api(basePath). Each handler is a method decorated with an HTTP-method decorator:

import { Api, api, get, post, inject } from "@palinx/core";
import { DatabaseService } from "../services/db.service";

@api("/tasks")
export default class Tasks extends Api {
  private db = inject(DatabaseService);

  @get("/")
  async list() {
    const tasks = this.db.conn
      .query("SELECT id, title, done FROM tasks ORDER BY created_at DESC")
      .all();
    return Response.json(tasks);
  }

  @post("/")
  async create(req: Request) {
    const { title } = await req.json();
    if (!title) {
      return Response.json({ error: "Title is required" }, { status: 400 });
    }
    this.db.conn.run("INSERT INTO tasks (title) VALUES (?)", [title]);
    return Response.json({ title, done: false }, { status: 201 });
  }
}

@api("/tasks") mounts this class under /api/tasks. The @get("/") handler answers GET /api/tasks; the @post("/") handler answers POST /api/tasks.

HTTP methods

Each HTTP verb has a decorator:

Decorator Method
@get(path) GET
@post(path) POST
@put(path) PUT
@patch(path) PATCH
@del(path) DELETE

The path is relative to the class's base path. Combine them for a full REST surface on one class.

The request and response

Handlers receive the standard Request. Read a JSON body with await req.json().

What a handler returns becomes the response:

Return Response
a Response sent as it is
an object, array, number or boolean 200, as JSON
a string 200, as plain text
undefined or null 204 No Content

So the simplest handler returns its data:

@get("/")
list() {
  return this.db.conn.query("SELECT * FROM tasks").all();
}

Return a Response yourself when you need a status other than 200, or your own headers, with Response.json(data, init):

@post("/")
async create(req: Request) {
  const body = await req.json();
  return Response.json({ received: body }, { status: 201 });
}

A value that can't be sent as JSON, such as an object that refers to itself, gives a 500, and the log names the route. To send a file, a stream or raw bytes (a Blob, a ReadableStream, an ArrayBuffer), return new Response(value).

Route parameters

Declare a parameter in the decorator path with :name. Handlers receive the captured values as the second argument:

@post("/:id/toggle")
async toggle(req: Request, { params }: { params: Record<string, string> }) {
  this.db.conn.run("UPDATE tasks SET done = NOT done WHERE id = ?", [params.id]);
  return Response.json({ ok: true });
}

@del("/:id")
async remove(req: Request, { params }: { params: Record<string, string> }) {
  this.db.conn.run("DELETE FROM tasks WHERE id = ?", [params.id]);
  return Response.json({ ok: true });
}

POST /api/tasks/42/toggle binds params.id to "42". Params are decoded the same way as page params: %20 becomes a space, while %2F and %5C stay encoded, and a malformed encoding gets a 400 before your handler runs. See Routing & layouts.

Using services

API endpoints inject services exactly like the rest of your app. The example above pulls in DatabaseService; an authenticated endpoint would inject an auth service and check the request before proceeding:

import { Api, api, get, inject } from "@palinx/core";
import { AuthService } from "../services/auth.service";
import { DatabaseService } from "../services/db.service";

@api("/tasks")
export default class Tasks extends Api {
  private auth = inject(AuthService);
  private db = inject(DatabaseService);

  @get("/")
  async list(req: Request) {
    const user = this.auth.getAuthenticatedUser(req);
    if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });

    const tasks = this.db.conn
      .query("SELECT id, title, done FROM tasks WHERE user_id = ?")
      .all(user.id);
    return Response.json(tasks);
  }
}

Errors

When a handler throws instead of returning a response, palinx answers for it with a JSON body of the form {"error": "..."} and writes the full error, stack included, to the server's log.

In production (px serve, or running the build directly) an unexpected error gets a generic answer, so nothing about your code or server reaches the client:

{ "error": "Internal server error" }

with status 500. The real message is in the log, labelled with the request that failed, such as GET /api/tasks/42.

Some errors are meant for the caller: "Not found", "Title is required". To send one, throw an error with a status (or statusCode) property set to a whole number from 400 to 499. palinx uses that status and passes your message through, in production too:

class HttpError extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}

@get("/:id")
async show(req: Request, { params }: { params: Record<string, string> }) {
  const task = this.db.conn.query("SELECT * FROM tasks WHERE id = ?").get(params.id);
  if (!task) throw new HttpError(404, "Task not found");
  return Response.json(task);
}

A request for a missing task gets a 404 with {"error": "Task not found"}. Any other status on a thrown error, such as 503, 302, or the string "404", is treated as a server error: the client gets the generic 500.

During px dev, errors still answer with status 500, but the body carries the error's real message so you can see what went wrong without opening the log.

Returning a response yourself, as in the examples above, is never changed. These rules only apply to errors a handler throws.

An error a handler leaves behind, such as a promise it started without awaiting that later rejects, or a throw inside a timer it scheduled, happens after the response, outside any request. It's written to the log with its stack, and the server keeps serving. Deployment covers the policy and how to change it.

Next

Configuration covers the settings that control the server, the router, and the build.