A REST API with real routes, in one deployable unit
A collection, its items and a nested collection, served across several HTTP methods by a single Function — so the whole resource ships, versions and rolls back together instead of one deploy per verb.
One Function owns the whole path space
A Function here is mounted at an address and receives everything beneath it, which it routes itself. That means `/customers`, `/customers/42` and `/customers/42/orders` are one deploy unit at one version — there is no arrangement in which reading a customer and updating one are running different builds. This template is that resource, with a route matcher short enough to read in full and a store you replace with your own.
The whole template is public — read it before you trust it.
Or scaffold it
$ npx wawesome init --template rest-api
$ npx wawesome deployThe thing most serverless platforms get wrong about REST
On a platform where a function answers at exactly one address, a REST resource
is not one thing. It is a handful of separate deployments — one for GET, one
for PUT, one for the nested collection — each with its own version, its own
rollout, and its own rollback.
That arrangement breaks in ways that are hard to see and expensive to debug. A
PATCH at version 9 while the GET beside it is still at version 8. A CORS
preflight in a different deploy unit from the request it precedes. A rollback
that fixes the write path and leaves the read path broken. None of it is
anything a developer asked for; it is the addressing model leaking into the
domain model.
Here, a Function is mounted at a prefix and owns everything beneath it. One deploy, one version, one rollback, for the entire resource.
What you get
GET / the collection
POST / create one
GET /:id read one
PUT /:id replace one
DELETE /:id delete one
GET /:id/orders a collection nested under an item
* the Function's own 404
OPTIONS is answered from the same table with an accurate Allow, HEAD is
served by whatever serves GET, and a method a path does not serve returns
405 saying what it does serve. All of it falls out of the routing table rather
than being written six times:
const ROUTES: Route<Handler>[] = [
{ method: 'GET', pattern: '/', handler: collection },
{ method: 'POST', pattern: '/', handler: create },
{ method: 'GET', pattern: '/:id', handler: read },
{ method: 'PUT', pattern: '/:id', handler: replace },
{ method: 'DELETE', pattern: '/:id', handler: remove },
{ method: 'GET', pattern: '/:id/orders', handler: orders },
];
Adding a route is one line. There is no route to register with the platform, no second Function, and no configuration file that has to agree with your code.
Your code never names the platform’s addressing
The mount is stripped before the request reaches you, so the path your handler
routes on is relative to it. A call to your Function’s own address arrives as
/.
This is what keeps the code portable across the addresses one deployment answers at. A Preview URL puts the same version under a different prefix — so a Function that routed on the full path would route differently through a Preview than through production, and a Preview would stop being a rehearsal of it.
When you do need the caller’s real address — for a Location on a 201, or a
self link on an item — the stripped mount comes back on a header you can trust,
because the platform’s reserved namespace is stripped from every inbound request
before your code runs:
const url = new URL(request.url);
const prefix = request.headers.get('x-wawesome-forwarded-prefix') ?? '';
const publicUrl = url.origin + prefix + url.pathname + url.search;
A conventional x-forwarded-prefix would be whatever the caller said it was.
This one is not forgeable, which is what makes it safe to build links from.
The path arrives exactly as it was typed
No percent-decoding, no collapsing of doubled separators, no trailing-slash rewriting. That is what lets a scheme signing a URL verify against it — and it means those decisions are yours rather than a proxy’s. The template makes each one in a single place, and pins it with a test:
| The caller sends | This router | Why |
|---|---|---|
/cus_42/ |
same as /cus_42 |
A trailing slash names the same resource |
/acme%2Feu |
one item, id acme/eu |
Split raw, decode after — otherwise an escaped separator silently becomes a route |
/cus_42//orders |
404 |
An empty segment is not an identifier |
/cus_%zz |
400 |
Not valid percent-encoding, so there is no path to route |
Four short branches you can change, not a framework’s opinion you have to work
around. And if you would rather use a router you already know — itty-router,
or anything else that takes a Request and returns a Response — it runs here
unmodified.
The data is a fixture, and it says so
src/store.ts serves two customers and three orders from a constant. Reads are
real. Writes are deliberate no-ops that answer exactly as a real store would:
POST returns 201 with a well-formed Location, and that address returns
404, because nothing was written.
That is stated rather than hidden, because the alternative would teach you something false about the runtime. A Function is started fresh for each request, so there is nowhere in the process for a write to go — an in-memory array here would pass its tests and lose every write in production.
store.ts is the one file to replace. Each function in it is a seam: keep the
signature, put your database call inside, and neither the router nor the
handlers change, because they already await what those functions return.
Reaching a database is an outbound call like any other, and outbound access is
default-deny per App — the host your store lives on goes on the App’s allowlist
first, and a fetch to somewhere you did not authorise does not silently
succeed.
What it deliberately leaves to you
Authentication. Every route is public. Check a credential in the same place the route is matched.
CORS. A Function’s response carries only the headers the Function sets, so no
browser on another origin can call it until you say it may. The llm-proxy
template has a worked example of stating that policy.
Pagination. The collection returns everything, which is right for two rows
and wrong for two million. The self link on the collection already carries the
caller’s query string, which is where a cursor goes.
Getting it live
npx wawesome init --template rest-api
Nothing to configure — no API key, no third-party account, no provider to enable. It deploys and prints the address, and all six routes are live at it.
- rest
- api
- routing
- crud
- http
- typescript
Ready in about a minute
Sign in with GitHub, deploy, and get a public HTTPS endpoint.