Build a REST Resource (x/rest)
Build a REST Resource
Section titled “Build a REST Resource”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.
Release posture note
Section titled “Release posture note”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.
What this guide covers
Section titled “What this guide covers”| 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 |
End-to-end shape
Section titled “End-to-end shape”User type -> SQLBuilder -> BaseRepository[User] -> DBResourceController[User] -> RegisterResourceRoutes(...) -> contract.WriteResponse / contract.WriteErrorStep 1 — Define your resource type
Section titled “Step 1 — Define your resource type”type User struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` CreatedAt time.Time `json:"created_at"`}Step 2 — Build a repository
Section titled “Step 2 — Build a repository”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.
Step 3 — Wire the controller
Section titled “Step 3 — Wire the controller”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=alice@example.com{ "data": [ { "id": "user_123", "name": "Alice", "email": "alice@example.com" } ], "meta": { "total": 1, "page": 1, "size": 25 }}Step 4 — Query parameters
Section titled “Step 4 — Query parameters”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| 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, })}Step 5 — Custom controller
Section titled “Step 5 — Custom controller”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.Production adoption check
Section titled “Production adoption check”| 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 |
If this does not work
Section titled “If this does not work”| 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 |
Complete example in the reference app
Section titled “Complete example in the reference app”The reference service shows a complete CRUD handler with explicit dependency injection:
reference/standard-service/internal/handler/api.go— canonical handler shape withWriteResponseandWriteErrorreference/standard-service/internal/app/routes.go— route wiring that keeps controller instantiation explicit
Run it locally to see the list and show endpoints respond correctly:
git clone https://github.com/spcent/plumegocd plumego/reference/standard-servicego run .curl 'http://localhost:8080/api/v1/greet?name=Alice'