Aplicación full-stack de gestión de clientes sobre la base de datos Northwind: API REST en Rust con Rocket y panel administrativo en Next.js.
| Aplicación | https://northwind-fullstack.vercel.app/ |
| API | https://northwind-api-hkvs.onrender.com/health |
La primera carga puede tardar hasta un minuto. La API está en el plan gratuito de Render, que suspende la instancia tras 15 minutos sin tráfico y tiene que arrancarla de nuevo con la siguiente petición. El panel lo da por hecho: reintenta hasta 90 segundos con backoff y, si a los 3 segundos aún no hay datos, muestra un aviso explicando que el servidor está despertando. Lo mismo vale para una pestaña que se deja abierta: tras más de 10 minutos sin respuestas, la siguiente carga vuelve a contar con que el servidor puede estar dormido.
- Stack
- Arranque local
- La API
- Arquitectura
- Decisiones técnicas
- Tests
- Despliegue
- Limitaciones conocidas
| Capa | Tecnología | Versión | Por qué |
|---|---|---|---|
| Backend | Rust · edition 2021 | toolchain 1.98 | La edition está fijada a 2021 a propósito (ver back/Cargo.toml) |
| Rocket | 0.5.1 | Primera versión estable async, sobre Tokio | |
| rusqlite | 0.40 · feature bundled |
SQLite se compila desde su código C y se enlaza estáticamente | |
| serde / serde_json | 1.0 | Serialización JSON | |
| Base de datos | SQLite | 3.53.2 (embebida) | 93 clientes, 16 282 pedidos |
| Frontend | Next.js · App Router | 15.5.25 | |
| React | 19.1.0 | ||
| TypeScript | 5 · modo estricto | ||
| Tailwind CSS | 4 | ||
| shadcn/ui sobre Radix | dialog 1.1 · select 2.3 | Componentes copiados al proyecto, no una dependencia | |
| Gestor de paquetes | pnpm | 11 | |
| Despliegue | Render + Vercel | Ver Despliegue |
Requisitos: Rust y Cargo (1.98 o superior), Node.js 20+, pnpm.
Important
northwind.db NO está en el repositorio. Pesa 24 MB y está en .gitignore, así
que hay que descargarla antes del primer arranque. Sin este paso el servidor no
arranca: aborta el lanzamiento con un diagnóstico que indica la ruta que intentó
abrir y el directorio de trabajo desde el que la resolvió.
cd back
# 1. Descargar la base de datos (24 MB) EN LA RAÍZ DEL BACKEND.
# La ruta importa: main.rs abre "northwind.db" relativo al directorio de trabajo.
curl -L -o northwind.db \
https://github.com/jpwhite3/northwind-SQLite3/raw/main/dist/northwind.db
# 2. Comprobar que llegó entera: 93 clientes.
sqlite3 northwind.db "SELECT COUNT(*) FROM Customers;" # → 93
# 3. Arrancar. La primera compilación tarda unos minutos porque
# rusqlite compila SQLite desde su código C.
cargo run # → http://localhost:8001Comprobación rápida de que todo está en pie:
curl localhost:8001/health
# {"customers":93,"sqlite":"3.53.2","status":"ok"}| Variable | Por defecto | Para qué |
|---|---|---|
CORS_ALLOWED_ORIGIN |
http://localhost:3000 |
Origen que el navegador puede usar contra la API |
En local no hace falta definir nada: el valor por defecto ya es el del frontend en desarrollo.
El backend tiene que estar corriendo: el panel no tiene datos propios ni fixtures.
cd front
pnpm install
pnpm dev # → http://localhost:3000| Variable | Por defecto | Para qué |
|---|---|---|
NEXT_PUBLIC_API_URL |
http://localhost:8001 |
URL base de la API |
Tampoco hace falta crear ningún .env en local. Si la API está en otro sitio,
cp .env.example .env.local y editar el valor.
📖 El detalle del frontend —estructura, comandos, y el razonamiento completo de sus
decisiones— está en front/README.md.
Seis endpoints. Todos los ejemplos de abajo se ejecutaron contra
https://northwind-api-hkvs.onrender.com y las respuestas son las reales; el JSON de las más
largas está formateado para poder leerlo.
Todos los errores comparten la misma forma, vengan del handler o de un catcher:
{ "error": "codigo_corto", "message": "explicación legible" }Un solo formato significa que el frontend escribe una función para manejar errores, no dos.
curl https://northwind-api-hkvs.onrender.com/health{ "customers": 93, "sqlite": "3.53.2", "status": "ok" }Responder esto exige que funcione la cadena completa —estado gestionado, mutex, SQLite y una consulta real—, así que es lo primero que conviene mirar cuando algo falla.
curl 'https://northwind-api-hkvs.onrender.com/customers?page=1&pageSize=2&sortBy=customerId&sortDir=asc'{
"data": [
{
"customerId": "ALFKI",
"companyName": "Alfreds Futterkiste",
"contactName": "Maria Anders",
"contactTitle": "Sales Representative",
"address": "Obere Str. 57",
"city": "Berlin",
"region": "Western Europe",
"postalCode": "12209",
"country": "Germany",
"phone": "030-0074321",
"fax": "030-0076545"
},
{
"customerId": "ANATR",
"companyName": "Ana Trujillo Emparedados y helados",
"contactName": "Ana Trujillo",
"contactTitle": "Owner",
"address": "Avda. de la Constitución 2222",
"city": "México D.F.",
"region": "Central America",
"postalCode": "05021",
"country": "Mexico",
"phone": "(5) 555-4729",
"fax": "(5) 555-3745"
}
],
"total": 93,
"page": 1,
"pageSize": 2
}| Parámetro | Por defecto | Notas |
|---|---|---|
page |
1 |
Base 1 |
pageSize |
10 |
Se recorta al rango 1–100 |
companyName |
— | Coincidencia parcial, LIKE %texto% |
sortBy |
companyName |
customerId, companyName, contactName, contactTitle, city, country |
sortDir |
asc |
asc o desc |
El filtro es parcial y no distingue mayúsculas, así que busca dentro del nombre:
curl 'https://northwind-api-hkvs.onrender.com/customers?companyName=ana&pageSize=3'total: 2 → ANATR (Ana Trujillo Emparedados y helados) · HANAR (Hanari Carnes)
Hanari aparece porque contiene ana. Un sortBy que no esté en la lista no da
error: cae en el orden por defecto en silencio, algo que se explica en
Whitelist en el ORDER BY.
curl https://northwind-api-hkvs.onrender.com/customers/ALFKI{
"customerId": "ALFKI",
"companyName": "Alfreds Futterkiste",
"contactName": "Maria Anders",
"contactTitle": "Sales Representative",
"address": "Obere Str. 57",
"city": "Berlin",
"region": "Western Europe",
"postalCode": "12209",
"country": "Germany",
"phone": "030-0074321",
"fax": "030-0076545"
}El identificador se normaliza a mayúsculas, así que /customers/alfki es el mismo
recurso. Los dos caminos de error:
curl -i https://northwind-api-hkvs.onrender.com/customers/ZZZZZ # HTTP 404
{"error":"not_found","message":"no customer with id 'ZZZZZ'"}
curl -i https://northwind-api-hkvs.onrender.com/customers/AB # HTTP 400
{"error":"invalid_customer_id",
"message":"customerId must be exactly 5 alphanumeric ASCII characters (e.g. \"ALFKI\")"}Los once campos. customerId y companyName son obligatorios; el resto acepta null.
curl -i -X POST https://northwind-api-hkvs.onrender.com/customers \
-H 'Content-Type: application/json' \
-d '{"customerId":"TEST1","companyName":"Pruebas SL","contactName":"Ada Lovelace",
"contactTitle":"CTO","address":"Calle Mayor 1","city":"Madrid","region":null,
"postalCode":"28013","country":"Spain","phone":"+34 900 000 000","fax":null}'HTTP/1.1 201 Created
location: /customers/TEST1
{"customerId":"TEST1","companyName":"Pruebas SL","contactName":"Ada Lovelace",
"contactTitle":"CTO","address":"Calle Mayor 1","city":"Madrid","region":null,
"postalCode":"28013","country":"Spain","phone":"+34 900 000 000","fax":null}
Repetir la misma llamada:
HTTP/1.1 409 Conflict
{"error":"duplicate_id","message":"a customer with id 'TEST1' already exists"}
La cabecera Content-Type: application/json no es opcional: sin ella la ruta ni siquiera
se selecciona y la respuesta es un 404 desconcertante.
Warning
Es un reemplazo total. Escribe las diez columnas editables con lo que reciba, y un
campo ausente del cuerpo no se conserva: se guarda como NULL. Enviar solo
{"companyName":"…","city":"Barcelona"} cambiaría la ciudad y vaciaría los otros ocho
campos, sin error y sin que nadie se entere hasta que falte un teléfono.
curl -X PUT https://northwind-api-hkvs.onrender.com/customers/TEST1 \
-H 'Content-Type: application/json' \
-d '{"companyName":"Pruebas SL","contactName":"Ada Lovelace","contactTitle":"CTO",
"address":"Calle Mayor 1","city":"Barcelona","region":null,"postalCode":"28013",
"country":"Spain","phone":"+34 900 000 000","fax":null}'HTTP/1.1 200 OK
{"customerId":"TEST1","companyName":"Pruebas SL","contactName":"Ada Lovelace",
"contactTitle":"CTO","address":"Calle Mayor 1","city":"Barcelona","region":null,
"postalCode":"28013","country":"Spain","phone":"+34 900 000 000","fax":null}
El customerId va en la URL, no en el cuerpo: no se puede cambiar.
curl -i -X DELETE https://northwind-api-hkvs.onrender.com/customers/TEST1HTTP/1.1 204 No Content
Y el caso que en esta base de datos es el normal:
curl -i -X DELETE https://northwind-api-hkvs.onrender.com/customers/ALFKIHTTP/1.1 409 Conflict
{"error":"has_orders",
"message":"customer 'ALFKI' cannot be deleted because it has associated orders
— delete or reassign them first"}
Los 93 clientes de Northwind tienen pedidos —16 282 en total—, así que borrar cualquiera de ellos devuelve 409. Solo se puede borrar de verdad un cliente creado desde el panel. El porqué está en Claves foráneas encendidas y el 409.
| Método | Ruta | Éxito | Errores posibles |
|---|---|---|---|
| GET | /health |
200 | 503 si la base no responde |
| GET | /customers |
200 + Paginated<Customer> |
500 |
| GET | /customers/<id> |
200 + Customer |
400, 404, 500 |
| POST | /customers |
201 + Location |
400, 409, 422, 500 |
| PUT | /customers/<id> |
200 + Customer |
400, 404, 422, 500 |
| DELETE | /customers/<id> |
204 | 400, 404, 409 si tiene pedidos, 500 |
flowchart TD
A["Navegador"] -->|"GET /customers?page=2"| B["Enrutador"]
B --> C["Handler"]
C --> D["Mutex"]
D --> E["SQLite"]
E -->|"respuesta JSON"| A
La API no es ninguna de las cajas: son las dos flechas. El navegador no sabe que dentro
hay Rust, ni Rocket, ni un mutex, ni SQLite; solo sabe que si manda
GET /customers?page=2 recibe un JSON con data, total, page y pageSize. Se
podría reescribir todo el interior en otro lenguaje y el frontend no se enteraría.
📖 El recorrido completo —qué hace cada pieza, dónde encajan los catchers y por qué
SQLite no es un servidor— está en
docs/conceptos/01-que-es-una-api.md.
Lo intuitivo es leer el Mutex como una medida defensiva: "por si acaso dos peticiones
chocan". No es eso. Sin él, el programa no compila.
Rocket atiende peticiones en varios hilos y todos comparten el mismo estado gestionado,
así que exige que ese estado sea Send + Sync. La Connection de rusqlite es Send
—se puede mover a otro hilo— pero no es Sync, porque no admite que varios hilos la
usen a la vez. Mutex<Connection> sí es Sync: el mutex garantiza que solo uno entre
cada vez, que es justo lo que le faltaba al tipo.
Aquí la seguridad de tipos no está avisando de un riesgo teórico, está impidiendo compilar un programa que corrompería datos.
Trade-off: el mutex serializa todos los accesos a la base. Con 50 peticiones
simultáneas, 49 esperan en la cola. Para 93 filas y una demo es irrelevante; en
producción se sustituiría por un pool de conexiones (r2d2_sqlite), que reparte varias
conexiones entre los hilos en lugar de hacerlos pelear por una.
Los parámetros de SQL (?1, ?2…) solo sustituyen valores, nunca identificadores.
No se puede parametrizar el nombre de una columna, así que el nombre tiene que
interpolarse en el texto de la consulta — y eso es exactamente por donde entra una
inyección SQL.
Lo que hace el peligro difícil de detectar es esto: ORDER BY ?1 no falla. SQLite lo
acepta sin rechistar y ordena por una constante, que vale lo mismo para todas las filas.
Resultado: cero error, cero ordenación, y una consulta que parece funcionar hasta que
alguien se fija en que el orden nunca cambia.
La solución es no dejar que el texto del usuario llegue nunca a la consulta:
fn sort_column(requested: Option<&str>) -> &'static str {
match requested.unwrap_or_default().to_ascii_lowercase().as_str() {
"customerid" => "CustomerID",
"companyname" => "CompanyName",
// …
_ => DEFAULT_SORT_COLUMN,
}
}La función no devuelve lo que le dieron: devuelve uno de los literales que están escritos en el propio código. Lo que se interpola en el SQL es siempre una cadena que estaba en el binario, con lo que la inyección deja de ser posible por construcción y no por saneamiento.
Trade-off: un sortBy desconocido cae al valor por defecto en silencio, sin
error. Es deliberado —ver Validación permisiva o estricta—
pero significa que un error de escritura en el parámetro da un orden que no es el que se
pidió sin decirlo. Por eso el frontend mantiene la lista de columnas ordenables como un
tipo de TypeScript y no como cadenas sueltas.
let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_NO_MUTEX
| OpenFlags::SQLITE_OPEN_URI;
let conn = Connection::open_with_flags(path, flags)?;Lo cómodo sería Connection::open(path), pero esa función incluye
SQLITE_OPEN_CREATE: si el archivo no existe, lo crea vacío. Un error de tipeo en la
ruta, o arrancar desde el directorio equivocado, produciría entonces un servidor que
levanta perfectamente, responde 200 a todo y devuelve cero clientes. El fallo no
aparecería al arrancar sino más tarde, disfrazado de "no hay datos".
Omitiendo la bandera, un archivo que falta es un error al abrir, y el arranque se aborta con un diagnóstico que dice la ruta configurada, el directorio de trabajo y la ruta absoluta a la que se resolvió. Un fallo ruidoso y temprano en lugar de uno silencioso y tardío.
SQLITE_OPEN_NO_MUTEX completa la idea: le indica a SQLite que no ponga su propio
bloqueo interno, porque la exclusión ya la garantiza el Mutex de Rust. Dos candados
para la misma puerta serían un coste sin ninguna seguridad adicional.
SQLite trae las claves foráneas desactivadas por defecto, por compatibilidad histórica, y el ajuste es por conexión: no basta con que el esquema declare la relación.
conn.execute("PRAGMA foreign_keys = ON", [])?;Sin esa línea, borrar un cliente con pedidos funciona: devuelve 204 y deja los pedidos apuntando a un cliente que ya no existe. La corrupción es silenciosa y solo se descubre mucho después, cuando algún informe cruza las dos tablas.
Con la línea, SQLite rechaza el borrado con una violación de restricción, que el handler
traduce a un 409 con el código has_orders. Y como los 93 clientes tienen pedidos, ese
409 es el camino normal, no la excepción: es lo que va a pasar casi siempre.
El frontend está construido en consecuencia — el diálogo no se cierra, el aviso es informativo en azul en lugar de un error en rojo, y el botón pasa a "Entendido" en vez de ofrecer un reintento cuyo resultado sería idéntico. Pintar de rojo el resultado más frecuente de una operación entrena a la persona usuaria a ignorar el rojo, que es justo lo que no conviene el día que algo se rompa de verdad.
Las dos mitades de la API validan con criterios opuestos, a propósito.
En GET /customers nada da error. Un pageSize=5000 se recorta a 100; un
sortBy=inventado cae al orden por defecto; un page negativo pasa a 1. Un listado es
presentación: un enlace guardado hace meses, o una URL con un parámetro que ya no
existe, deben seguir mostrando algo útil. Un muro de errores por un parámetro decorativo
es peor servicio que enseñar la primera página.
En POST y PUT la validación es estricta. Un customerId que no sean cinco
caracteres alfanuméricos es un 400; un companyName vacío o solo con espacios es un 400.
Escribir es persistencia: el radio de daño es permanente y afecta a todo el que lea
después. Aquí es mejor rechazar y explicar que aceptar y guardar basura.
La regla, en una frase: ser tolerante con lo que se muestra, intransigente con lo que se guarda.
CORS aquí necesita exactamente tres cosas: un origen configurable, una lista fija de métodos y una cabecera permitida. Eso son unas cuarenta líneas de fairing que se leen de una sentada y dicen con precisión qué permite el servidor.
rocket_cors resuelve el estándar completo —listas de orígenes, credenciales, cabeceras
expuestas, Vary— y arrastra su propio calendario de versiones, que en la transición a
Rocket 0.5 fue una fuente conocida de fricción. Para esta superficie, la dependencia
costaba más de lo que ahorraba.
Trade-off: el fairing propio no implementa el estándar entero. Permite un único
origen, no emite Vary: Origin y no soporta credenciales. Si mañana hicieran falta
varios frontends con dominios distintos, o cookies entre orígenes, la decisión correcta
sería pasar a un crate que ya lo tenga resuelto en vez de ir parcheando el propio.
El ciclo de vida de una petición en Rocket es
routing → request guards → data guard → handler → responder, y un fallo puede ocurrir
antes de llegar al handler:
| Fallo | Dónde ocurre | Ejemplo |
|---|---|---|
| Ninguna ruta casa | routing | GET /rutaquenoexiste → 404 |
| El cuerpo no es JSON válido | data guard | Una coma de más → 400 |
| Es JSON, pero falta un campo | data guard | Sin companyName → 422 |
En esos casos el código del handler nunca se ejecuta, así que no existe ningún punto
del programa donde construir la respuesta de error a mano. Rocket respondería con su
página HTML por defecto, y un frontend que hace response.json() se rompería con un
unexpected token < — un error que no dice nada de lo que pasó realmente.
Los catchers cubren ese hueco: convierten un Status suelto en el mismo {error, message} que emite el resto de la API. La regla en una frase: ApiError protege la
salida del código propio; los catchers protegen todo lo que lo rodea.
Un detalle confirma que están bien montados: un error del handler como
invalid_customer_id no se convierte en el genérico malformed_json. Los catchers
solo saltan cuando Rocket tiene un Status desnudo, y un ApiError ya es una respuesta
completa —código, Content-Type y cuerpo— que sale tal cual.
El ENUNCIADO pedía axios y Material-UI. El frontend usa fetch y
shadcn/ui. Las dos divergencias son deliberadas:
-
fetchsobre axios. Lo que el buscador necesita de verdad esAbortController, para cancelar la petición anterior en cada pulsación y evitar que las respuestas lleguen desordenadas.fetchlo acepta directamente; axios lo envuelve. Además axios convierte todo status ≥ 400 en una excepción con la respuesta enterrada enerror.response, lo que estorba justo en la distinción que aquí importa: unTypeErrorsin status —backend caído o CORS ausente— frente a un 409 con cuerpo. Cuándo sería mejor axios: con interceptores, reintentos automáticos o autenticación con refresco de token, es decir, en cuanto la capa de red deje de ser trivial. -
shadcn/ui sobre Material-UI. Los componentes se copian al proyecto y se versionan como cualquier otro archivo, en vez de ajustarse a través de un sistema de theming ajeno. Y Material Design trae una opinión visual —elevaciones, ondas al pulsar, color de marca por todas partes— que choca con la de este panel, donde el color está reservado para señal: rojo es "algo falló", azul es "regla de negocio", y todo lo demás es gris para que esos dos se vean. Cuándo sería mejor MUI: en un equipo que ya tenga un design system encima de MUI, donde la consistencia con lo existente vale más que cualquiera de estos argumentos.
📖 El razonamiento completo de ambas, con el detalle de accesibilidad y de tamaño de
bundle, está en front/README.md.
cd back && cargo test Finished `test` profile [unoptimized + debuginfo] target(s) in 0.40s
Running unittests src/main.rs (target/debug/deps/back-935cbb1c79a60dba)
running 15 tests
test tests::port_comes_from_the_variable_when_valid ... ok
test tests::port_falls_back_on_unusable_values ... ok
test tests::port_falls_back_when_the_variable_is_absent ... ok
test tests::port_tolerates_surrounding_whitespace ... ok
test tests::get_by_id_normalises_the_case ... ok
test tests::get_by_id_returns_the_customer ... ok
test tests::page_size_is_honoured ... ok
test tests::company_name_filters_case_insensitively ... ok
test tests::responses_carry_the_cors_header ... ok
test tests::list_defaults_to_ten_per_page ... ok
test tests::health_reports_the_93_customers ... ok
test tests::unknown_route_is_caught_as_json ... ok
test tests::unknown_id_returns_a_json_404 ... ok
test tests::page_size_is_capped ... ok
test tests::unknown_sort_by_falls_back_to_the_default ... ok
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s
Hay dos grupos, y la diferencia no es cosmética:
- 11 de integración. Levantan una instancia real de Rocket con el cliente de pruebas
del framework y consultan la base de datos de verdad, así que ejercitan la cadena
completa: enrutado, guards, handler, mutex, SQLite y serialización. Necesitan
back/northwind.dben disco: sin ella, cada uno se detiene con un panic que dice qué archivo falta y en qué ruta lo buscó. - 4 unitarios sobre
resolve_port, la función que decide el puerto a partir dePORT. Son pruebas puras: no tocan ni el framework ni la base.
Por eso, en un clon recién hecho sin descargar la base (paso 1 de
Arranque local), cargo test da 4 en verde y 11 en rojo. El código no está
roto: falta el archivo.
Qué cubren:
| Test | Qué comprueba |
|---|---|
health_reports_the_93_customers |
La cadena entera responde y cuenta 93 clientes |
list_defaults_to_ten_per_page |
Los valores por defecto de la paginación |
page_size_is_honoured |
pageSize cambia el tamaño de la página |
page_size_is_capped |
pageSize se recorta a 100 |
company_name_filters_case_insensitively |
El filtro parcial sin distinguir mayúsculas |
unknown_sort_by_falls_back_to_the_default |
La whitelist del ORDER BY no rompe |
get_by_id_returns_the_customer |
Lectura por identificador |
get_by_id_normalises_the_case |
/customers/alfki y /customers/ALFKI son el mismo recurso |
unknown_id_returns_a_json_404 |
El 404 sale como JSON, no como HTML |
unknown_route_is_caught_as_json |
El catcher de ruta inexistente también responde JSON |
responses_carry_the_cors_header |
El fairing de CORS añade su cabecera |
port_falls_back_when_the_variable_is_absent |
Sin PORT definida, el servidor usa el 8001 |
port_comes_from_the_variable_when_valid |
Un PORT válido (el 10000 de Render) se respeta |
port_tolerates_surrounding_whitespace |
Espacios o un salto de línea alrededor del valor no lo invalidan |
port_falls_back_on_unusable_values |
Vacío, no numérico, fuera de rango de u16 o 0: vuelve al 8001 |
Qué NO cubren: ninguno escribe. No hay ni un test de POST, PUT o
DELETE, y es una decisión consciente: se ejecutan contra el northwind.db real, así que
un test de escritura modificaría el archivo de trabajo y los siguientes dejarían de ser
reproducibles —el primer cargo test pasaría y el segundo fallaría por un id duplicado—.
Probarlas bien exige aislar el estado: copiar la base a un archivo temporal por test, o abrirla en memoria y cargar el esquema. Ninguna de las dos está hecha, así que las validaciones de escritura, el 409 de duplicado y el 409 de pedidos están verificados a mano (las llamadas de La API son precisamente esas comprobaciones) pero no en la suite automática. Es la brecha más grande de este repositorio.
flowchart LR
A["Navegador"] -->|"HTML + JS"| B["Vercel · Next.js"]
A -->|"fetch JSON"| C["Render · Rust + Rocket"]
C --> D["SQLite dentro de la imagen"]
Son dos servicios porque hacen trabajos distintos: Vercel solo entrega archivos que luego ejecuta el navegador, y Render mantiene vivo un proceso con estado, la conexión abierta a la base de datos. Por eso las peticiones a la API salen del navegador y van directas a Render, sin pasar por Vercel. El backend no puede ir en Vercel porque allí el filesystem es de solo lectura y efímero, y SQLite es un archivo que el proceso tiene que abrir y escribir.
Un servicio Docker construido desde GitHub. La configuración entera son seis campos:
| Campo | Valor |
|---|---|
| Región | Virginia |
| Plan | Free |
| Runtime | Docker |
| Build Context | back |
| Dockerfile Path | back/Dockerfile |
| Health Check Path | /health |
El contexto es back y no la raíz porque el Dockerfile copia Cargo.toml, Cargo.lock
y src/ con rutas relativas al backend; con la raíz como contexto el build subiría también
front/ y no encontraría nada donde lo busca.
(El primer destino fue Fly.io. Se descartó cuando su trial —7 días o 2 horas de máquina— expiró: detrás no hay plan gratuito.)
Por qué no Vercel también para el backend. Vercel ejecuta funciones serverless: el
filesystem es de solo lectura salvo /tmp, y además efímero entre invocaciones. SQLite no
es un servidor al que conectarse, es un archivo que el proceso abre y escribe, así que
un backend con SQLite embebido necesita un proceso de larga vida con disco propio. Render
da justo eso —un contenedor que corre— y por eso el reparto es Vercel para el frontend y
Render para la API.
back/Dockerfile es multi-stage: compila con la imagen oficial de Rust y despacha sobre
debian:bookworm-slim. Imagen final: 158 MB — 85 MB de base Debian, 25 MB de base de
datos y 6 MB de binario ya pasado por strip.
Cuatro detalles que hacen que funcione:
-
La caché de capas está montada al revés a propósito. Se copian primero
Cargo.tomlyCargo.locky se compilan las dependencias con unmain.rsfalso; solo después entrasrc/. Así un cambio de código no vuelve a compilar el árbol entero —que incluye compilar SQLite desde C, la parte lenta con diferencia—. Medido: 4 min 22 s en frío, 19 s tras tocar un archivo fuente. -
No se instala ninguna librería de SQLite en la imagen final, porque
bundledla enlaza estáticamente dentro del binario. Verificado conldd: el binario solo pidelibgcc_s,libm,libcy el cargador. -
northwind.dbse descarga durante el build, con el sha256 fijado. El archivo pesa 24 MB y está en.gitignore, así que unCOPYexigiría tenerlo en el disco de quien construye — y quien construye aquí es una plataforma que parte de un clon limpio del repositorio. Descargándolo en elDockerfilela imagen se construye desde el repo y nada más. El--checksumno es decorativo: sin él, el despliegue ejecutaría el archivo que hoy haya en una rama de un tercero; con el hash fijado, si el archivo remoto cambia el build falla en esa línea en lugar de servir una base distinta a la probada, y actualizar la base pasa a ser un cambio explícito y revisable en el diff. -
ROCKET_ADDRESS=0.0.0.0es la línea que hace el contenedor alcanzable. Rocket escucha en127.0.0.1por defecto, que dentro de un contenedor es el loopback del contenedor: el servidor arranca, anuncia tan tranquilo que se lanzó, responde a un curl hecho desde dentro y rechaza toda conexión que venga de fuera. Lo cruel del síntoma es que no hay ningún error en los logs —desde el punto de vista de Rocket todo va bien—; desde fuera solo se veconnection refused, y en la plataforma, health checks que fallan sin explicación.0.0.0.0significa "escucha en todas las interfaces", que es lo que necesitan el reenvío de puertos de Docker y el proxy de Render.
Render no sabe qué framework hay dentro del contenedor: asigna un puerto y lo comunica por
la variable PORT, la convención común. Un servicio que la ignore y escuche en un puerto
fijo no recibe tráfico, porque la plataforma enruta hacia el puerto que eligió ella.
Lo que obliga a leerla explícitamente es Figment, la capa de configuración de Rocket:
main.rs fija el puerto con Config::figment().merge(("port", …)), y en Figment un
merge tiene más precedencia que las variables de entorno. Es decir, ROCKET_PORT —la
vía normal de Rocket— no movería nada: el merge la pisaría. Por eso main.rs lee
PORT del entorno antes del merge y lo mete ahí, cayendo en 8001 si nadie dice nada.
Así cargo run sigue funcionando sin exportar ninguna variable y Render puede inyectar
el suyo sin reconstruir la imagen.
Se deja fuera de la imagen a propósito y se define en Render como variable de entorno
del servicio, con el valor https://northwind-fullstack.vercel.app. Con un valor por
defecto dentro de la imagen, cambiar el origen permitido —al mover el frontend de dominio—
obligaría a reconstruir y redesplegar la imagen entera para cambiar una cadena de texto.
Sin definirla, cors.rs cae en http://localhost:3000, que es justo lo que hace falta en
desarrollo.
Las dos consecuencias del plan Free, las dos asumidas:
-
La base de datos viaja dentro de la imagen, sin volumen persistente. Lo que se escriba se pierde en el siguiente reinicio o redespliegue, y para una demo eso es una ventaja: cualquiera puede crear, editar y borrar sin miedo, y la base vuelve sola a sus 93 clientes sin ningún paso manual de limpieza. Si esto tuviera que guardar datos de verdad, sería el error más grave del proyecto.
-
La instancia se suspende tras 15 minutos sin tráfico y tarda alrededor de un minuto en volver. El frontend lo trata como lo que es —una espera, no un error—: reintenta con backoff exponencial hasta 90 segundos y, si a los 3 segundos no hay datos, muestra un aviso de que el servidor está despertando. Solo reintenta en la primera carga o tras más de 10 minutos sin respuestas, que es cuando el servidor puede estar dormido; si acaba de contestar, un fallo ya es un fallo de verdad y se muestra como tal. Verificado de extremo a extremo contra el despliegue real.
Se despliega el directorio front/ con la configuración por defecto de Next.js. La única
variable es NEXT_PUBLIC_API_URL, apuntando a https://northwind-api-hkvs.onrender.com.
El prefijo NEXT_PUBLIC_ es obligatorio: las peticiones salen del navegador, no del servidor de
Next, así que la variable tiene que viajar en el bundle.
Todas están verificadas, no son sospechas.
Ordenando por ciudad de forma descendente, el primer resultado es Århus, por delante de Warszawa:
curl 'https://northwind-api-hkvs.onrender.com/customers?sortBy=city&sortDir=desc&pageSize=6'
# Århus · Warszawa · Walla Walla · Versailles · Vancouver · TsawassenNo es un bug, es la collation BINARY de SQLite, que compara por código de carácter
y no por reglas de idioma. Å es U+00C5 (197) y W es U+0057 (87), así que en orden
descendente Århus va primero, exactamente como se le pidió. Arreglarlo requiere una
collation con reconocimiento de idioma —la extensión ICU de SQLite— o guardar una columna
adicional con el nombre normalizado sobre la que ordenar.
Todas las consultas pasan por la misma conexión protegida por un Mutex, así que se
atienden de una en una. Con 93 filas y una demo es imperceptible; bajo carga real sería el
primer cuello de botella. La salida es un pool de conexiones (r2d2_sqlite).
main.rs fija el puerto con Config::figment().merge(("port", …)), y en Figment un
merge tiene más precedencia que las variables de entorno, así que la vía normal de
Rocket no funciona. Comprobado lanzando la imagen con ROCKET_PORT=9999: siguió
arrancando en el 8001. La variable que sí decide el puerto es PORT, que main.rs lee
del entorno antes del merge —ver Despliegue—. ROCKET_ADDRESS sí funciona
por entorno, que es lo que importaba.
Como la base de datos va dentro de la imagen y el filesystem de Render es efímero,
cualquier cliente creado o editado en northwind-fullstack.vercel.app desaparece cuando
la instancia se reinicia o se redespliega, y la base vuelve a sus 93 clientes. Es
intencional —ver Despliegue— pero conviene saberlo antes de enseñar la
demo.
El plan gratuito de Render suspende la instancia tras 15 minutos sin tráfico. El panel lo absorbe reintentando hasta 90 segundos y avisando a los 3, así que se ve una espera explicada en vez de un error, pero la espera existe. Desaparece con un plan que no suspenda.
Buscar ALFKI no encuentra nada; hay que buscar Alfreds. El filtro se aplica solo sobre
companyName, que es lo que pide el enunciado. Ampliarlo sería añadir un OR CustomerID LIKE ? en la consulta y otro parámetro en la whitelist.
Detallado en Tests: ninguna de las quince pruebas automáticas escribe, y las operaciones de escritura están verificadas a mano.
.
├── back/ API en Rust · Rocket
│ ├── src/
│ │ ├── main.rs rutas, validación, ApiError y catchers
│ │ ├── models.rs Customer, NewCustomer, UpdateCustomer, Paginated
│ │ ├── db.rs apertura de SQLite y Mutex<Connection>
│ │ ├── cors.rs fairing de CORS
│ │ └── tests.rs 15 tests: 11 de integración y 4 unitarios de resolve_port
│ └── Dockerfile build multi-stage · descarga northwind.db con sha256 fijado
├── front/ panel en Next.js · ver front/README.md
├── docs/conceptos/ documento de conceptos sobre el flujo de una petición
└── ENUNCIADO.md el enunciado original del proyecto
El código de ambos lados está comentado en español con las notas de diseño de cada decisión; este README recoge las que afectan al proyecto entero.
