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.
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:
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";importtype{ActionCtx,Ctx}from"@snapfire/fsr/host";importtype{NewNote}from"@schemas/note";exportasyncfunctionGET({session}:Ctx<"/api/notes">){return{notes:session.notes,count:session.notes.length};}exportconstPOST=action(async({input,session}:ActionCtx<NewNote>)=>{consttext=input.text.trim();if(text==="")fail("invalid","a note needs some text");session.notes=[...session.notes,text];constcount=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/notesHTTP/1.1 400 Bad Requestcontent-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/notesHTTP/1.1 400 Bad Requestcontent-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.
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 savedINFO 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
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:
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/notesHTTP/1.1 200 OKcontent-type: application/jsoncache-control: no-store{"notes":["buy milk","call Sam"],"count":2}$curl -i -c jar -b jar http://127.0.0.1:3000/api/v1/notesHTTP/1.1 200 OKcontent-type: application/json{"notes":["buy milk","call Sam"],"count":2}
A script on the page reaches the same handlers with fetch. The browser sends its cookie, so the handlers see the same session:
constsaved=awaitfetch("/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"}.