Tutorial 31 of 54 · for anyone whose app needs endpoints a script or another service can call · Markdown

Answer an API route

You'll write a JSON API with a GET and a checked POST, answer a plain text download with your own Response and wrap every API call in middleware. The handlers see the same session, identity and services as a page.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Start from a fresh app: fsr new notes. The notes live in the session, so there is nothing else to set up.

Store the notes in the session#

app/schemas/session.ts
export interface Session {
  notes: string[];
}

export const defaults: Session = {
  notes: [],
};

Write a GET handler#

A directory holds a route.ts in place of a page. Its exports named GET, POST, PUT, PATCH or DELETE are handlers. The host matches the method and the path before any page and answers with the returned value as JSON:

app/routes/api/notes/route.ts
import type { Ctx } from "@snapfire/fsr/host";

export async function GET({ session }: Ctx<"/api/notes">) {
  return { notes: session.notes, count: session.notes.length };
}
$ curl -i http://127.0.0.1:3000/api/notes
HTTP/1.1 200 OK
content-type: application/json

{"notes":[],"count":0}

A directory holds a page or a handler, never both.

Take input with a POST#

Declare the input:

app/schemas/note.ts
export interface NewNote {
  text: string;
}

Write the handler as an action<T> and the host checks the body against T before your code runs. Add it to the same file:

app/routes/api/notes/route.ts
import { action, after, fail } from "@snapfire/fsr/host";
import type { ActionCtx, Ctx } from "@snapfire/fsr/host";
import type { NewNote } from "@schemas/note";

export async function GET({ session }: Ctx<"/api/notes">) {
  return { notes: session.notes, count: session.notes.length };
}

export const POST = action(async ({ input, session }: ActionCtx<NewNote>) => {
  const text = input.text.trim();
  if (text === "") fail("invalid", "a note needs some text");
  session.notes = [...session.notes, text];
  const count = session.notes.length;
  after(() => {
    console.info(`note ${count} saved`);
  });
  return { notes: session.notes, count };
});

A handler written as a plain function gets input as whatever the request carried, unchecked.

Post a note with a cookie jar, so the second call reads the session the first one wrote:

$ curl -c jar -b jar -H 'content-type: application/json' -d '{"text":"buy milk"}' http://127.0.0.1:3000/api/notes
{"notes":["buy milk"],"count":1}
$ curl -c jar -b jar -H 'content-type: application/json' -d '{"text":"call Sam"}' http://127.0.0.1:3000/api/notes
{"notes":["buy milk","call Sam"],"count":2}
$ curl -c jar -b jar http://127.0.0.1:3000/api/notes
{"notes":["buy milk","call Sam"],"count":2}

A body that does not match NewNote never reaches your code. A fail sets the status by its kind, so invalid is a 400:

$ curl -i -H 'content-type: application/json' -d '{"txt":"x"}' http://127.0.0.1:3000/api/notes
HTTP/1.1 400 Bad Request
content-type: application/json

{"kind":"invalid","message":"input: missing field `text`"}
$ curl -i -H 'content-type: application/json' -d '{"text":"  "}' http://127.0.0.1:3000/api/notes
HTTP/1.1 400 Bad Request
content-type: application/json

{"kind":"invalid","message":"a note needs some text"}

A JSON call is not checked for a CSRF token. A form post is, so posting the same handler as a form without _csrf answers 403 with csrf verification failed.

Do work after the response#

after(fn) runs fn once the response is written, so the caller does not wait on it. The two posts above logged:

INFO fsr::console: note 1 saved
INFO fsr::console: note 2 saved

The function takes nothing and reads the names around it as they stood when after was called. It runs whether the body went on to return or to fail. It cannot write the session, since the cookie has already gone out with the response. The build refuses one that tries:

routes/api/notes/route.ts:14:3: a session write inside `after`, which runs once the response and its cookie are sent

Answer with a Response#

Return a Response when JSON with a 200 is not what you want. Response.json sets the status:

app/routes/api/notes/latest/route.ts
import type { Ctx } from "@snapfire/fsr/host";

export async function GET({ session }: Ctx<"/api/notes/latest">) {
  if (session.notes.length === 0) {
    return Response.json({ error: "no notes yet" }, { status: 404 });
  }
  return { text: session.notes[session.notes.length - 1] };
}
$ curl -i http://127.0.0.1:3000/api/notes/latest
HTTP/1.1 404 Not Found
content-type: application/json

{"error":"no notes yet"}

new Response answers anything else. A directory name can carry an extension, so notes.txt is a route of its own:

app/routes/api/notes.txt/route.ts
import type { Ctx } from "@snapfire/fsr/host";

export async function GET({ session }: Ctx<"/api/notes.txt">) {
  const body = session.notes.map((note, i) => `${i + 1}. ${note}`).join("\n");
  return new Response(`${body}\n`, {
    headers: { "content-type": "text/plain; charset=utf-8", "content-disposition": "attachment; filename=notes.txt" },
  });
}
$ curl -i -c jar -b jar http://127.0.0.1:3000/api/notes.txt
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-disposition: attachment; filename=notes.txt

1. buy milk
2. call Sam

Wrap the API in middleware#

app/middleware.ts runs before every request that is not a static file, handlers included. await next() runs the route and hands back its Response, which you can change before returning it:

app/middleware.ts
import type { MiddlewareCtx } from "@snapfire/fsr/host";

export async function middleware({ request, next }: MiddlewareCtx) {
  if (!request.path.startsWith("/api/")) return;
  if (request.path === "/api/v1/notes") return next(new Request("/api/notes"));
  const response = await next();
  response.headers.set("cache-control", "no-store");
  return response;
}

request carries method and path. Returning nothing lets a page request through untouched. next(new Request("/api/notes")) runs the route for another path under the same URL, so the old /api/v1/notes keeps answering. It returns straight away, so it gets no header. Every other API response now carries one:

$ curl -i -c jar -b jar http://127.0.0.1:3000/api/notes
HTTP/1.1 200 OK
content-type: application/json
cache-control: no-store

{"notes":["buy milk","call Sam"],"count":2}
$ curl -i -c jar -b jar http://127.0.0.1:3000/api/v1/notes
HTTP/1.1 200 OK
content-type: application/json

{"notes":["buy milk","call Sam"],"count":2}

Call it from the page#

A script on the page reaches the same handlers with fetch. The browser sends its cookie, so the handlers see the same session:

const saved = await fetch("/api/notes", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ text: "from the page" }),
}).then((r) => r.json());

Run in a fresh browser, that answers {"notes":["from the page"],"count":1}. A following fetch("/api/notes/latest") answers {"text":"from the page"}.

Next up: 390. Animate page changes with view transitions.