API REST
Las operaciones de la API local expuestas por HTTP. Esta capa no decide nada: traduce una petición a una llamada de la API local y su resultado a una respuesta. Todo lo que decide qué documentos salen o qué escrituras valen está documentado en la otra página.
La integración de Astro monta estos seis endpoints por ti. La firma del final de la página solo importa si montas el CMS a mano.
Endpoints
Sección titulada «Endpoints»Prefijo por defecto /api/cms, configurable con api.path.
| Método | Ruta | Operación | Éxito |
|---|---|---|---|
GET |
/api/cms/:collection |
find |
200 |
POST |
/api/cms/:collection |
create |
201 |
GET |
/api/cms/:collection/count |
count |
200 |
GET |
/api/cms/:collection/:id |
findByID |
200 |
PATCH |
/api/cms/:collection/:id |
update |
200 |
DELETE |
/api/cms/:collection/:id |
delete |
200 |
count se resuelve antes que :id, así que nunca se interpreta como el id de un documento.
Las actualizaciones son PATCH y no PUT: son parciales, y PUT prometería un reemplazo total que no
ocurre. findOne no tiene endpoint —?limit=1 sobre el listado da lo mismo— y la autenticación se sirve
aparte, en /api/auth/*.
Ficheros
Sección titulada «Ficheros»Las colecciones que declaran upload añaden dos rutas más. Están documentadas en
Ficheros:
| Método | Ruta | Qué hace | Éxito |
|---|---|---|---|
POST |
/api/cms/:collection/upload |
Sube un fichero con multipart/form-data |
201 |
GET |
/api/cms/cdn/:collection/:id/:filename |
Devuelve el fichero | 200 |
GET |
/api/cms/cdn/:collection/:id |
Redirige a la ruta canónica | 302 |
upload se resuelve antes que :id, igual que count. El segmento cdn no colisiona con ninguna
colección: las rutas de colección tienen uno o dos segmentos y las de fichero tres o cuatro, así que una
colección llamada cdn sigue respondiendo con normalidad en /api/cms/cdn.
Subir es una escritura y exige sesión. Servir es una lectura y respeta el access.read de su
colección.
Ajustes
Sección titulada «Ajustes»Dos rutas más, sobre los ajustes del sitio guardados en KV:
| Método | Ruta | Qué hace | Éxito |
|---|---|---|---|
GET |
/api/cms/settings |
Los cinco campos efectivos | 200 |
PATCH |
/api/cms/settings |
Fusión superficial, devuelve el resultado | 200 |
Las dos exigen sesión siempre, sin el matiz de access.read que tienen las colecciones. Un
PATCH con una clave desconocida, un valor que no es cadena o un siteUrl que no es una URL
absoluta responde 400 con su array issues.
A diferencia de cdn, este segmento sí colisionaría con una colección: /api/cms/settings tiene un
solo segmento, igual que un listado. Por eso settings es un slug reservado y defineConfig
rechaza al arrancar una colección que lo declare.
Cambiar el prefijo mueve los seis, y también las de ficheros y la de ajustes:
export default defineConfig({ collections: [posts], api: { path: '/api/contenido' },})Parámetros de consulta
Sección titulada «Parámetros de consulta»GET /api/cms/posts ?where[status][equals]=published &where[publishedAt][less_than]=2026-01-01 &sort=-publishedAt &limit=10 &page=2 &depth=1 &select=title,slug| Parámetro | Por defecto | Rango |
|---|---|---|
limit |
10 |
entero de 1 a 1000 |
page |
1 |
entero ≥ 1 |
depth |
1 |
entero de 0 a 10 |
sort |
-createdAt |
campos separados por comas, - para descendente |
select |
todas las columnas | nombres de campo separados por comas |
where |
— | ver abajo |
Cualquiera fuera de rango responde 400 sin haber lanzado una sola consulta.
select y depth valen también al leer un documento por id. count solo mira where.
Se codifica con la sintaxis de corchetes de qs, la misma que usa Payload:
| Consulta | Query string |
|---|---|
{ status: { equals: 'published' } } |
where[status][equals]=published |
{ readingTime: { greater_than: 5 } } |
where[readingTime][greater_than]=5 |
{ slug: { in: ['a', 'b'] } } |
where[slug][in][0]=a&where[slug][in][1]=b |
{ or: [{ … }, { … }] } |
where[or][0][slug][equals]=a&where[or][1][slug][equals]=b |
Los once operadores y sus tipos admitidos son los de la API local, sin
recorte. Los valores viajan como cadenas y los convierte el campo, así que una fecha se escribe en ISO y un
checkbox como true:
where[publishedAt][less_than]=2026-01-01where[featured][equals]=truein y not_in aceptan como mucho 100 valores, que es el tope de parámetros que D1 liga por consulta.
Un operador que no existe, un campo que no existe, un operador que no aplica al tipo del campo o un valor que
el campo no sabe convertir responden 400. Ninguno se ignora en silencio: un filtro descartado devuelve filas
que no deberían estar ahí y el fallo aparece tarde.
Respuestas
Sección titulada «Respuestas»Listado — 200:
{ "docs": [{ "id": "01J8…", "title": "Hola" }], "pagination": { "totalDocs": 42, "limit": 10, "page": 1, "totalPages": 5, "hasNextPage": true, "hasPrevPage": false }}Conteo — 200:
{ "totalDocs": 42 }Documento único — 200 con el documento. Creación — 201 con el documento. Borrado — 200 con el documento
borrado.
Errores
Sección titulada «Errores»El cuerpo lleva siempre error y message, más issues cuando es de validación:
{ "error": "VALIDATION_ERROR", "message": "El documento no es válido", "issues": [{ "path": "title", "code": "REQUIRED", "message": "title es obligatorio" }]}| Situación | Status | error |
|---|---|---|
| Colección o documento inexistente | 404 | NOT_FOUND |
| Cuerpo inválido | 400 | VALIDATION_ERROR |
| Consulta inválida | 400 | QUERY_ERROR |
| Sin sesión donde hace falta | 401 | UNAUTHORIZED |
| Con sesión pero sin permiso | 403 | FORBIDDEN |
| Método no soportado en la ruta | 405 | METHOD_NOT_ALLOWED |
| Cuerpo mayor de 1 MB | 413 | PAYLOAD_TOO_LARGE |
| Fallo de D1 | 500 | INTERNAL_ERROR |
Los issues de una validación son los mismos { path, code, message }
de la API local.
El 403 no lo emite nadie todavía: v1 no tiene roles. Está en la tabla porque a tu cliente le conviene
contemplarlo desde el principio.
Cuerpos de escritura
Sección titulada «Cuerpos de escritura»El Content-Type no se mira: lo que decide es si el cuerpo parsea como objeto JSON. Si no parsea, es 400.
Un POST con {} responde 400 con un issue por cada campo obligatorio que falta.
El límite es 1 MB. Por encima es 413, y se rechaza sin llegar a parsearlo.
El handler recibe una función getSession(request), que la ruta inyectada cablea a la
autenticación. La sesión viaja en una cookie, así que desde el navegador basta
con haber iniciado sesión.
| Operación | Regla |
|---|---|
GET |
Pública si la colección declara access: { read: 'public' }; si no, requiere sesión |
POST, PATCH, DELETE |
Requieren sesión siempre |
Cualquiera sobre /settings |
Requiere sesión siempre, también el GET |
defineCollection({ slug: 'posts', access: { read: 'public' }, fields: [{ name: 'title', type: 'text', required: true }],})El método se resuelve antes que la sesión, así que un PUT responde 405 lleve o no credenciales: pedir
autenticación para una ruta que no existe es confirmar que existe.
Desactivado por defecto: en el caso normal la API la consume el propio sitio, y el mismo origen no necesita CORS. Se activa listando orígenes:
cms(config, { cors: ['https://app.example.com'] })Con la lista puesta, Access-Control-Allow-Origin se emite solo para los orígenes que estén en ella, siempre
acompañado de Vary: Origin, y el preflight OPTIONS responde 204. Sin lista no se emite ninguna cabecera
de CORS y un OPTIONS responde 405, porque no hay preflight que atender.
Access-Control-Allow-Credentials no se emite nunca, que es lo que hace inofensivo un * en la lista.
Lo que enruta la petición. La integración de Astro lo llama por ti desde la ruta que inyecta; esta firma solo importa si montas el CMS a mano.
function handle( request: Request, cms: { collections: Record<string, unknown> }, config: ResolvedConfig, options: { getSession(request: Request): Promise<unknown> | unknown; cors?: string[] },): Promise<Response>El cliente de D1 y el objeto de operaciones se le pasan: son los que devuelve
buildAPI. El handler no construye ninguno de los dos.