Tutorial paso a paso por Fitz. Arrancamos desde
fitz new, terminamos con un binario nativo corriendo en Docker. Sin dependencias externas. Sin pip install. Solo Postgres tipado, auth con JWT, y OpenAPI auto-generado.
Qué vamos a construir
Un acortador de URLs con las cuatro cosas que toda API real necesita:
- Endpoints HTTP para crear, redirigir, y ver stats.
- Persistencia en Postgres para los links y el contador de clicks.
- Autenticación con JWT — solo los usuarios logueados pueden crear URLs cortas.
-
Un binario nativo con
fitz build, listo para meter en un contenedor.
Tamaño final: ~120 líneas de Fitz. Sin requirements.txt. Sin package.json. Sin cargo add. Solo fitz y Postgres.
Vas a salir con:
-
POST /login→ cambiá credenciales por un JWT. -
POST /shorten(requiere auth) → devuelve el código corto. -
GET /{code}→ redirige a la URL original e incrementa el contador. -
GET /stats/{code}(requiere auth) → muestra clicks y fecha de creación. - Un schema OpenAPI 3.1 auto-generado en
/openapi.json. - La UI de Scalar en
/docspara probar desde el browser.
Vamos.
Setup (2 minutos)
Instalá Fitz:
# Linux / macOS / WSL
curl -sSf https://thegreekman76.github.io/fitz/install.sh | sh
# Windows (PowerShell)
irm https://thegreekman76.github.io/fitz/install.ps1 | iex
O bajá un binario pre-compilado desde releases.
Extensión VSCode (fuertemente recomendada para este tutorial — te da hover con tipos, autocomplete, signature help, format on save): desde la misma página de releases bajá el fitz-lang-<plataforma>.vsix que matchee tu OS e instalalo:
code --install-extension fitz-lang-<plataforma>.vsix --force
El Language Server viene incluido adentro del .vsix — no hace falta instalarlo aparte. Recargá VSCode una vez después del install.
Reabrí la terminal para que el cambio de PATH aplique, después verificá:
fitz --version
# fitz 0.15.0
También vas a necesitar un Postgres corriendo. Lo más rápido es Docker:
docker run -d --name pg-shortener \
-e POSTGRES_PASSWORD=demo \
-e POSTGRES_DB=shortener \
-p 5432:5432 \
postgres:16
Ahora creá el proyecto:
fitz new url-shortener --http
cd url-shortener
El flag --http templatea un main.fitz con @get + @server ya cableados. Abrí main.fitz y arrancamos a editar.
Paso 1 — Hello world HTTP
Reemplazá main.fitz con este mínimo:
@server(8080)
fn main() => 0
@get("/health")
fn health() -> Str => "ok"
Corrélo:
fitz dev
fitz dev watchea el archivo y respawnea ante cada guardado — es el equivalente Fitz de uvicorn --reload. En otra terminal:
curl localhost:8080/health
# ok
Abrí http://localhost:8080/docs en el browser. Ya tenés una UI de Scalar con el GET /health documentado. Sin magia de decoradores-que-registran-rutas, sin app.include_router(...). El compilador lee el AST y genera el schema.
Paso 2 — Modelar el dominio
Un Link es el código corto, la URL original, el contador, y el dueño.
type Link {
@primary id: Int,
code: Str,
target_url: Str,
user_email: Str,
clicks: Int,
created_at: Str,
}
El decorador @primary lo marca como primary key. Int es i64 en el binario generado, bigint en Postgres. El ORM entiende esto porque el tipo se lee en el compilador — no en tiempo de ejecución.
Pero todavía no le dijimos a Fitz que este type es una tabla Postgres. Agregamos @table:
@table("links")
type Link {
@primary id: Int,
code: Str,
target_url: Str,
user_email: Str,
clicks: Int,
created_at: Str,
}
Ahora Link.all(db), Link.insert(db, l), Link.where(...), .preload(...) etc. existen sobre este tipo. El checker valida estáticamente que cualquier closure adentro de .where(...) solo referencie campos que existen. Los typos mueren en compile time, no en producción.
Paso 3 — Conectarse a Postgres
db.connect(url) abre un pool de conexiones. Lo hacemos una vez al boot:
let DB_URL = env_or("DATABASE_URL", "postgres://postgres:demo@localhost:5432/shortener")
let db = db.connect(DB_URL)
env_or es un built-in: lee la variable de entorno, fallback al default si no está. Útil para dev local + Docker sin condicionales.
También necesitamos que la tabla exista. La herramienta correcta acá son las migraciones de schema — Fitz trae fitz db diff y fitz db migrate built-in. Declarás la tabla como un type @table, y dejás que el migrator haga el SQL:
@table("links")
type Link {
@primary id: Int,
code: Str,
target_url: Str,
user_email: Str,
clicks: Int = 0,
created_at: Str = "",
}
La primera vez:
$ fitz db diff
+ CREATE TABLE links (
+ id BIGSERIAL PRIMARY KEY,
+ code TEXT NOT NULL,
+ target_url TEXT NOT NULL,
+ user_email TEXT NOT NULL,
+ clicks BIGINT NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT ''
+ );
$ fitz db migrate
✓ aplicada migration_20260530_links.sql
fitz db diff lee los types @table de tu código, introspecciona la DB viva, computa el SQL necesario para sincronizarlas, y emite un archivo de migración idempotente. fitz db migrate aplica las migraciones no aplicadas en orden. Mismo modelo que Alembic, pero con los types como fuente de verdad en lugar de archivos SQL separados que tenés que mantener alineados a mano.
Cuando después agregás name: Str al Link, fitz db diff emite ALTER TABLE links ADD COLUMN name TEXT NOT NULL. Re-ejecutás fitz db migrate. Sin DDL escrito a mano.
Para este tutorial, ejecutá fitz db diff + fitz db migrate una vez antes de levantar el server.
Paso 4 — Crear + redirigir
Dos endpoints. Lo interesante es cuán compactos son:
type ShortenRequest { target_url: Str }
type ShortenResponse { code: Str, short_url: Str }
@post("/shorten")
async fn shorten(db: DbConn, req: ShortenRequest, user: User) -> ShortenResponse {
let code = generar_codigo()
let link = Link {
id: 0,
code: code,
target_url: req.target_url,
user_email: user.email,
clicks: 0,
created_at: "", // el default de la DB lo completa
}
Link.insert(db, link).await
return ShortenResponse {
code: code,
short_url: "http://localhost:8080/{code}",
}
}
Tres cosas para notar:
-
El parámetro
db: DbConn— el runtime inyecta la conexión automáticamente. Fitz se da cuenta por el tipo que viene del pool global. -
El parámetro
user: User— viene de la auth, que cableamos en el próximo paso. El compilador valida estáticamente que el handler tenga@authenticated. -
id: 0— sentinel que le dice al ORM "es el primer insert, ignorá el campo, dejá queBIGSERIALlo asigne". El ORM omite el campo delINSERTautomáticamente.
La redirección:
@get("/{code}")
async fn redirect(db: DbConn, code: Str) -> Result<HttpResponse> {
let link: Link = match Link.where(fn(l) => l.code == code).first(db).await {
Ok(l) => l,
Err(_) => return Err("no encontrado"),
}
// incrementar el contador sin bloquear la redirección
spawn(incrementar_clicks(db, link.id))
return Ok(redirect_to(link.target_url))
}
@background
async fn incrementar_clicks(db: DbConn, link_id: Int) {
Link.where(fn(l) => l.id == link_id)
.update(db, { "clicks": "clicks + 1" })
.await
}
La closure fn(l) => l.code == code se traduce a SQL parametrizado en compile time: WHERE code = $1. No hay eval, ni string concat, ni riesgo de SQL injection. La variable code se convierte en un bind param.
spawn(incrementar_clicks(...)) es fire-and-forget — la respuesta sale sin esperar. El decorador @background autoriza a la función a ser invocada desde un spawn. Es un cerco-de-intención: nada se va a un scheduler en background por accidente.
Paso 5 — Auth con JWT
Tres piezas: el tipo User, el @auth_provider, y el endpoint de /login.
type User { email: Str, name: Str }
type LoginRequest { email: Str, password: Str }
type LoginResponse { token: Str }
let JWT_SECRET = env_or("JWT_SECRET", "demo-secret-cambiame")
let DEMO_HASH = hash.password("demo123")
@auth_provider
fn check_token(headers: Map<Str, Str>) -> Result<User> {
let auth: Str = match headers.get("authorization") {
Ok(v) => v,
Err(_) => return Err("falta Authorization"),
}
let parts = auth.split(" ")
if (parts.len() != 2 or parts[0] != "Bearer") {
return Err("se esperaba 'Bearer <token>'")
}
let claims = jwt.decode(parts[1], JWT_SECRET)?
return Ok(User { email: claims["email"], name: claims["name"] })
}
@post("/login")
fn login(creds: LoginRequest) -> LoginResponse {
if (creds.email != "ada@example.com") {
return 401 { "error": "credenciales inválidas" }
}
if (not hash.verify(creds.password, DEMO_HASH)) {
return 401 { "error": "credenciales inválidas" }
}
let claims = { "email": creds.email, "name": "Ada" }
return LoginResponse { token: jwt.encode(claims, JWT_SECRET) }
}
Qué está pasando:
-
hash.password(...)es Argon2id (la recomendación de OWASP para hashing de passwords en 2026). Al boot del programa hasheamos el password demo"demo123"— en producción esto sería una query SQL. -
jwt.encode(...)firma con HS256 por default. Los claims son unMap<Str, Str>. -
@auth_provideres el singleton global del programa. El checker exige un solo@auth_providerpor programa y que los handlers@authenticatedreferencien un tipoUserválido. - El operador
?sobrejwt.decode(...)?propaga el error hacia arriba — token inválido, expirado, firma incorrecta — todo termina comoErrque el runtime de auth mapea a 401.
Ahora para que los handlers requieran auth, apilamos el decorador:
@authenticated
@post("/shorten")
async fn shorten(db: DbConn, req: ShortenRequest, user: User) -> ShortenResponse { ... }
@authenticated
@get("/stats/{code}")
async fn stats(db: DbConn, code: Str, user: User) -> Result<Link> {
return Link.where(fn(l) => l.code == code and l.user_email == user.email)
.first(db)
.await
}
El parámetro user: User lo inyecta automáticamente el runtime después de que el provider valida el token. Si el token falta o es inválido → 401 sin que el handler corra siquiera. El schema OpenAPI refleja todo esto: aparece el securitySchemes.bearerAuth, los handlers protegidos llevan security: [{bearerAuth: []}], y el 401 queda documentado automático.
El decorador @admin (no lo usamos acá) va más allá: además del token exige user.role == "admin". El compilador valida estáticamente que User tenga un campo role: Str. Si no lo tiene, error en compile time.
Paso 6 — Probarlo
Con fitz dev corriendo en otra terminal:
# Login
curl -X POST localhost:8080/login \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"demo123"}'
# {"token":"eyJ0eXAiOiJKV1Qi..."}
TOKEN="eyJ0eXAi..." # pegá el token del response
# Crear una URL corta
curl -X POST localhost:8080/shorten \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"target_url":"https://github.com/Thegreekman76/fitz"}'
# {"code":"abc123","short_url":"http://localhost:8080/abc123"}
# Redirección (el browser sigue; con curl usá -I)
curl -I localhost:8080/abc123
# HTTP/1.1 302 Found
# location: https://github.com/Thegreekman76/fitz
# Stats
curl localhost:8080/stats/abc123 -H "Authorization: Bearer $TOKEN"
# {"id":1,"code":"abc123","target_url":"...","clicks":1,"created_at":"..."}
Abrí http://localhost:8080/docs y vas a ver la UI completa de Scalar: los cuatro endpoints con el schema correcto, el botón Authorize para pegar el token que funciona, los endpoints protegidos con el iconito de candado. Nada de esto se configuró a mano — vino del compilador leyendo el AST.
Paso 7 — Compilar a binario nativo
Hasta ahora corrimos con fitz run (interpretado). Ahora envíamos:
fitz build
Eso produce url-shortener (o url-shortener.exe en Windows) — un binario nativo standalone. Todo lo demás está estáticamente linkeado excepto libc.
ls -lh url-shortener
# -rwxr-xr-x 1 user user 18M May 29 14:00 url-shortener
DATABASE_URL=postgres://postgres:demo@localhost:5432/shortener ./url-shortener
# server listening on 127.0.0.1:8080
El requisito duro de Fitz es que fitz run y fitz build produzcan comportamiento bit-a-bit idéntico. Mismo output JSON, mismos status codes, mismas queries SQL. Si divergen, es un bug.
Para Docker:
FROM gcr.io/distroless/cc-debian12
COPY url-shortener /url-shortener
ENV DATABASE_URL=postgres://...
ENV JWT_SECRET=...
EXPOSE 8080
CMD ["/url-shortener"]
Imagen distroless con el binario adentro. ~30 MB final. Sin python, sin node, sin cargo — solo tu binario compilado y un libc mínimo.
Paso 8 — Deploy con un solo comando
En realidad no tenés que escribir ese Dockerfile a mano. Fitz lee el shape de tu programa y lo genera por vos:
fitz docker init
Esto crea tres archivos en tu proyecto:
-
Dockerfile— multi-stage, imagen builder más una stage de runtime congcr.io/distroless/cc-debian12.EXPOSE 8080porque hay un@server(8080)en el código. -
.dockerignore— defaults razonables (target/,.git/,.env*,__pycache__/). -
docker-compose.yml— tu app más un servicepostgres:16-alpinecon healthcheck y volumenpgdata, porque hay undb.connect(...)en el código. El env varDATABASE_URLse cablea automático.
La detección es AST-only (~50ms). No hace falta ejecutar el programa para saber qué infraestructura querés. Si hubieras tenido @cron, hubiera agregado restart: unless-stopped. Si hubieras tenido from python import ..., hubiera elegido python:3.12-slim-bookworm en lugar de distroless. Genera lo que vos escribirías a mano, lo commiteás, lo editás cuando lo necesites.
Ahora shippeás:
# Build de imagen, push al registry.
fitz deploy docker --tag mycorp/shortener:v1
# O levantá local con compose (útil para QA local).
fitz deploy compose
fitz deploy es un wrapper fino sobre los CLIs nativos docker/docker compose. No se trata de inventar herramientas nuevas — se trata de que dejes de perseguir errores de Dockerfile equivocados durante dos días cada vez que arrancás un proyecto. Los correctos ya están ahí.
Si querés deploy del binario sin Docker, podés seguir haciendo lo de paso 7 — scp del binario, correrlo. El output de fitz build es genuinamente standalone.
Detalles production: healthchecks, secrets, observability
Ya que estamos hablando de producción, Fitz ya tiene el resto del checklist como parte del lenguaje:
@healthz
fn liveness() -> Bool => true
@readyz
async fn readiness(db: DbConn) -> Bool {
return match db.exec("SELECT 1").await {
Ok(_) => true,
Err(_) => false,
}
}
/healthz y /readyz se auto-montan en el router HTTP. Kubernetes contento.
let JWT_SECRET: Secret<Str> = secret("JWT_SECRET") // nunca printea, nunca loguea el valor
let LOG_LEVEL: Str = config("LOG_LEVEL", "info") // env var tipada con default
Secret<T> es un type opaco — print(JWT_SECRET) imprime "***", la serialización JSON lo redacta, las llamadas a log.info(...) lo strippean de los fields estructurados. La única forma de exponer el valor es .expose() — explícito y greppable en code review.
# Observability con un env var, cero cambios de código.
OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318 ./url-shortener
Cuando el env var está seteado, cada request HTTP abre un span que exporta a OpenTelemetry. trace_id/span_id se propagan a cada log.info(...) adentro del handler — grepeás Jaeger por trace_id y encontrás cada log line relacionado al instante. Cuando el env var no está seteado, hay cero overhead de red — el exporter es no-op.
No tenés que enchufar nada de esto. Ya está.
¿Cuán rápido es lo que acabás de construir?
Aprovechando que hablamos de producción: el boilerplate api-postgres-python del repo implementa el mismo CRUD con forma de shortener que vos escribiste en este tutorial, pero con FastAPI + SQLAlchemy + asyncpg. Mismo Postgres, mismo schema, mismos endpoints, mismo shape de JSON, mismo docker compose. Desde el bench reproducible en v0.10.13 (Intel Core Ultra 7 155H, Docker 29.2.1, 30s sostenidos, concurrencia 10):
| Métrica | Fitz ORM | Python + SQLAlchemy | Speedup |
|---|---|---|---|
| Memory peak | 9.2 MB | 51 MB | 5.5× más eficiente |
GET /users p50 |
4.88 ms | 37.85 ms | 7.76× |
GET /users RPS |
1944 | 246 | 7.91× |
GET /users/{id} p50 |
3.60 ms | 31.87 ms | 8.85× |
GET /users/{id} RPS |
2604 | 296 | 8.80× |
| Cold start | 0.14 s | 0.22 s | 1.57× |
| Image size | 131 MB | 258 MB | 2× más liviano |
Misma máquina, misma red Docker, mismo Postgres. El binario que acabás de compilar en el paso 7 está en la columna de la izquierda. Corré bash benchmarks/orm-vs-sqlalchemy/run.sh desde el repo para reproducir en tu hardware (~5–8 min con cache Docker caliente; requiere oha + jq). Metodología completa y output crudo en benchmarks/orm-vs-sqlalchemy/README.md.
Lo que te queda
Un acortador de URLs funcionando en ~120 líneas de Fitz:
- Server HTTP con OpenAPI 3.1 + UI Scalar.
- ORM Postgres con closure-to-SQL tipado y
fitz db diff/migratepara schema. - Auth con JWT + passwords Argon2id.
- Jobs en background con
spawn. - Binario nativo construido con
fitz build. -
Dockerfile + compose generados del AST con
fitz docker init. -
fitz deploywrappeandodocker build/pushydocker compose up. - Health checks vía
@healthz/@readyz, secrets víaSecret<T>, observability vía OpenTelemetry — todo built-in.
Lo que no instalaste:
- FastAPI, Uvicorn, Pydantic.
- SQLAlchemy, asyncpg, alembic.
- python-jose, passlib, argon2-cffi.
- Celery, Redis (para el spawn).
- OpenTelemetry SDK + paquetes de auto-instrumentation.
- Un linter de Dockerfile, un generador de compose, un script de deploy.
Dependencias externas totales de tu proyecto: 0. Solo Fitz y Postgres.
Qué falta (para contexto)
En un shortener real igualmente querrías:
-
Usuarios reales — hardcodeamos a Ada. Una tabla
Userreal con@tableson dos minutos más. -
Rate limiting por usuario — middleware encima del
@authenticated. Un@middleware(rate_limit)más un contador chico en Redis/Postgres. - Analytics custom — más allá del contador de clicks, querrías user agent, referer, etc. Se suma en 5 líneas más.
-
Tracking en background con retry —
spawnes fire-and-forget. Para retries reales, los jobs@croncon persistencia + retry config (@cron("expr", retry={max: 5, backoff: "exponential"}, store=db)) ya están soportados — cableás una tablaclicks_queuey un@cronque la drene.
Todo realizable hoy con lo que está en la caja.
Qué viene en la serie
En el próximo post sumamos WebSockets al shortener: un dashboard en tiempo real que mira los clicks en vivo, con la misma auth, con AsyncAPI generado automático.
Si te trabaste en algún paso o construiste algo interesante, escribí en issues de GitHub — leo todos.
Repo final: el código completo de este tutorial está en github.com/Thegreekman76/fitz/tree/main/boilerplates/api-orm-full.
Docs y curso: thegreekman76.github.io/fitz
Guía (34 capítulos): thegreekman76.github.io/fitz/guide/
Roadmap: docs/roadmap.md
CHANGELOG: CHANGELOG.md
Nos vemos en el próximo.
Top comments (0)