Skip to content

Build a REST Resource (x/rest)

This guide shows how to build a standard CRUD API using x/rest. You get paginated list, show, create, update, delete, and patch endpoints wired from a typed repository with minimal boilerplate.

For the boundary rationale, see the x/rest Primer.

x/rest is an experimental extension family in the current release matrix. Use it behind application-local controllers or adapters in production paths, and check Release Posture and Extension Maturity before assuming API or configuration compatibility is frozen.

Goal API surface Result
Define a resource model application type JSON shape for create/list/show responses
Build a repository rest.NewSQLBuilder, rest.NewBaseRepository[T] FindAll, FindByID, Create, Update, Delete, Count, Exists
Wire CRUD routes rest.NewDBResourceController[T], rest.RegisterResourceRoutes Six standard resource endpoints
Parse list queries rest.NewQueryBuilder().WithPageSize(...).Parse(r) Pagination, sort, filters, search, and fields
Customize behavior Embed rest.BaseResourceController Override only the handlers that need business logic
User type
-> SQLBuilder
-> BaseRepository[User]
-> DBResourceController[User]
-> RegisterResourceRoutes(...)
-> contract.WriteResponse / contract.WriteError
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}

Use rest.NewBaseRepository for a SQL-backed resource. Configure the SQLBuilder with your table name, ID column, and column list:

import (
"github.com/spcent/plumego/x/rest"
"github.com/spcent/plumego/store/db"
)
builder := rest.NewSQLBuilder("users", "id").
WithColumns("id", "name", "email", "created_at").
WithScanFunc(func(rows *sql.Rows) (any, error) {
var u User
err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt)
return u, err
}).
WithInsertFunc(func(v any) ([]any, error) {
u := v.(User)
return []any{u.ID, u.Name, u.Email, u.CreatedAt}, nil
}).
WithUpdateFunc(func(v any) ([]any, error) {
u := v.(User)
return []any{u.Name, u.Email}, nil
})
repo := rest.NewBaseRepository[User](sqlDB, builder)

BaseRepository[T] implements rest.Repository[T] and handles FindAll (with pagination + filters), FindByID, Create, Update, Delete, Count, and Exists.

controller := rest.NewDBResourceController[User]("users", repo)

Register all CRUD routes at once:

import (
"github.com/spcent/plumego/router"
"github.com/spcent/plumego/x/rest"
)
r := router.NewRouter()
rest.RegisterResourceRoutes(r, "/api/v1/users", controller, rest.RouteOptions{})

This registers:

Method Path Handler
GET /api/v1/users controller.Index
GET /api/v1/users/:id controller.Show
POST /api/v1/users controller.Create
PUT /api/v1/users/:id controller.Update
DELETE /api/v1/users/:id controller.Delete
PATCH /api/v1/users/:id controller.Patch

Example list request and response envelope:

GET /api/v1/users?page=1&page_size=25&sort=name&[email protected]
{
"data": [
{
"id": "user_123",
"name": "Alice",
"email": "[email protected]"
}
],
"meta": {
"total": 1,
"page": 1,
"size": 25
}
}

Clients can use these query parameters on list endpoints:

GET /api/v1/users?page=2&page_size=25&sort=name&[email protected]&search=alice
Parameter Type Description
page int Page number (1-based)
page_size int Items per page
sort string Column name; prefix with - for descending (e.g. -created_at)
any non-reserved query key, such as email string Exact-match filter on a column
search string Full-text search string (implementation-dependent)
fields comma-list Limit the returned fields

Builder configuration:

Builder call Effect
WithPageSize(25, 100) Default page size 25, maximum page size 100
WithAllowedSorts("name", "created_at") Rejects unexpected sort fields
WithAllowedFilters("email") Allows email as an exact-match filter
Parse(r) Reads the current *http.Request and returns rest.QueryParams

Parse them in your own handlers with rest.QueryBuilder:

import "github.com/spcent/plumego/x/rest"
func (h UserHandler) List(w http.ResponseWriter, r *http.Request) {
params := rest.NewQueryBuilder().
WithPageSize(25, 100).
WithAllowedSorts("name", "created_at").
WithAllowedFilters("email").
Parse(r)
users, total, err := h.repo.FindAll(r.Context(), params)
if err != nil { ... }
contract.WriteResponse(w, r, http.StatusOK, users, map[string]any{
"total": total,
"page": params.Page,
"size": params.PageSize,
})
}

When the generated controller does not fit (e.g. you need business validation or custom response shapes), embed rest.BaseResourceController and override the methods you need:

type UserController struct {
rest.BaseResourceController
svc *UserService
}
func NewUserController(svc *UserService) *UserController {
return &UserController{
BaseResourceController: *rest.NewBaseResourceController("users"),
svc: svc,
}
}
func (c *UserController) Create(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
contract.WriteError(w, r, contract.NewErrorBuilder().
Type(contract.TypeValidation).
Code(contract.CodeInvalidJSON).
Message("invalid request body").
Build())
return
}
user, err := c.svc.Create(r.Context(), req)
if err != nil { ... }
contract.WriteResponse(w, r, http.StatusCreated, user, nil)
}
// Index, Show, Update, Delete, Patch fall through to BaseResourceController
// and return 501 unless you override them.
Check Recommendation
Is this endpoint on a production critical path? Keep x/rest behind an application-local controller or adapter
Do you need custom validation, auth, or response shape? Override the handler explicitly instead of stretching generated behavior
Are you depending on query semantics? Pin behavior in application tests because x/rest is experimental
Are you documenting this API for consumers? Document the application endpoint contract, not the experimental package internals
Symptom Check first
Query parsing does not compile Use rest.NewQueryBuilder().WithPageSize(...).Parse(r), not request-bound builder constructors
Route handlers are not reached Confirm rest.RegisterResourceRoutes is called from app-local route wiring and uses the router you serve
Repository calls fail at compile time Confirm your repository implements the current rest.Repository[T] methods used by the controller
List responses have the wrong shape Assert the contract.WriteResponse envelope: data carries the list and meta carries pagination
Production upgrade risk is unclear Check x/rest/module.yaml, Release Posture, and application tests that pin query semantics

The reference service shows a complete CRUD handler with explicit dependency injection:

Run it locally to see the list and show endpoints respond correctly:

Terminal window
git clone https://github.com/spcent/plumego
cd plumego/reference/standard-service
go run .
curl 'http://localhost:8080/api/v1/greet?name=Alice'