Complete Web Application

This chapter presents a complete, production-style web application using PostgreSQL, HTML templating, connection pooling, routing, forms, and database transactions. All techniques from previous chapters come together here.

Features

  • Connection Pooling - Efficient database access via stdlib.pool
  • HTML Templating - Type-safe templates with Html(...)
  • Routing - Path matching with Server.matchPath
  • Form Handling - POST data with validation
  • Transactions - Atomic multi-statement updates
  • Spawn-per-request - Scalable request handling

Prerequisites

This example requires PostgreSQL running at localhost with user/password: postgres/postgres.

Testing the Application

# Start the server
./nostos examples/web_server_complete.nos

# Test endpoints
curl http://localhost:8080/
curl http://localhost:8080/users
curl -X POST -d "name=Alice&email=alice@example.com" http://localhost:8080/users
curl -X POST -d "from=1&to=2&amount=50" http://localhost:8080/transfer

stdlib.pool - Connection Pool

The stdlib.pool module manages a pool of PostgreSQL connections. Instead of connecting per-request, connections are reused for better performance.

use stdlib.pool.{init, initWithMax, query, execute, transaction, stats}

# Initialize pool at startup (default max: 25 connections)
init("host=localhost user=postgres password=postgres")

# Or initialize with custom pool size for high-load applications
initWithMax("host=localhost user=postgres password=postgres", 50)

# Query returns List of tuples
rows = query("SELECT id, name FROM users", [])

# Execute for INSERT/UPDATE/DELETE
execute("INSERT INTO users (name) VALUES ($1)", ("Alice"))

# Check pool statistics: (active, pooled, total, max)
(active, pooled, total, max) = stats()
println("Active: " ++ show(active) ++ " Pooled: " ++ show(pooled))

Database Transactions

Use transaction for atomic operations where multiple statements must succeed or fail together. The connection is automatically committed on success or rolled back on error.

# Transaction ensures atomicity - both updates succeed or both fail
# If any statement throws an error, the entire transaction is rolled back

transaction(conn => {
    # Debit from source account
    Pg.execute(conn, "UPDATE accounts SET balance = balance - $1 WHERE id = $2", (amount, fromId))

    # Credit to destination account
    Pg.execute(conn, "UPDATE accounts SET balance = balance + $1 WHERE id = $2", (amount, toId))

    # Both statements committed together
})

# Use withConn for read-only operations that need a dedicated connection
withConn(conn => {
    rows = Pg.query(conn, "SELECT * FROM large_table WHERE status = $1", "active")
    # Process rows...
})

Query Parameters

Query params auto-normalize: use () for none, bare values for one param, lists/tuples for multiple. Tuples support mixed types.

# No parameters - unit ()
query("SELECT * FROM users", ())

# Single parameter - bare value auto-wraps
query("SELECT * FROM users WHERE id = $1", userId)

# Multiple parameters - tuple supports mixed types
execute("INSERT INTO users (name, email) VALUES ($1, $2)", ("Alice", "alice@example.com"))

# Lists also work
execute("INSERT INTO data (id, name, score) VALUES ($1, $2, $3)", (1, "Bob", 95.5))

Local Lambda Pattern (DRY)

Use local lambdas to avoid repeating code. Match expressions now support comma separators for single-line style.

# Local helper lambda with inline match
handleForm(req) = {
    intOr = s => match String.toInt(s) { Some(n) -> n, None -> 0 }

    amount = intOr(getParam(req.formParams, "amount"))
    userId = intOr(getParam(req.formParams, "user_id"))

    # Use the parsed values...
}

Complete Application Code

Here is the full web application with all features integrated:

# Complete Web Application
#
# PostgreSQL + Html(...) templating + routing + forms + connection pooling
#
# Requires: PostgreSQL at localhost (user: postgres, pw: postgres)

use stdlib.html.{Html, render}
use stdlib.server.{serve, getParam, respondHtml, redirect, respond400, respond405}
use stdlib.pool.{init, initWithMax, query, execute, transaction, stats}

# --- Database Setup ---

setupDatabase() = {
    execute("
        CREATE TABLE IF NOT EXISTS web_users (
            id SERIAL PRIMARY KEY,
            name TEXT NOT NULL,
            email TEXT NOT NULL,
            credits INT DEFAULT 100,
            created_at TIMESTAMP DEFAULT NOW()
        )
    ", [])

    rows = query("SELECT COUNT(*) FROM web_users", [])
    count = head(rows).0

    if count == 0 then {
        execute("INSERT INTO web_users (name, email) VALUES ($1, $2)", ("Alice", "alice@example.com"))
        execute("INSERT INTO web_users (name, email) VALUES ($1, $2)", ("Bob", "bob@example.com"))
        println("Inserted sample users")
    } else ()
}

# --- HTML Components ---

layout(pageTitle: String, content: Html) = Html(
    el("html", [], [
        headEl([
            meta([("charset", "UTF-8")]),
            title(pageTitle ++ " - Nostos App"),
            el("style", [], [raw("
                body { font-family: sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; }
                .container { background: white; padding: 30px; border-radius: 8px; }
                nav a { margin-right: 15px; }
                .card { background: #f9f9f9; padding: 15px; margin: 10px 0; border-radius: 4px; }
                form input { padding: 8px; margin: 5px 0; width: 200px; }
                form button { padding: 8px 16px; background: #0066cc; color: white; border: none; }
            ")])
        ]),
        body([
            el("div", [("class", "container")], [
                nav([a([("href", "/")], "Home"), a([("href", "/users")], "Users")]),
                content
            ])
        ])
    ])
)

userCard(userId: Int, name: String, email: String, credits: Int) = Html(
    el("div", [("class", "card")], [
        h3(name),
        p("Email: " ++ email),
        p("Credits: " ++ show(credits)),
        p([a([("href", "/users/" ++ show(userId))], "View Details")])
    ])
)

# --- Route Handlers ---

handleHome(req) = {
    rows = query("SELECT COUNT(*) FROM web_users", [])
    userCount = head(rows).0

    content = Html(div([
        h1("Nostos Web App"),
        p("Users in database: " ++ show(userCount)),
        p([a([("href", "/users")], "View All Users")])
    ]))
    respondHtml(req, render(layout("Home", content)))
}

handleUsers(req) = {
    rows = query("SELECT id, name, email, credits FROM web_users ORDER BY id", [])
    cards = map(rows, (id, name, email, credits) => userCard(id, name, email, credits))

    content = Html(div([
        h1("All Users"),
        div(cards),
        hr(),
        h2("Add New User"),
        el("form", [("method", "POST"), ("action", "/users")], [
            input([("type", "text"), ("name", "name"), ("placeholder", "Name")]),
            input([("type", "email"), ("name", "email"), ("placeholder", "Email")]),
            el("button", [("type", "submit")], [text("Add User")])
        ]),
        hr(),
        h2("Transfer Credits"),
        el("form", [("method", "POST"), ("action", "/transfer")], [
            input([("type", "number"), ("name", "from"), ("placeholder", "From User ID")]),
            input([("type", "number"), ("name", "to"), ("placeholder", "To User ID")]),
            input([("type", "number"), ("name", "amount"), ("placeholder", "Amount")]),
            el("button", [("type", "submit")], [text("Transfer")])
        ])
    ]))
    respondHtml(req, render(layout("Users", content)))
}

handleUserDetail(req, userId: String) = {
    intOr = s => match String.toInt(s) { Some(n) -> n, None -> 0 }
    rows = query("SELECT id, name, email, credits FROM web_users WHERE id = $1", (intOr(userId)))

    content = match rows {
        [] -> Html(div([
            h1("User Not Found"),
            p([a([("href", "/users")], "Back to Users")])
        ]))
        [(id, name, email, credits) | _] -> Html(div([
            h1("User: " ++ name),
            el("div", [("class", "card")], [
                p("ID: " ++ show(id)),
                p("Name: " ++ name),
                p("Email: " ++ email),
                p("Credits: " ++ show(credits))
            ]),
            p([a([("href", "/users")], "Back to Users")])
        ]))
    }
    respondHtml(req, render(layout("User " ++ userId, content)))
}

handleCreateUser(req) = match req.method {
    "POST" -> {
        name = getParam(req.formParams, "name")
        email = getParam(req.formParams, "email")

        match (name, email) {
            ("", _) -> respond400(req, "Name is required")
            (_, "") -> respond400(req, "Email is required")
            _ -> {
                execute("INSERT INTO web_users (name, email) VALUES ($1, $2)", (name, email))
                redirect(req, "/users")
            }
        }
    }
    _ -> respond405(req)
}

# Transfer credits - uses transaction for atomicity
handleTransfer(req) = match req.method {
    "POST" -> {
        fromId = getParam(req.formParams, "from")
        toId = getParam(req.formParams, "to")
        amountStr = getParam(req.formParams, "amount")

        match (fromId, toId, amountStr) {
            ("", _, _) -> respond400(req, "From user ID is required")
            (_, "", _) -> respond400(req, "To user ID is required")
            (_, _, "") -> respond400(req, "Amount is required")
            _ -> {
                intOr = s => match String.toInt(s) { Some(n) -> n, None -> 0 }
                amount = intOr(amountStr)
                fromIdInt = intOr(fromId)
                toIdInt = intOr(toId)

                if amount <= 0 then respond400(req, "Amount must be positive")
                else {
                    transaction(conn => {
                        Pg.execute(conn, "UPDATE web_users SET credits = credits - $1 WHERE id = $2", (amount, fromIdInt))
                        Pg.execute(conn, "UPDATE web_users SET credits = credits + $1 WHERE id = $2", (amount, toIdInt))
                    })
                    redirect(req, "/users")
                }
            }
        }
    }
    _ -> respond405(req)
}

handle404(req) = {
    content = Html(div([h1("404 - Not Found"), p([a([("href", "/")], "Go Home")])]))
    Server.respond(req.id, 404, [("Content-Type", "text/html")], render(layout("Not Found", content)))
}

# --- Router ---

route(req) = match req.path {
    "/" -> handleHome(req)
    "/users" -> if req.method == "POST" then handleCreateUser(req) else handleUsers(req)
    "/transfer" -> handleTransfer(req)
    _ -> {
        params = Server.matchPath(req.path, "/users/:id")
        if length(params) > 0 then {
            (_, userId) = head(params)
            handleUserDetail(req, userId)
        } else handle404(req)
    }
}

main() = {
    init("host=localhost user=postgres password=postgres")
    setupDatabase()

    println("Web App: http://localhost:8080")
    println("Using connection pooling via stdlib.pool")
    serve(8080, route)
}

Key Takeaways

  • Initialize pool once at startup with init(connString) or initWithMax(connString, 50) for custom size
  • Monitor pool with stats() returning (active, pooled, total, max)
  • Use tuples for query parameters: ("Alice", 25) not ["Alice", 25]
  • Wrap atomic operations in transaction(conn => { ... }) - auto commits/rollbacks
  • Local lambdas with inline match: f = x => match x { A -> 1, B -> 2 }
  • Html components are functions that return Html(...)
  • spawn-per-request happens automatically via serve()