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
- Handler Guide - Handler configuration
- Error Handling - Error patterns
- Authentication Cookbook - Adding auth