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.

GoalAPI surfaceResult
Define a resource modelapplication typeJSON shape for create/list/show responses
Build a repositoryrest.NewSQLBuilder, rest.NewBaseRepository[T]FindAll, FindByID, Create, Update, Delete, Count, Exists
Wire CRUD routesrest.NewDBResourceController[T], rest.RegisterResourceRoutesSix standard resource endpoints
Parse list queriesrest.NewQueryBuilder().WithPageSize(...).Parse(r)Pagination, sort, filters, search, and fields
Customize behaviorEmbed rest.BaseResourceControllerOverride 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:

MethodPathHandler
GET/api/v1/userscontroller.Index
GET/api/v1/users/:idcontroller.Show
POST/api/v1/userscontroller.Create
PUT/api/v1/users/:idcontroller.Update
DELETE/api/v1/users/:idcontroller.Delete
PATCH/api/v1/users/:idcontroller.Patch

Example list request and response envelope:

GET /api/v1/users?page=1&page_size=25&sort=name&email=alice@example.com
{
"data": [
{
"id": "user_123",
"name": "Alice",
"email": "alice@example.com"
}
],
"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=alice@example.com&search=alice
ParameterTypeDescription
pageintPage number (1-based)
page_sizeintItems per page
sortstringColumn name; prefix with - for descending (e.g. -created_at)
any non-reserved query key, such as emailstringExact-match filter on a column
searchstringFull-text search string (implementation-dependent)
fieldscomma-listLimit the returned fields

Builder configuration:

Builder callEffect
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.
CheckRecommendation
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
SymptomCheck first
Query parsing does not compileUse rest.NewQueryBuilder().WithPageSize(...).Parse(r), not request-bound builder constructors
Route handlers are not reachedConfirm rest.RegisterResourceRoutes is called from app-local route wiring and uses the router you serve
Repository calls fail at compile timeConfirm your repository implements the current rest.Repository[T] methods used by the controller
List responses have the wrong shapeAssert the contract.WriteResponse envelope: data carries the list and meta carries pagination
Production upgrade risk is unclearCheck 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'