DEV Community

Cover image for Presentando Fitz: un lenguaje donde HTTP, Postgres, JWT y WebSockets son parte de la sintaxis
Martin Palopoli
Martin Palopoli

Posted on

Presentando Fitz: un lenguaje donde HTTP, Postgres, JWT y WebSockets son parte de la sintaxis

Fitz es un lenguaje de programación nuevo, escrito en Rust, con compilador de tipado gradual. La premisa: en lugar de apilar FastAPI + SQLAlchemy + python-jose + Celery + Pydantic + uvicorn + Alembic + typer sobre Python, lo que cada una resuelve vive adentro del lenguaje: ruteo HTTP, generación de OpenAPI/AsyncAPI, async/await, autenticación con JWT, hashing de passwords, un ORM con driver Postgres escrito en Rust puro, migraciones de schema, WebSockets, cron, jobs en background, un CLI builder, healthchecks, observability con OpenTelemetry, secrets como tipos opacos, y un orquestador fitz deploy. Un solo binario. Cero deps externas para el stack core. Repo: github.com/Thegreekman76/fitz · Docs: thegreekman76.github.io/fitz

Hace años que vengo escribiendo APIs en Python — FastAPI más el elenco habitual: SQLAlchemy, python-jose para JWT, passlib para Argon2, Celery + Redis para jobs, Pydantic para validación, uvicorn para servir, alembic para migraciones. Cada API que entrego necesita más o menos las mismas nueve librerías, cada una con sus convenciones, sus breaking changes, su forma de integrarse con el resto.

En algún momento me hice la pregunta obvia: ¿por qué no es esto el lenguaje y listo?

Esa pregunta es Fitz.

Cómo se ve Fitz

Empecemos por la foto, después caminamos las piezas.

@server(43928)
fn main() => 0

type User { id: Int, email: Str, name: Str, role: Str }
type Credentials { email: Str, password: Str }
type LoginResponse { token: Str }

let SECRET = "demo-secret-cambiame-en-prod"
let ADA_HASH = hash.password("secret-ada-123")

@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 header 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], SECRET)?
    return find_user(claims["email"])
}

@post("/login")
fn login(creds: Credentials) -> LoginResponse {
    let user: User = match find_user(creds.email) {
        Ok(u) => u,
        Err(_) => return 401 { "error": "credenciales inválidas" },
    }
    if (not hash.verify(creds.password, ADA_HASH)) {
        return 401 { "error": "credenciales inválidas" }
    }
    let claims = { "email": user.email, "role": user.role }
    return LoginResponse { token: jwt.encode(claims, SECRET) }
}

@authenticated
@get("/me")
fn me(user: User) -> User => user

@admin
@get("/admin/users")
fn admin_list(user: User) -> List<User> { ... }
Enter fullscreen mode Exit fullscreen mode

Lo que hace este código, sin un solo import ni una dependencia externa:

  • Levanta un servidor HTTP en el puerto 43928.
  • Genera OpenAPI 3.1 automático en /openapi.json.
  • Sirve la UI de Scalar en /docs con botón "Authorize" funcional.
  • Firma y verifica JWT (HS256/384/512).
  • Hashea passwords con Argon2id (recomendación de OWASP, no bcrypt).
  • Valida estáticamente que cada @authenticated/@admin tenga un @auth_provider declarado, que el provider devuelva el tipo User correcto, y que los handlers @admin tengan un campo role: Str en el User.
  • Compila a un binario nativo con fitz build, con paridad bit-a-bit contra fitz run.

La auth, el hashing, el JWT, el OpenAPI con el security scheme bearerAuth, las respuestas 401/403 — todo eso vive adentro del binario fitz. No hay requirements.txt, no hay package.json, no hay Cargo.toml del lado del usuario.

Por qué "ciudadano de primera" importa

"Ciudadano de primera clase" es una de esas frases que se gastan rápido. Lo digo concreto.

En FastAPI, @app.get("/users") es un método sobre una instancia. El framework es una librería de la cual hacés opt-in. El router es una estructura de datos Python. La autenticación es un Depends(...). Nada de eso es visible para el type checker como algo especial — son simplemente llamadas a funciones y decoradores que producen metadata.

En Fitz, @get("/users") es un decorador que el compilador entiende. El checker valida el template del path, los tipos de los parámetros contra los path params, el tipo del body, el tipo de retorno. El generador de OpenAPI inspecciona el AST directamente — no introspeciona objetos de runtime, no necesita decoradores que se "registren" a sí mismos. El User que devolvés en tu handler es el mismo User que aparece en el schema generado y en la UI de Scalar.

Suena chico hasta que lo vivís una semana. Después dejás de pelear con "por qué Pydantic discrepa con SQLAlchemy sobre si este campo es opcional" y empezás a escribir endpoints.

Las piezas

HTTP + OpenAPI + UI Scalar, todo automático

type Post { id: Int, title: Str, body: Str, tags: List<Str> }

@get("/posts")
fn list_posts() -> List<Post> { ... }

@post("/posts")
fn create_post(post: Post) -> Post { ... }
Enter fullscreen mode Exit fullscreen mode

Eso es todo lo que necesitás. /openapi.json y /docs (Scalar) aparecen solos. Path params (/posts/{id}) son tipados y coercionados. La deserialización del JSON del body chequea required, aplica defaults, valida nullables, rechaza campos extra. Podés desactivar con @server(docs=false).

WebSockets tipados, con AsyncAPI auto-generado

type ChatMessage { from: Str, text: Str }

@server(43929, ws_heartbeat_secs=30)
fn main() => 0

@authenticated
@ws("/chat")
async fn chat(conn: WsConn<ChatMessage>, user: User) {
    loop {
        let msg = match conn.recv() {
            Ok(m) => m,
            Err(_) => break,
        }
        conn.broadcast(ChatMessage { from: user.name, text: msg.text })
    }
}
Enter fullscreen mode Exit fullscreen mode

Cada frame se marshallea automáticamente desde y hacia el tipo declarado. La auth corre antes del upgrade WebSocket — token inválido devuelve 401 sin abrir el socket. El heartbeat con ping/pong mantiene la conexión viva más allá de los 60s default de Nginx. /asyncapi.json se genera solo (la spec hermana de OpenAPI para APIs event-driven). No conozco otro lenguaje que auto-genere AsyncAPI desde el código fuente tipado.

Jobs en background y cron, sin Redis

@cron("*/5 * * * *")
async fn cleanup_old_sessions() {
    db.exec("DELETE FROM sessions WHERE expires_at < now()")
}

@background
async fn send_welcome_email(email: Str) {
    // cosa cara
}

@post("/signup")
fn signup(creds: Credentials) -> User {
    let user = create_user(creds)
    spawn(send_welcome_email(user.email))  // fire-and-forget, Future<Null> tipado
    return user
}
Enter fullscreen mode Exit fullscreen mode

Sin Celery. Sin Redis. Sin celery worker -A app corriendo al lado del uvicorn. El scheduler está en tu binario. Suficiente para el 90% de servicios — cuando lo superás, lo superás por una razón concreta, y eso es problema de Fase 11+.

Un ORM nativo con driver Postgres en Rust puro

Esta es la pieza de la que más orgulloso estoy, y la que más tiempo me llevó. Fitz tiene su propio driver de Postgres escrito en Rust — sin libpq, sin tokio-postgres, sin sqlx. El protocolo wire (v3.0), auth SCRAM-SHA-256, prepared statements, formato binario para 11 tipos OID — todo implementado desde el RFC.

@table("users")
type User {
    @primary id: Int,
    email: Str,
    name: Str,
    @has_many("Post", "user_id") posts: List<Post>,
}

@table("posts")
type Post {
    @primary id: Int,
    user_id: Int,
    title: Str,
    body: Str,
    @belongs_to user: User?,
}

@get("/users")
async fn list_users(db: DbConn) -> List<User> {
    return User.all(db).preload("posts").await
}

@get("/users/{id}")
async fn get_user(db: DbConn, id: Int) -> Result<User> {
    return User.where(fn(u) => u.id == id).first(db).await
}

@post("/users")
async fn create_user(db: DbConn, user: User) -> User {
    return User.insert(db, user).await
}
Enter fullscreen mode Exit fullscreen mode

La closure adentro de .where(...) se traduce a SQL parametrizado en tiempo de compilaciónfn(u) => u.id == id se convierte en WHERE id = $1. Operadores como .is_in([...]), .like(...), .ilike(...), .contains(...), más operadores JSONB como .has_key(...), .contains_json(...) mapean a operadores nativos de Postgres. El eager loading con .preload("posts") dispara una sola query batched. Agregados (.sum/.avg/.min/.max/.count) y GROUP BY están soportados a través de un tipo separado Aggregated<Row>.

Esto compila a código nativo vía fitz build. El binario generado hace exactamente las mismas calls a Postgres. Cero overhead de runtime para el SQL — ya es constante en build-time, comparable en performance con Diesel o sqlx.

from python import math, json

let radius = 5.0
let area: Float = math.pi * radius * radius

let parsed: Result<Map<Str, Any>> = match json.loads("{\"name\": \"ada\"}") {
    Ok(d) => Ok(d),
    Err(e) => Err("JSON malformado: {e}"),
}
Enter fullscreen mode Exit fullscreen mode

SQLAlchemy, NumPy, pandas, lo que esté en PyPI — accesible desde Fitz con from python import .... El runtime embebe CPython vía PyO3. Las excepciones Python se vuelven Result::Err automáticamente. Async Python (asyncpg, SQLAlchemy 2.x async) se bridgea al .await de Fitz transparente. Incluso podés hacer fitz build --bundle-python para entregar un binario con CPython embebido — no se necesita Python en la máquina destino.

Esto es intencional. Fitz no quiere reemplazar el ecosistema de Python — quiere darte un lenguaje mejor para la capa web mientras dejás abierta la puerta a todo lo que Python ya construyó.

Async, finalmente sin color

async fn fetch_user(id: Int) -> Result<User> { ... }

async fn main() {
    let user = fetch_user(42).await?
    print("llegó {user.name}")
}
Enter fullscreen mode Exit fullscreen mode

async/await en el core, sobre runtime tokio. El operador ? funciona a través de Result<T>. El type checker exige que ? solo aparezca adentro de funciones que retornan Result<...>. Compila a async fn + .await en Rust — mismo modelo de ejecución, mismo executor multi-thread.

CLI builder — el mismo lenguaje, herramientas de línea de comandos

Fitz no es solo para servicios HTTP. El mismo compilador trae un CLI builder built-in, sin librerías:

@command("greet", desc="Saludar a una persona")
fn greet(name: Str, loud: Bool = false, count: Int = 1) -> Int {
    let n = count
    while n > 0 {
        if loud { print("HOLA, {name}!") } else { print("hola, {name}") }
        n = n - 1
    }
    return 0
}

@command("add", desc="Sumar dos números")
fn add(a: Int, b: Int) -> Int {
    print("{a + b}")
    return 0
}
Enter fullscreen mode Exit fullscreen mode
$ ./mybin greet Ada --loud --count 3
HOLA, Ada!
HOLA, Ada!
HOLA, Ada!

$ ./mybin --help
USAGE: mybin <command> [ARGS] [OPTIONS]
COMMANDS:
    greet    Saludar a una persona
    add      Sumar dos números
Enter fullscreen mode Exit fullscreen mode

Convención sobre decoración: params sin default son positional args, params con default son flags. Bool con default = false se vuelve --flag, otros tipos --flag <value>. Short flags auto-derivados (--loud-l) con detección de conflictos. Help auto-generado, exit codes POSIX estándar. Paridad bit-a-bit entre fitz run (desarrollo) y fitz build (binario self-contained que dropeás en /usr/local/bin).

Es el mismo lenguaje. Mismo type checker. Mismo async/await. Mismo Result<T> para errores. Si tu herramienta necesita pegar a la DB, el ORM está ahí. Si necesita HTTP, @get/@post están ahí. La línea entre "servicio web" y "CLI tool" deja de ser una decisión de stack.

Stack production-ready — del repo a producción

Esto es lo que separa a Fitz de los lenguajes "prototipo interesante". Los servicios reales necesitan health checks, secrets, observability, y una forma de shippear. Todo eso es parte del lenguaje:

@server(43928)
fn main() => 0

// Auto-montados en GET /healthz y /readyz — Kubernetes-friendly.
@healthz
fn liveness() -> Bool => true

@readyz
async fn readiness(db: DbConn) -> Bool {
    return match db.exec("SELECT 1").await {
        Ok(_) => true,
        Err(_) => false,
    }
}

// Secret<T> nunca leakea a logs, imprime "***" en Display.
let db_url: Secret<Str> = secret("DATABASE_URL")
let log_level: Str = config("LOG_LEVEL", "info")

// Tracing + métricas con un decorador cada uno.
@trace(name="process_order")
@metric(name="orders")
async fn process(order: Order) -> Result<Receipt> {
    // process_order_duration_seconds (histogram) y orders_calls_total
    // (counter) se populan automático al hacer drop del scope.
}

// Feature flags con dos fuentes: fitz.toml [flags] + env vars FITZ_FLAG_<NAME>.
@flag("new-checkout")
@post("/v2/checkout")
fn v2_checkout(body: Cart) -> Receipt { ... }
Enter fullscreen mode Exit fullscreen mode

Atrás de bambalinas:

  • HTTP access logs auto-emitidos con trace_id/span_id propagado a cada log.info(...) adentro del handler.
  • Export OpenTelemetry OTLP con un solo env var: OTEL_EXPORTER_OTLP_ENDPOINT. Los spans fluyen a Jaeger/Tempo/Honeycomb. Sin el env var, cero overhead, cero llamadas de red.
  • Endpoint /metrics Prometheus expone counters y histogramas — @server(prometheus=true) lo activa.
  • @flag sobre handlers HTTP/WS retorna 404 cuando la flag está off — gate del hot path ANTES de middleware/auth.

Deployando:

# Genera Dockerfile + docker-compose.yml a partir del shape del programa.
fitz docker init

# Build del binario, build de imagen Docker, push al registry.
fitz deploy docker --tag mycorp/api:v1

# O levantá local con compose.
fitz deploy compose
Enter fullscreen mode Exit fullscreen mode

fitz docker init lee tu AST. Si hay db.connect(...), agrega Postgres al compose. Si hay @server(N), setea EXPOSE N. Si hay @cron, agrega restart: unless-stopped. Si hay from python import ..., elige 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.

No conozco otro lenguaje donde deployment sea una feature del lenguaje. Acá lo es porque cada proyecto que entregué en Python terminaba con dos días debuggeando gotchas del Dockerfile.

Las herramientas

Esta es la parte que subestimé cuando arranqué. Un lenguaje sin buenas herramientas nace muerto. Acá lo que hay hoy:

  • fitz run — interpreta el archivo directo. El ciclo de feedback más rápido.
  • fitz build — compila a binario nativo vía un proyecto Rust generado. La paridad bit-a-bit con fitz run es un requisito duro.
  • fitz check — solo type checker, sin ejecución.
  • fitz test — test runner built-in con decorador @test y assert, assert_eq, assert_throws. Output estilo cargo.
  • fitz dev — hot reload. Watchea *.fitz y fitz.toml, mata y respawnea el child al cambio.
  • fitz fmt — formatter opinionado, cero config. Preserva tus comentarios y líneas en blanco.
  • fitz lint — 4 lints built-in con supresión // @allow(<nombre>). Output estilo cargo-clippy.
  • fitz repl — REPL interactivo con soporte multi-línea, :type, :load, historial persistente.
  • fitz openapi — emite el schema OpenAPI sin levantar el server.
  • fitz db diff/migrate — tooling de migraciones de schema. Diff entre la DB viva y los types @table en tu código, genera migraciones idempotentes, las aplicás con fitz db migrate. Mismo modelo que Alembic pero con los types como fuente de verdad.
  • fitz docker init/build — genera el Dockerfile + .dockerignore + docker-compose.yml a partir del shape del programa, después docker build wrappeado.
  • fitz deploy docker/compose — wrapper fino para shippear la imagen o levantar local con un solo comando.
  • Extensión VSCode — diagnostics + hover + go-to-definition + autocomplete + signature help + format on save + inferencia bidireccional para callbacks, distribución multi-platform.
  • fitz new + fitz add + fitz remove + fitz update — package manager con fitz.toml, lockfile, path deps, git deps.

El LSP es real (tower-lsp adentro). El formatter es real (tu código hace round-trip por él). El test runner es real. Todo está dogfooded — escribo código Fitz con la misma extensión VSCode que entrego.

Siendo honesto sobre el estado

Esto es un proyecto de un solo desarrollador. Empecé aprendiendo Rust para construirlo. No voy a fingir que está listo para producción para cualquiera — esto es lo verdadero hoy (junio 2026, release v0.15.0):

Lo que funciona end-to-end, con paridad bit-a-bit fitz runfitz build:

  • Server HTTP con @get/@post/@put/@delete, OpenAPI auto, UI Scalar.
  • Chain de middleware con @middleware(fn) + CORS built-in.
  • Auth con JWT con @auth_provider/@authenticated/@admin + @requires("role_custom") para RBAC. Hashing de passwords con Argon2id. Token blacklist sobre Postgres para logout/refresh.
  • WebSockets con WsConn<T>, AsyncAPI auto, heartbeat, auth pre-upgrade.
  • Cron jobs con @cron("expr") (con retry, timezone, persistencia, catch-up), jobs background con @background + spawn(...).
  • ORM Postgres con @table/@primary/@column/@belongs_to/@has_many, closure-to-SQL, eager loading, transacciones (db.transaction(fn)), migraciones de schema (fitz db diff/migrate).
  • TLS estricto para Postgres (sslmode=require).
  • Async/await sobre tokio.
  • Interop Python con from python import ..., incluyendo bridge automático para async.
  • CLI builder con @command — mismo lenguaje para CLI tools.
  • Stack production: @healthz/@readyz, Secret<T>, secret()/config(), @trace/@metric, @flag, export OpenTelemetry OTLP, endpoint Prometheus /metrics, fitz docker init/build, fitz deploy.
  • Package manager con path deps y git deps.
  • Tooling completo: LSP (con signature help, format on save, hover sobre params y bindings), fmt, test, dev, repl, lint.

Lo que todavía no está en la caja:

  • Frontend en .fitz (single-file components, SSR). Roadmap (Fase 11) — la apuesta más ambiciosa del proyecto. Sin arrancar.
  • Un registry público de paquetes. Path deps y git deps funcionan hoy; el registry está en pausa hasta que aparezca demanda real.
  • Targets de fitz deploy más allá de docker/compose (todavía no hay wrapper de fly/railway/k8s — usá los CLIs nativos).
  • Debugging interactivo en VSCode (Debug Adapter Protocol). Workarounds: print, REPL :type/:env, diagnostics LSP. Trackeado como V6 en el backlog.

Lo que es estable: ~3030 tests unit de Rust + 13 LSP E2E + 360 compile E2E (smoke sobre cada ejemplo de la guía) + ~140 más entre otras suites corriendo en CI en cada push. Clippy -D warnings limpio.

Cómo probarlo

# Instalación en Linux / macOS / WSL
curl -sSf https://thegreekman76.github.io/fitz/install.sh | sh

# Instalación en Windows (PowerShell)
irm https://thegreekman76.github.io/fitz/install.ps1 | iex

# O bajá un binario desde GitHub
# https://github.com/Thegreekman76/fitz/releases

# Reabrí la terminal para que el cambio de PATH aplique, después:
fitz --version
Enter fullscreen mode Exit fullscreen mode

Extensión VSCode (recomendado — syntax highlighting, hover con tipos, autocomplete, signature help, format on save): bajá el .vsix de tu plataforma desde la misma página de releases (fitz-lang-<plataforma>.vsix) e instalala con code --install-extension fitz-lang-<plataforma>.vsix --force. El Language Server viene incluido — no hace falta instalarlo aparte. Recargá VSCode una vez.

Primer server:

fitz new mi-api --http
cd mi-api
fitz dev
Enter fullscreen mode Exit fullscreen mode

Vienen ocho boilerplates en el repo bajo boilerplates/:

  • api-simple — API HTTP mínima.
  • api-middleware-cors — chain de middleware + config de CORS.
  • api-postgres-fitz — ORM + Postgres, Dockerizado.
  • api-postgres-python — Postgres vía interop Python/SQLAlchemy.
  • api-websocket — chat WebSocket tipado.
  • api-orm-full — el showcase completo: auth + ORM + WebSockets + cron + jobs.
  • api-fullstack-postgres — backend + frontend mínimo en un binario.
  • cli-tool — app CLI con @command (sin HTTP).

Cada uno corre con docker compose up o fitz dev. El README tiene la matriz completa.

Por qué construí esto

Vivo en El Chaltén, en la Patagonia argentina. El Fitz Roy es la torre de granito que define el horizonte acá. Borges escribió que vivimos en un país donde el pasado es incierto y solo el futuro es real. Creo que también vale para los lenguajes de programación: el pasado está lleno de workarounds acumulados por features que faltan en el lenguaje, y el futuro es lo que vos decidís construir.

Llevo diez años escribiendo código de APIs en Python. Amo FastAPI. Pero cada vez que arranco un proyecto nuevo, las primeras tres horas se van pegando librerías para hacer lo mismo que hice la semana pasada. En algún punto la pregunta se vuelve: ¿cómo sería un lenguaje que arrancara desde este conjunto de necesidades en 2026, en lugar de hacerlas crecer como parches sobre un lenguaje diseñado para scripting de shell en 1991?

Eso es Fitz.

No está terminado. Soy uno solo. Va a llegar.

Repo: github.com/Thegreekman76/fitz
Docs y curso: thegreekman76.github.io/fitz
Guía (34 capítulos): thegreekman76.github.io/fitz/guide/
Roadmap: docs/roadmap.md
CHANGELOG: CHANGELOG.md — cada release con detalle.
Issues: github.com/Thegreekman76/fitz/issues

Si lo probás, quiero saber qué se rompió. Abrí un issue o una discussion en GitHub.

Top comments (0)