DEV Community

Cover image for Construí tu primera API con Fitz: un acortador de URLs con Postgres y auth en 30 minutos
Martin Palopoli
Martin Palopoli

Posted on

Construí tu primera API con Fitz: un acortador de URLs con Postgres y auth en 30 minutos

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:

  1. Endpoints HTTP para crear, redirigir, y ver stats.
  2. Persistencia en Postgres para los links y el contador de clicks.
  3. Autenticación con JWT — solo los usuarios logueados pueden crear URLs cortas.
  4. 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 /docs para 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Ahora creá el proyecto:

fitz new url-shortener --http
cd url-shortener
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Corrélo:

fitz dev
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 = "",
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}",
    }
}
Enter fullscreen mode Exit fullscreen mode

Tres cosas para notar:

  1. 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.
  2. 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.
  3. id: 0 — sentinel que le dice al ORM "es el primer insert, ignorá el campo, dejá que BIGSERIAL lo asigne". El ORM omite el campo del INSERT automá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
}
Enter fullscreen mode Exit fullscreen mode

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) }
}
Enter fullscreen mode Exit fullscreen mode

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 un Map<Str, Str>.
  • @auth_provider es el singleton global del programa. El checker exige un solo @auth_provider por programa y que los handlers @authenticated referencien un tipo User válido.
  • El operador ? sobre jwt.decode(...)? propaga el error hacia arriba — token inválido, expirado, firma incorrecta — todo termina como Err que 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
}
Enter fullscreen mode Exit fullscreen mode

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":"..."}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Esto crea tres archivos en tu proyecto:

  • Dockerfile — multi-stage, imagen builder más una stage de runtime con gcr.io/distroless/cc-debian12. EXPOSE 8080 porque hay un @server(8080) en el código.
  • .dockerignore — defaults razonables (target/, .git/, .env*, __pycache__/).
  • docker-compose.yml — tu app más un service postgres:16-alpine con healthcheck y volumen pgdata, porque hay un db.connect(...) en el código. El env var DATABASE_URL se 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
Enter fullscreen mode Exit fullscreen mode

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,
    }
}
Enter fullscreen mode Exit fullscreen mode

/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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/migrate para 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 deploy wrappeando docker build/push y docker compose up.
  • Health checks vía @healthz/@readyz, secrets vía Secret<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 User real con @table son 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 retryspawn es fire-and-forget. Para retries reales, los jobs @cron con persistencia + retry config (@cron("expr", retry={max: 5, backoff: "exponential"}, store=db)) ya están soportados — cableás una tabla clicks_queue y un @cron que 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)