Recipes

Adding a module

This recipe walks through creating a new API module from scratch — a "products" module with full CRUD. Follow this pattern for any new feature in your Stanza app.


Module structure

Every module is a single Go file in api/module/{name}/{name}.go with one exported function:

package products

func Register(admin *http.Group, db *sqlite.DB) {
    // mount routes here
}

The Register function receives an already-protected route group and the dependencies it needs. Modules never depend on other modules — they're completely decoupled.


Step 1: Write the migration

Add a migration in api/migration/migration.go. Use a Unix timestamp prefix:

db.AddMigration(1742500000, "create_products", createProductsUp, createProductsDown)
func createProductsUp(tx *sqlite.Tx) error {
    _, err := tx.Exec(`CREATE TABLE products (
        id          INTEGER PRIMARY KEY AUTOINCREMENT,
        name        TEXT    NOT NULL,
        description TEXT    NOT NULL DEFAULT '',
        price_cents INTEGER NOT NULL DEFAULT 0,
        is_active   INTEGER NOT NULL DEFAULT 1,
        created_at  TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
        updated_at  TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
        deleted_at  TEXT
    )`)
    return err
}

func createProductsDown(tx *sqlite.Tx) error {
    _, err := tx.Exec(`DROP TABLE IF EXISTS products`)
    return err
}

Migrations run automatically on boot. No manual step needed.


Step 2: Create the module

Create api/module/products/products.go:

package products

import (
    "strconv"
    "strings"
    "time"

    "github.com/stanza-go/framework/pkg/http"
    "github.com/stanza-go/framework/pkg/sqlite"
    "github.com/stanza-go/framework/pkg/validate"
    "github.com/stanza-go/standalone/module/adminaudit"
)

// Register mounts product management routes on the given admin group.
func Register(admin *http.Group, db *sqlite.DB) {
    admin.HandleFunc("GET /products", listHandler(db))
    admin.HandleFunc("POST /products", createHandler(db))
    admin.HandleFunc("GET /products/{id}", getHandler(db))
    admin.HandleFunc("PUT /products/{id}", updateHandler(db))
    admin.HandleFunc("DELETE /products/{id}", deleteHandler(db))
}

Step 3: Define the response type

Keep a single JSON struct for the resource:

type productJSON struct {
    ID          int64  `json:"id"`
    Name        string `json:"name"`
    Description string `json:"description"`
    PriceCents  int    `json:"price_cents"`
    IsActive    bool   `json:"is_active"`
    CreatedAt   string `json:"created_at"`
    UpdatedAt   string `json:"updated_at"`
}

Step 4: Implement handlers

Each handler is a closure factory that captures dependencies:

List with search and pagination

func listHandler(db *sqlite.DB) func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        limit := http.QueryParamInt(r, "limit", 50)
        offset := http.QueryParamInt(r, "offset", 0)
        search := r.URL.Query().Get("search")

        countQ := sqlite.Count("products").Where("deleted_at IS NULL")
        selectQ := sqlite.Select("id", "name", "description", "price_cents", "is_active", "created_at", "updated_at").
            From("products").
            Where("deleted_at IS NULL")
        if search != "" {
            like := "%" + escapeLike(search) + "%"
            countQ.Where("name LIKE ? ESCAPE '\\'", like)
            selectQ.Where("name LIKE ? ESCAPE '\\'", like)
        }

        var total int
        sql, args := countQ.Build()
        _ = db.QueryRow(sql, args...).Scan(&total)

        sql, args = selectQ.OrderBy("id", "DESC").Limit(limit).Offset(offset).Build()
        rows, err := db.Query(sql, args...)
        if err != nil {
            http.WriteError(w, http.StatusInternalServerError, "failed to list products")
            return
        }
        defer rows.Close()

        products := make([]productJSON, 0)
        for rows.Next() {
            var p productJSON
            var isActive int
            if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.PriceCents, &isActive, &p.CreatedAt, &p.UpdatedAt); err != nil {
                http.WriteError(w, http.StatusInternalServerError, "failed to scan product")
                return
            }
            p.IsActive = isActive == 1
            products = append(products, p)
        }

        http.WriteJSON(w, http.StatusOK, map[string]any{
            "products": products,
            "total":    total,
        })
    }
}

LIKE injection

Always escape user input in LIKE clauses with escapeLike() and add ESCAPE '\\' to the query. This prevents % and _ from being used as wildcards.

Create with validation

type createRequest struct {
    Name        string `json:"name"`
    Description string `json:"description"`
    PriceCents  int    `json:"price_cents"`
}

func createHandler(db *sqlite.DB) func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        var req createRequest
        if err := http.ReadJSON(r, &req); err != nil {
            http.WriteError(w, http.StatusBadRequest, "invalid request body")
            return
        }

        v := validate.Fields(
            validate.Required("name", req.Name),
            validate.MaxLen("name", req.Name, 255),
            validate.Positive("price_cents", req.PriceCents),
        )
        if v.HasErrors() {
            v.WriteError(w)
            return
        }

        now := time.Now().UTC().Format(time.RFC3339)
        sql, args := sqlite.Insert("products").
            Set("name", req.Name).
            Set("description", req.Description).
            Set("price_cents", req.PriceCents).
            Set("created_at", now).
            Set("updated_at", now).
            Build()
        result, err := db.Exec(sql, args...)
        if err != nil {
            http.WriteError(w, http.StatusInternalServerError, "failed to create product")
            return
        }

        adminaudit.Log(db, r, "product.create", "product", strconv.FormatInt(result.LastInsertID, 10), req.Name)

        http.WriteJSON(w, http.StatusCreated, map[string]any{
            "product": productJSON{
                ID:          result.LastInsertID,
                Name:        req.Name,
                Description: req.Description,
                PriceCents:  req.PriceCents,
                IsActive:    true,
                CreatedAt:   now,
                UpdatedAt:   now,
            },
        })
    }
}

Get by ID

func getHandler(db *sqlite.DB) func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        id, ok := http.PathParamInt64(w, r, "id")
        if !ok {
            return
        }

        var p productJSON
        var isActive int
        sql, args := sqlite.Select("id", "name", "description", "price_cents", "is_active", "created_at", "updated_at").
            From("products").
            Where("id = ?", id).
            Where("deleted_at IS NULL").
            Build()
        if err := db.QueryRow(sql, args...).Scan(&p.ID, &p.Name, &p.Description, &p.PriceCents, &isActive, &p.CreatedAt, &p.UpdatedAt); err != nil {
            http.WriteError(w, http.StatusNotFound, "product not found")
            return
        }
        p.IsActive = isActive == 1

        http.WriteJSON(w, http.StatusOK, map[string]any{"product": p})
    }
}

Update

type updateRequest struct {
    Name        string `json:"name"`
    Description string `json:"description"`
    PriceCents  *int   `json:"price_cents"`
    IsActive    *bool  `json:"is_active"`
}

func updateHandler(db *sqlite.DB) func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        id, ok := http.PathParamInt64(w, r, "id")
        if !ok {
            return
        }

        var req updateRequest
        if err := http.ReadJSON(r, &req); err != nil {
            http.WriteError(w, http.StatusBadRequest, "invalid request body")
            return
        }

        // Load current product.
        var curName, curDesc, createdAt string
        var curPrice, curActive int
        sql, args := sqlite.Select("name", "description", "price_cents", "is_active", "created_at").
            From("products").
            Where("id = ?", id).
            Where("deleted_at IS NULL").
            Build()
        if err := db.QueryRow(sql, args...).Scan(&curName, &curDesc, &curPrice, &curActive, &createdAt); err != nil {
            http.WriteError(w, http.StatusNotFound, "product not found")
            return
        }

        // Merge updates.
        name := curName
        if req.Name != "" {
            name = req.Name
        }
        desc := curDesc
        if req.Description != "" {
            desc = req.Description
        }
        price := curPrice
        if req.PriceCents != nil {
            price = *req.PriceCents
        }
        isActive := curActive
        if req.IsActive != nil {
            if *req.IsActive {
                isActive = 1
            } else {
                isActive = 0
            }
        }

        now := time.Now().UTC().Format(time.RFC3339)
        sql, args = sqlite.Update("products").
            Set("name", name).
            Set("description", desc).
            Set("price_cents", price).
            Set("is_active", isActive).
            Set("updated_at", now).
            Where("id = ?", id).
            Where("deleted_at IS NULL").
            Build()
        if _, err := db.Exec(sql, args...); err != nil {
            http.WriteError(w, http.StatusInternalServerError, "failed to update product")
            return
        }

        adminaudit.Log(db, r, "product.update", "product", strconv.FormatInt(id, 10), curName)

        http.WriteJSON(w, http.StatusOK, map[string]any{
            "product": productJSON{
                ID:          id,
                Name:        name,
                Description: desc,
                PriceCents:  price,
                IsActive:    isActive == 1,
                CreatedAt:   createdAt,
                UpdatedAt:   now,
            },
        })
    }
}

Soft-delete

func deleteHandler(db *sqlite.DB) func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        id, ok := http.PathParamInt64(w, r, "id")
        if !ok {
            return
        }

        now := time.Now().UTC().Format(time.RFC3339)
        sql, args := sqlite.Update("products").
            Set("deleted_at", now).
            Set("is_active", 0).
            Set("updated_at", now).
            Where("id = ?", id).
            Where("deleted_at IS NULL").
            Build()
        result, err := db.Exec(sql, args...)
        if err != nil {
            http.WriteError(w, http.StatusInternalServerError, "failed to delete product")
            return
        }
        if result.RowsAffected == 0 {
            http.WriteError(w, http.StatusNotFound, "product not found")
            return
        }

        adminaudit.Log(db, r, "product.delete", "product", strconv.FormatInt(id, 10), "")

        http.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
    }
}

LIKE escape helper

func escapeLike(s string) string {
    s = strings.ReplaceAll(s, `\`, `\\`)
    s = strings.ReplaceAll(s, `%`, `\%`)
    s = strings.ReplaceAll(s, `_`, `\_`)
    return s
}

Step 5: Wire into main.go

In registerModules(), import and mount the module:

import "github.com/stanza-go/standalone/module/products"

func registerModules(router *http.Router, db *sqlite.DB, a *auth.Auth, ...) {
    api := router.Group("/api")

    // ... existing routes ...

    admin := api.Group("/admin")
    admin.Use(a.RequireAuth())
    admin.Use(auth.RequireScope("admin"))

    products.Register(admin, db)  // ← add this line
}

That's it. The routes are live at /api/admin/products.


Testing

// api/module/products/products_test.go
package products

import (
    "testing"
    "github.com/stanza-go/standalone/testutil"
)

func TestCreateProduct(t *testing.T) {
    db := testutil.SetupDB(t)
    // run migration, register routes, use httptest
}

Run with go test -race ./module/products/.


Key patterns

PatternDetail
One file per modulemodule/{name}/{name}.go
One exported functionRegister(group, deps...)
Closure-based handlersFactory functions capture dependencies
Soft deletesdeleted_at field, WHERE deleted_at IS NULL
Booleans as integersSQLite has no bool — use INTEGER with 0/1
Timestamps as text"2006-01-02T15:04:05Z" format in UTC
Pre-allocated empty slicesmake([]T, 0) not nil — matters for JSON
Audit loggingCall adminaudit.Log() after every mutation
Previous
Job queue