Zum Hauptinhalt springen
•
YouTube Security VideosMicrosoft Developer: Migrate to Azure SQL. Then Build Something New(20.10.2026 um 23:00 Uhr)
••
YouTube Security VideosMicrosoft Developer: SQL Server 2025: AI-Ready Wherever You Run It(20.10.2026 um 23:00 Uhr)
•••
YouTube Security VideosMicrosoft Developer: Hyperscale for Every Workload(20.10.2026 um 23:00 Uhr)
•
YouTube Security VideosMicrosoft Developer: SQL Meets Fabric: Migrate, Mirror, Build(20.10.2026 um 23:00 Uhr)
••••
YouTube Security VideosMicrosoft Developer: Migrate to Azure SQL. Then Build Something New(20.10.2026 um 23:00 Uhr)
••
YouTube Security VideosMicrosoft Developer: SQL Server 2025: AI-Ready Wherever You Run It(20.10.2026 um 23:00 Uhr)
•••
YouTube Security VideosMicrosoft Developer: Hyperscale for Every Workload(20.10.2026 um 23:00 Uhr)
•
YouTube Security VideosMicrosoft Developer: SQL Meets Fabric: Migrate, Mirror, Build(20.10.2026 um 23:00 Uhr)
•••
Intelligence View
⚡ tsecurity.de Intelligence

Build a URL shortener with Fitz: HTTP + Postgres + auth in 30 minutes

A step-by-step tutorial through Fitz. We start from fitz new, finish with a native binary running in Docker. No external dependencies. No pip install. Just…

Beitrag
0
Seite
0
↗ Quelle (dev.to)
Social ReaktionenReagiere als Erste:r — dein Feedback zählt!

A step-by-step tutorial through Fitz. We start from fitz new, finish with a native binary running in Docker. No external dependencies. No pip install. Just typed Postgres, JWT auth, and OpenAPI auto-generated.







What we're building



A URL shortener with the four things every real API needs:





  1. HTTP endpoints for create, redirect, and stats.


  2. Postgres persistence for the links and the click counter.


  3. JWT authentication — only logged-in users can create short URLs.


  4. A native binary with fitz build, ready to drop into a container.



Final size: ~120 lines of Fitz. No requirements.txt. No package.json. No cargo add. Just fitz and Postgres.



You'll come out with:





  • POST /login → swap credentials for a JWT.


  • POST /shorten (auth required) → returns the short code.


  • GET /{code} → redirects to the original URL and increments the counter.


  • GET /stats/{code} (auth required) → shows clicks and creation date.

  • An auto-generated OpenAPI 3.1 schema at /openapi.json.

  • The Scalar UI at /docs to test from the browser.



Let's go.






Setup (2 minutes)



Install 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






Or grab a pre-compiled binary from the releases.



VSCode extension (strongly recommended for this tutorial — you get hover with types, autocomplete, signature help, format on save): from the same releases page grab the fitz-lang-<platform>.vsix matching your OS and install it:




code --install-extension fitz-lang-<platform>.vsix --force






The Language Server is bundled inside the .vsix — no separate install. Reload VSCode once after the install.



Reopen the terminal so the PATH change applies, then check:




fitz --version
# fitz 0.15.0






You'll also need a running Postgres. The fastest is Docker:




docker run -d --name pg-shortener \
-e POSTGRES_PASSWORD=demo \
-e POSTGRES_DB=shortener \
-p 5432:5432 \
postgres:16






Now create the project:




fitz new url-shortener --http
cd url-shortener






The --http flag templates a main.fitz with @get + @server already wired. Open main.fitz and let's start editing.






Step 1 — Hello world HTTP



Replace main.fitz with this minimum:




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

@get("/health")
fn health() -> Str => "ok"






Run it:




fitz dev






fitz dev watches the file and respawns on every save — it's the Fitz equivalent of uvicorn --reload. In another terminal:




curl localhost:8080/health
# ok






Open http://localhost:8080/docs in your browser. You already have a Scalar UI with GET /health documented. No decorator-to-register-routes magic, no app.include_router(...). The compiler reads the AST and generates the schema.






Step 2 — Model the domain



A Link is the short code, the original URL, the counter, and the owner.




type Link {
@primary id: Int,
code: Str,
target_url: Str,
user_email: Str,
clicks: Int,
created_at: Str,
}






The @primary decorator marks it as primary key. Int is i64 in the generated binary, bigint in Postgres. The ORM understands this because the type is read in the compiler — not at runtime.



But we haven't told Fitz that this type is a Postgres table. Adding @table:




@table("links")
type Link {
@primary id: Int,
code: Str,
target_url: Str,
user_email: Str,
clicks: Int,
created_at: Str,
}






Now Link.all(db), Link.insert(db, l), Link.where(...), .preload(...) etc. exist on this type. The checker validates statically that any closure inside .where(...) only references existing fields. Typos die at compile time, not in production.






Step 3 — Connect to Postgres



db.connect(url) opens a connection pool. We do it once at boot:




let DB_URL = env_or("DATABASE_URL", "postgres://postgres:demo@localhost:5432/shortener")
let db = db.connect(DB_URL)






env_or is a built-in: reads the environment variable, falls back to the default if it's not set. Useful for local dev + Docker without conditionals.



We also need the table to exist. The right tool here is schema migrations — Fitz has fitz db diff and fitz db migrate built-in. Declare the table as a @table type, then let the migrator do the SQL:




@table("links")
type Link {
@primary id: Int,
code: Str,
target_url: Str,
user_email: Str,
clicks: Int = 0,
created_at: Str = "",
}






The first time:




$ 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
✓ applied migration_20260530_links.sql






fitz db diff reads the @table types in your code, introspects the live DB, computes the SQL needed to bring them in sync, and emits an idempotent migration file. fitz db migrate applies the unapplied migrations in order. Same model as Alembic, but with the types as source of truth instead of separate SQL files you have to keep aligned by hand.



When you add name: Str to Link later, fitz db diff emits ALTER TABLE links ADD COLUMN name TEXT NOT NULL. You re-run fitz db migrate. No hand-written DDL.



For this tutorial, run fitz db diff + fitz db migrate once before starting the server.






Step 4 — Create + redirect



Two endpoints. The interesting thing is how compact they are:




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 = generate_code()
let link = Link {
id: 0,
code: code,
target_url: req.target_url,
user_email: user.email,
clicks: 0,
created_at: "", // DB default fills it in
}
Link.insert(db, link).await
return ShortenResponse {
code: code,
short_url: "http://localhost:8080/{code}",
}
}






Three things to note:





  1. The db: DbConn parameter — the runtime injects the connection automatically. Fitz figures out it's coming from the global pool by type.


  2. The user: User parameter — this comes from auth, which we'll wire up in the next step. The compiler statically validates the handler is @authenticated.


  3. id: 0 — sentinel that tells the ORM "this is the first insert, ignore the field, let BIGSERIAL assign it". The ORM omits the field from the INSERT automatically.



The redirect:




@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("not found"),
}
// increment the counter without blocking the redirect
spawn(increment_clicks(db, link.id))
return Ok(redirect_to(link.target_url))
}

@background
async fn increment_clicks(db: DbConn, link_id: Int) {
Link.where(fn(l) => l.id == link_id)
.update(db, { "clicks": "clicks + 1" })
.await
}






The closure fn(l) => l.code == code gets translated to parametrized SQL at compile time: WHERE code = $1. There's no eval, no string concat, no SQL injection risk. The code variable becomes a bind parameter.



spawn(increment_clicks(...)) is fire-and-forget — the response goes back without waiting. The @background decorator authorizes the function to be called from a spawn. It's a fence-of-intent: nothing accidentally goes to a background scheduler.






Step 5 — JWT auth



Three pieces: the User type, the @auth_provider, the /login endpoint.




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-change-me")
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("missing Authorization"),
}
let parts = auth.split(" ")
if (parts.len() != 2 or parts[0] != "Bearer") {
return Err("expected '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 != "[email protected]") {
return 401 { "error": "invalid credentials" }
}
if (not hash.verify(creds.password, DEMO_HASH)) {
return 401 { "error": "invalid credentials" }
}
let claims = { "email": creds.email, "name": "Ada" }
return LoginResponse { token: jwt.encode(claims, JWT_SECRET) }
}






What's happening:





  • hash.password(...) is Argon2id (the OWASP recommendation for password hashing in 2026). At program boot we hash the demo password "demo123" — in production this would be a SQL query.


  • jwt.encode(...) signs with HS256 by default. The claims are a Map<Str, Str>.


  • @auth_provider is the global singleton of the program. The checker enforces only one @auth_provider per program and that handlers @authenticated reference a valid User type.

  • The ? operator on jwt.decode(...)? propagates the error upward — invalid token, expired, wrong signature — everything ends up as Err that the auth runtime maps to a 401.



Now to require auth on the handlers, stack the decorator:




@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
}






The user: User parameter is automatically injected by the runtime after the provider validates the token. If the token is missing/invalid → 401 without the handler ever running. The OpenAPI schema reflects all of this: securitySchemes.bearerAuth appears, the protected handlers carry security: [{bearerAuth: []}], and 401 is documented automatically.



The @admin decorator (not used here) takes it further: it requires user.role == "admin" in addition to the token. The compiler enforces statically that User has a role: Str field. If it doesn't, error at compile time.






Step 6 — Test it



With fitz dev running on another terminal:




# Login
curl -X POST localhost:8080/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"demo123"}'
# {"token":"eyJ0eXAiOiJKV1Qi..."}

TOKEN="eyJ0eXAi..." # paste the token from the response

# Create a short URL
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"}

# Redirect (browser follows; with curl use -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":"..."}






Open http://localhost:8080/docs and you'll see the full Scalar UI: all four endpoints with the right schema, the Authorize button that paste the token works, the protected endpoints with the lock icon. None of this was set up by hand — it came from the compiler reading the AST.






Step 7 — Compile to a native binary



So far we ran with fitz run (interpreted). Now we ship:




fitz build






That produces url-shortener (or url-shortener.exe on Windows) — a single self-contained native binary. Everything is statically linked except 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






The hard requirement of Fitz is that fitz run and fitz build produce bit-for-bit identical behavior. Same JSON output, same status codes, same SQL queries. If they diverge, it's a bug.



For Docker:




FROM gcr.io/distroless/cc-debian12
COPY url-shortener /url-shortener
ENV DATABASE_URL=postgres://...
ENV JWT_SECRET=...
EXPOSE 8080
CMD ["/url-shortener"]






Distroless image with the binary inside. ~30 MB final. No python, no node, no cargo — just your compiled binary and a minimal libc.






Step 8 — Deploy with one command



You don't actually have to write that Dockerfile by hand. Fitz reads the shape of your program and generates it for you:




fitz docker init






This creates three files in your project:





  • Dockerfile — multi-stage, builder image plus a gcr.io/distroless/cc-debian12 runtime stage. EXPOSE 8080 because there's an @server(8080) in the code.


  • .dockerignore — sane defaults (target/, .git/, .env*, __pycache__/).


  • docker-compose.yml — your app plus a postgres:16-alpine service with a healthcheck and pgdata volume, because there's a db.connect(...) in the code. The DATABASE_URL env var is wired automatically.



The detection is AST-only (~50ms). No need to run the program to figure out what infrastructure it wants. If you had @cron, it would add restart: unless-stopped. If you had from python import ..., it would pick python:3.12-slim-bookworm over distroless. It generates what you'd write by hand, you commit it, you edit it when you need to.



Now you ship:




# Build the image, push to a registry.
fitz deploy docker --tag mycorp/shortener:v1

# Or bring up locally with compose (handy for local QA).
fitz deploy compose






fitz deploy is a thin wrapper over the native docker/docker compose CLIs. The point isn't to invent new tools — it's that you stop chasing wrong Dockerfile mistakes for two days every time you start a project. The right ones are already there.



If you want the binary deployment without Docker, you can keep doing what we did in step 7 — scp the binary, run it. The fitz build output is genuinely standalone.






Production niceties: healthchecks, secrets, observability



While we're talking about production, Fitz already has the rest of the production checklist as part of the language:




@healthz
fn liveness() -> Bool => true

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






/healthz and /readyz auto-mount on the HTTP router. Kubernetes is happy.




let JWT_SECRET: Secret<Str> = secret("JWT_SECRET")  // never prints, never logs the value
let LOG_LEVEL: Str = config("LOG_LEVEL", "info") // typed env var with default






Secret<T> is an opaque type — print(JWT_SECRET) prints "***", JSON serialization redacts it, the log.info(...) calls strip it from structured fields. The only way to expose the value is .expose() — explicit and grep-able for code review.




# Observability with one env var, zero code changes.
OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318 ./url-shortener






When the env var is set, every HTTP request opens a span that exports to OpenTelemetry. trace_id/span_id are propagated to every log.info(...) inside the handler — you grep Jaeger by trace_id and instantly find every related log line. When the env var is not set, there is zero network overhead — the exporter is a no-op.



You don't have to bolt any of this on. It's already there.






How fast is the thing you just built?



While we're talking production: the api-postgres-python boilerplate in the repo implements the same shortener-shaped CRUD that you wrote in this tutorial, but with FastAPI + SQLAlchemy + asyncpg. Same Postgres, same schema, same endpoints, same JSON shape, same docker compose. From the reproducible bench on v0.10.13 (Intel Core Ultra 7 155H, Docker 29.2.1, 30s sustained, concurrency 10):
























































Metric Fitz ORM Python + SQLAlchemy Speedup
Memory peak 9.2 MB 51 MB 5.5× leaner

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× lighter


Same machine, same Docker network, same Postgres. The binary you just compiled in step 7 is in that left column. Run bash benchmarks/orm-vs-sqlalchemy/run.sh from the repo to reproduce on your hardware (~5–8 min with hot Docker cache; needs oha + jq). Full methodology and raw output in benchmarks/orm-vs-sqlalchemy/README.md.






What you got



A working URL shortener in ~120 lines of Fitz:




  • HTTP server with OpenAPI 3.1 + Scalar UI.

  • Postgres ORM with typed closure-to-SQL and fitz db diff/migrate for schema.

  • JWT auth + Argon2id passwords.

  • Background jobs with spawn.

  • Native binary built with fitz build.


  • Dockerfile + compose generated from the AST with fitz docker init.


  • fitz deploy wrapping docker build/push and docker compose up.

  • Health checks via @healthz/@readyz, secrets via Secret<T>, observability via OpenTelemetry — all built in.



What you didn't install:




  • FastAPI, Uvicorn, Pydantic.

  • SQLAlchemy, asyncpg, alembic.

  • python-jose, passlib, argon2-cffi.

  • Celery, Redis (for the spawn).

  • OpenTelemetry SDK + auto-instrumentation packages.

  • A Dockerfile linter, a compose generator, a deploy script.



Total external dependencies of your project: 0. Just Fitz and Postgres.






What's missing (for context)



In a real shortener you'd still want:





  • Real users — we hardcoded Ada. A real User table with @table is two minutes more.


  • Rate limiting per user — middleware on top of @authenticated. A @middleware(rate_limit) plus a small Redis/Postgres counter.


  • Custom analytics — beyond click counter, you'd want user agent, referer, etc. Adds in 5 more lines.


  • Background tracking with retry — spawn is fire-and-forget. For real retries, the @cron jobs with persistence + retry config (@cron("expr", retry={max: 5, backoff: "exponential"}, store=db)) are already supported — wire a clicks_queue table and a @cron that drains it.



All achievable today with what's in the box.






What comes next in the series



In the next post we add WebSockets to the shortener: a real-time dashboard that watches clicks live, with the same auth, with AsyncAPI generated automatically.



If you got stuck somewhere or built something interesting, drop a note in issues on GitHub — I read every one.



Final repo: the full code of this tutorial is at github.com/Thegreekman76/fitz/tree/main/boilerplates/api-orm-full.



Docs and course: thegreekman76.github.io/fitz

Guide (34 chapters): thegreekman76.github.io/fitz/guide/

Roadmap: docs/roadmap.md

CHANGELOG: CHANGELOG.md



Until the next one.

🔍 CTI & Forensik

Cyber Threat Intelligence & Forensik

ATT&CK-Navigator · IoC-Radar · Exploit-Belege
MITRE ATT&CK Matrix Navigator
2 belegte Techniken
T1190TA0001 · Initial Access
Exploit Public-Facing Application
Mitigation: M1042 Network Segmentation & WAF Rule Enforcement
Quelle: Kontext-Klassifikation des Artikeltextes
T1059TA0002 · Execution
Command and Scripting Interpreter
Mitigation: M1038 Execution Prevention & Script Block Logging
Quelle: Kontext-Klassifikation des Artikeltextes
Initial Access
Execution
CTI Threat Relationship Graph
Akteure · Techniken · Beziehungen
5 Knoten · 4 Relationen
CVE / Incident Threat Actor Software MITRE ATT&CK CWE Weakness IoC
Intelligence Digest — kostenlos Täglich die wichtigsten Security-News · jederzeit abbestellbar
Community Rating & Social Proof
⭐ Leser-Wertung
–
Ø / 5.0
Noch keine Leser-Bewertungen
War diese Seite hilfreich?
0
Jetzt Bewertung abgeben
🟢 Niedrig (1-3.9) 🟡 Mittel (4.0-6.9) 🔴 Hoch (7.0-8.9) Kritisch (9.0-10.0)

Verwandte Story-Cluster & Quellen (Vektor-KI)

Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Build a URL shortener with Fitz: HTTP + Postgres + auth in 30 minutes

Thematisch verwandte Begriffe: Build, shortener, with, Fitz · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

💬 Kommentare werden geladen…
Zum Aktualisieren ziehen
Nächster Beitrag