astromatico
v0.3.0
Published
Common utilities for Astro projects. Today: api route helpers with schema validation, authorization, error handling and response wrapping.
Maintainers
Readme
The ones worth writing once instead of again in the next project. So far that is the api route helpers: schema validation, authorization, error handling and response wrapping, so a route handler is the one thing it should be — a function from a request to a value.
// src/pages/api/clients/[name].ts
import { EndpointError, route } from 'astromatico'
import { z } from 'astro/zod'
export const GET = route({ path: z.object({ name: z.string() }) }, async (context) => {
const client = await db.clients.byName(context.path.name)
if (!client) throw new EndpointError('Client not found', 404)
return client
})That answers 200 with the client as json, 404 with { code: 404, error: 'Client not found' },
and 400 with the issues if the path does not validate — none of which the handler spells out.
Install
npm i astromatico
# or
bun add astromaticoastro is a peer dependency. Nothing else is: astromatico imports only Astro's types and the zod
that Astro already ships, so there is no second copy of anything.
What route does
It wraps an Astro APIRoute and takes four jobs off the handler:
- Authorizes the caller against the route's
requires, once you have told it how —401with no actor,403when it is not allowed. See Permissions. - Validates
body,queryandpathagainst the schemas you declare, and puts the parsed values on the context. A failure answers400with the issues. - Wraps the return value in a
Response. Return a value and it becomes200; return aResponseand it is sent untouched. - Turns thrown errors into answers. Anything carrying a 4xx/5xx
statusbecomes that status. - Answers
500for everything else, after handing the error to youronError.
Validation
export const POST = route(
{
body: z.object({ name: z.string(), code: z.string() }),
query: z.object({ dryRun: z.coerce.boolean().optional() }),
path: z.object({ clientName: z.string() }),
},
(context) => {
context.body // { name: string; code: string }
context.query // { dryRun?: boolean }
context.path // { clientName: string }
},
)The body is read according to its content-type: application/json, multipart/form-data and
application/x-www-form-urlencoded are understood. A route that only validates a body can pass the
schema directly:
export const POST = route(z.object({ name: z.string() }), (context) => create(context.body))The response
The handler returns the payload; route builds the Response:
export const GET = route(() => ({ ok: true })) // 200 {"ok":true}
export const GET = route(() => [1, 2, 3]) // 200 [1,2,3]A route that answers with another status declares it once, next to its schemas, instead of repeating
it at every return:
export const POST = route({ body: schema, res: res.created }, (context) => create(context.body))
// 201res holds the builders. Any of them that takes a payload can be a res option — res.ok (the
default), res.created, res.notFound, and so on. res.noContent and res.redirect cannot: their
first argument is not the payload, and the types say so.
When a route needs to answer differently depending on what it finds, it returns a Response itself
and route passes it through:
export const GET = route(async (context) => {
const user = await me(context)
return user ? user : res.unauthorized()
})Errors
Throw EndpointError to answer with a status and a message:
throw new EndpointError('Adjuntá un archivo de imagen', 400)Add a third argument to say which input the message is about, and the answer carries it so a form can put the message on that field:
throw new EndpointError(`A client with code "${code}" already exists`, 409, 'code')
// 409 { code: 409, error: 'A client with code "..." already exists', field: 'code' }You do not have to use that class. Any error carrying a numeric 4xx/5xx status is answered with
it, so errors from your own layers or from another library work without extending anything:
class DomainError extends Error {
status = 409
}Anything else is a bug, not an answer: it becomes 500 and is handed to onError.
Logging
The default onError is console.error. To report through a request-scoped logger, make your own
route once and use it everywhere:
// src/lib/server/route.ts
import { createRoute } from 'astromatico'
export const route = createRoute({
onError: (error, context) => context.locals.logger.error({ err: error }, 'unhandled route error'),
})The envelope
Successful answers are the payload, untouched. Error answers add the status to the body as code:
| helper | status | body |
| --- | --- | --- |
| res.ok(data) | 200 | data |
| res.created(data) | 201 | data |
| res.noContent() | 204 | — |
| res.bad(data?) | 400 | { code, ...data } |
| res.unauthorized(data?) | 401 | { code, ...data } |
| res.forbidden(data?) | 403 | { code, ...data } |
| res.notFound(data?) | 404 | { code, ...data } |
| res.unprocessable(data) | 422 | { code, ...data } |
| res.serverError(data?) | 500 | { code, ...data } |
| res.err(data) | 400 | { code, ...data } |
| res.json(data, options?) | yours | { code, ...data } |
| res.redirect(url) | 302 | — |
A payload that is not an object is wrapped so the body stays json: a string becomes { message },
anything else { data }.
API
| export | what it is |
| --- | --- |
| route | defines an APIRoute with validation, error handling and response wrapping |
| createRoute(config) | the same, with your error reporting and your permission model |
| res | the Response builders |
| EndpointError | throw to answer with a status, a message and optionally a field |
| isStatusError | whether a throwable names a 4xx/5xx status |
| ValidatedContext, RouteHandler, RouteOptions, Responder, RouteConfig, AuthConfig | the types, for building your own wrappers on top |
Those last types are the extension point, for wrappers of your own.
Permissions
An app's idea of who is calling, and of what they may do, is its own. astromatico does not define either — it asks for two functions and then owns the rest:
// src/lib/server/route.ts
export const route = createRoute<Session, Permission>({
onError: (error, context) => context.locals.logger.error({ err: error }, 'unhandled route error'),
auth: {
actor: (context) => context.locals.session,
can: (session, required) => can(session.permissions, required),
},
})A route then states what it needs alongside its schemas, and the handler stays a plain function:
export const POST = route(
{ body: createClientSchema, requires: 'client:write', res: res.created },
(context) => create(context.actor, context.body),
)No actor answers 401, one that can rejects answers 403, and neither is spelled out at the call
site. requires is passed to can untouched, so its type is yours — a string, a union, an array,
an object; astromatico never inspects it.
The actor is typed by whether the route requires anything. With requires, it was resolved and
checked before the handler ran, so context.actor is your actor. Without it, the actor is still
resolved and handed over, but it may be null and the types say so:
route({ requires: 'client:write' }, (c) => c.actor.login) // Session
route((c) => c.actor?.login) // Session | nullThat is what removes the nullable dance from handlers that only ever run authorized.
Authorization runs before validation. A caller who is not allowed is not told what the schemas would have rejected.
onAnonymous and onDenied override the two answers when res.unauthorized() and res.forbidden()
are not the bodies you want:
auth: {
actor, can,
onAnonymous: () => res.json({ error: 'Iniciá sesión' }, { status: 401 }),
onDenied: (_context, required) => res.json({ error: `Te falta ${required}` }, { status: 403 }),
}actor is called once per request whenever auth is configured, requires or not. If resolving it
is expensive, cache it inside your own function.
License
MIT
