zoobzio December 15, 2025 Edit this page

CRUD API Cookbook

A complete example of building a RESTful CRUD API with rocco.

Domain Model

We'll build a product management API:

// Domain types
type Product struct {
    ID          string    `json:"id"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    Price       float64   `json:"price"`
    Stock       int       `json:"stock"`
    CreatedAt   time.Time `json:"created_at"`
    UpdatedAt   time.Time `json:"updated_at"`
}

// Input types
type CreateProductInput struct {
    Name        string  `json:"name" validate:"required,min=1,max=200" description:"Product name"`
    Description string  `json:"description" validate:"max=2000" description:"Product description"`
    Price       float64 `json:"price" validate:"required,gt=0" description:"Price in USD"`
    Stock       int     `json:"stock" validate:"gte=0" description:"Available stock"`
}

type UpdateProductInput struct {
    Name        *string  `json:"name" validate:"omitempty,min=1,max=200"`
    Description *string  `json:"description" validate:"omitempty,max=2000"`
    Price       *float64 `json:"price" validate:"omitempty,gt=0"`
    Stock       *int     `json:"stock" validate:"omitempty,gte=0"`
}

// Output types
type ProductListOutput struct {
    Products []Product `json:"products"`
    Total    int       `json:"total"`
    Page     int       `json:"page"`
    PageSize int       `json:"page_size"`
}

type DeleteOutput struct {
    Deleted bool `json:"deleted"`
}

Handlers

Create Product

var createProduct = rocco.NewHandler[CreateProductInput, Product](
    "create-product",
    "POST",
    "/products",
    func(req *rocco.Request[CreateProductInput]) (Product, error) {
        product := Product{
            ID:          generateID(),
            Name:        req.Body.Name,
            Description: req.Body.Description,
            Price:       req.Body.Price,
            Stock:       req.Body.Stock,
            CreatedAt:   time.Now(),
            UpdatedAt:   time.Now(),
        }

        if err := db.CreateProduct(&product); err != nil {
            if isUniqueViolation(err) {
                return Product{}, rocco.ErrConflict.
                    WithMessage("product name already exists").
                    WithDetails(rocco.ConflictDetails{
                        Reason: "a product with this name already exists",
                    })
            }
            return Product{}, rocco.ErrInternalServer.WithCause(err)
        }

        return product, nil
    },
).
    WithSummary("Create a new product").
    WithDescription("Creates a new product in the catalog").
    WithTags("products").
    WithSuccessStatus(201).
    WithErrors(rocco.ErrConflict)

List Products

var listProducts = rocco.NewHandler[rocco.NoBody, ProductListOutput](
    "list-products",
    "GET",
    "/products",
    func(req *rocco.Request[rocco.NoBody]) (ProductListOutput, error) {
        // Parse pagination params
        page := parseIntOrDefault(req.Params.Query["page"], 1)
        pageSize := parseIntOrDefault(req.Params.Query["page_size"], 20)
        if pageSize > 100 {
            pageSize = 100 // Cap at 100
        }

        // Parse filters
        search := req.Params.Query["search"]
        minPrice := parseFloatOrDefault(req.Params.Query["min_price"], 0)
        maxPrice := parseFloatOrDefault(req.Params.Query["max_price"], 0)

        // Query products
        products, total, err := db.ListProducts(ListOptions{
            Page:     page,
            PageSize: pageSize,
            Search:   search,
            MinPrice: minPrice,
            MaxPrice: maxPrice,
        })
        if err != nil {
            return ProductListOutput{}, rocco.ErrInternalServer.WithCause(err)
        }

        return ProductListOutput{
            Products: products,
            Total:    total,
            Page:     page,
            PageSize: pageSize,
        }, nil
    },
).
    WithSummary("List products").
    WithDescription("Returns a paginated list of products with optional filters").
    WithTags("products").
    WithQueryParams("page", "page_size", "search", "min_price", "max_price")

Get Product

var getProduct = rocco.NewHandler[rocco.NoBody, Product](
    "get-product",
    "GET",
    "/products/{id}",
    func(req *rocco.Request[rocco.NoBody]) (Product, error) {
        product, err := db.GetProduct(req.Params.Path["id"])
        if err != nil {
            if errors.Is(err, sql.ErrNoRows) {
                return Product{}, rocco.ErrNotFound.WithDetails(rocco.NotFoundDetails{
                    Resource: "product",
                })
            }
            return Product{}, rocco.ErrInternalServer.WithCause(err)
        }

        return *product, nil
    },
).
    WithSummary("Get a product").
    WithDescription("Returns a single product by ID").
    WithTags("products").
    WithPathParams("id").
    WithErrors(rocco.ErrNotFound)

Update Product

var updateProduct = rocco.NewHandler[UpdateProductInput, Product](
    "update-product",
    "PUT",
    "/products/{id}",
    func(req *rocco.Request[UpdateProductInput]) (Product, error) {
        // Get existing product
        product, err := db.GetProduct(req.Params.Path["id"])
        if err != nil {
            if errors.Is(err, sql.ErrNoRows) {
                return Product{}, rocco.ErrNotFound.WithDetails(rocco.NotFoundDetails{
                    Resource: "product",
                })
            }
            return Product{}, rocco.ErrInternalServer.WithCause(err)
        }

        // Apply updates (only non-nil fields)
        if req.Body.Name != nil {
            product.Name = *req.Body.Name
        }
        if req.Body.Description != nil {
            product.Description = *req.Body.Description
        }
        if req.Body.Price != nil {
            product.Price = *req.Body.Price
        }
        if req.Body.Stock != nil {
            product.Stock = *req.Body.Stock
        }
        product.UpdatedAt = time.Now()

        // Save
        if err := db.UpdateProduct(product); err != nil {
            if isUniqueViolation(err) {
                return Product{}, rocco.ErrConflict.
                    WithMessage("product name already exists").
                    WithDetails(rocco.ConflictDetails{
                        Reason: "a product with this name already exists",
                    })
            }
            return Product{}, rocco.ErrInternalServer.WithCause(err)
        }

        return *product, nil
    },
).
    WithSummary("Update a product").
    WithDescription("Updates an existing product. Only provided fields are updated.").
    WithTags("products").
    WithPathParams("id").
    WithErrors(rocco.ErrNotFound, rocco.ErrConflict)

Delete Product

var deleteProduct = rocco.NewHandler[rocco.NoBody, DeleteOutput](
    "delete-product",
    "DELETE",
    "/products/{id}",
    func(req *rocco.Request[rocco.NoBody]) (DeleteOutput, error) {
        err := db.DeleteProduct(req.Params.Path["id"])
        if err != nil {
            if errors.Is(err, sql.ErrNoRows) {
                return DeleteOutput{}, rocco.ErrNotFound.WithDetails(rocco.NotFoundDetails{
                    Resource: "product",
                })
            }
            return DeleteOutput{}, rocco.ErrInternalServer.WithCause(err)
        }

        return DeleteOutput{Deleted: true}, nil
    },
).
    WithSummary("Delete a product").
    WithDescription("Permanently deletes a product").
    WithTags("products").
    WithPathParams("id").
    WithErrors(rocco.ErrNotFound)

Complete Application

package main

import (
    "context"
    "fmt"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/zoobz-io/openapi"
    "github.com/zoobz-io/rocco"
)

func main() {
    // Create engine
    engine := rocco.NewEngine()

    // Configure OpenAPI
    engine.WithOpenAPIInfo(openapi.Info{
        Title:       "Product API",
        Description: "API for managing product catalog",
        Version:     "1.0.0",
    })
    engine.WithTag("products", "Product management operations")

    // Register handlers
    engine.WithHandlers(
        createProduct,
        listProducts,
        getProduct,
        updateProduct,
        deleteProduct,
    )

    // Start server
    go func() {
        fmt.Println("Server running at http://localhost:8080")
        fmt.Println("API docs at http://localhost:8080/docs")
        if err := engine.Start(rocco.HostAll, 8080); err != nil {
            fmt.Printf("Server error: %v\n", err)
        }
    }()

    // Graceful shutdown
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
    <-sigChan

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    engine.Shutdown(ctx)
}

Testing

package main

import (
    "testing"
    "net/http"

    rtesting "github.com/zoobz-io/rocco/testing"
)

func TestProductCRUD(t *testing.T) {
    engine := rtesting.TestEngine()
    engine.WithHandlers(createProduct, listProducts, getProduct, updateProduct, deleteProduct)

    // Create
    t.Run("Create", func(t *testing.T) {
        capture := rtesting.ServeRequest(engine, "POST", "/products", CreateProductInput{
            Name:  "Widget",
            Price: 9.99,
            Stock: 100,
        })
        rtesting.AssertStatus(t, capture, http.StatusCreated)

        var product Product
        capture.DecodeJSON(&product)
        if product.Name != "Widget" {
            t.Errorf("expected name Widget, got %s", product.Name)
        }
    })

    // List
    t.Run("List", func(t *testing.T) {
        capture := rtesting.ServeRequest(engine, "GET", "/products?page=1&page_size=10", nil)
        rtesting.AssertStatus(t, capture, http.StatusOK)

        var list ProductListOutput
        capture.DecodeJSON(&list)
        if list.Total < 1 {
            t.Error("expected at least 1 product")
        }
    })

    // Get
    t.Run("Get", func(t *testing.T) {
        capture := rtesting.ServeRequest(engine, "GET", "/products/prod_123", nil)
        rtesting.AssertStatus(t, capture, http.StatusOK)
    })

    // Get Not Found
    t.Run("GetNotFound", func(t *testing.T) {
        capture := rtesting.ServeRequest(engine, "GET", "/products/nonexistent", nil)
        rtesting.AssertStatus(t, capture, http.StatusNotFound)
        rtesting.AssertErrorCode(t, capture, "NOT_FOUND")
    })

    // Update
    t.Run("Update", func(t *testing.T) {
        newPrice := 19.99
        capture := rtesting.ServeRequest(engine, "PUT", "/products/prod_123", UpdateProductInput{
            Price: &newPrice,
        })
        rtesting.AssertStatus(t, capture, http.StatusOK)
    })

    // Delete
    t.Run("Delete", func(t *testing.T) {
        capture := rtesting.ServeRequest(engine, "DELETE", "/products/prod_123", nil)
        rtesting.AssertStatus(t, capture, http.StatusOK)
    })
}

Variations

With Authentication

var createProduct = rocco.NewHandler[CreateProductInput, Product](
    "create-product",
    "POST",
    "/products",
    createProductHandler,
).
    WithAuthentication().
    WithScopes("products:write")

With Tenant Isolation

func(req *rocco.Request[rocco.NoBody]) (ProductListOutput, error) {
    tenantID := req.Identity.TenantID()

    products, total, err := db.ListProducts(tenantID, options)
    // ...
}

Soft Delete

var deleteProduct = rocco.NewHandler[rocco.NoBody, DeleteOutput](
    "delete-product",
    "DELETE",
    "/products/{id}",
    func(req *rocco.Request[rocco.NoBody]) (DeleteOutput, error) {
        err := db.SoftDeleteProduct(req.Params.Path["id"])
        // Sets deleted_at instead of removing row
        return DeleteOutput{Deleted: true}, nil
    },
)

See Also