Bookstore API
A complete goninja project with three models, a real belongs-to/has-many
relation, filtering and pagination, a custom action, and generated docs —
all running against a real Postgres database. The full source is
examples/prototype in the goninja repository; this page reproduces it
end to end.
What you get
Once generated and running, the server serves:
GET/POST /tasks,GET/PUT/DELETE /tasks/{id}GET/POST /authors,GET/PUT/DELETE /authors/{id}GET/POST /books,GET/PUT/DELETE /books/{id}POST /books/{id}/publish— a custom action, not generated CRUDGET /docs— a Swagger UI over the merged OpenAPI document
Prerequisites
- Go (matching the version in the repo’s
go.mod) - A running Postgres instance and a database for it to migrate into
db.AutoMigrate
itself on startup, same as any other GORM application.Project layout
- task.go
- author.go
- book.go
- task_generated.go
- author_generated.go
- book_generated.go
- main.go
- auth.go
- bookpublish.go
- author_resource_test.go
internal/api is generated output — regenerated by make generate-prototype, never hand-edited. It lives under internal/
deliberately, since generated code is meant to be consumed by the app
that owns it, not imported from outside the module.
Define the models
Task has a string (UUID) ID rather than an auto-increment integer,
proving the generator’s ID type isn’t hardcoded to int64 —
goninja.NewUUID fills it in on Create:
// Task is the first prototype model. Its ID is a string (UUID) primary
// key rather than an int64 auto-increment column — goninja.NewUUID fills
// it in on Create — proving the generator's ID type isn't hardcoded to
// int64 (see Model.IDGoType in internal/codegen/ir.go).
type Task struct {
ID string `gorm:"primaryKey;type:uuid" json:"id" goninja:"list,retrieve"`
Title string `json:"title" goninja:"list,retrieve,create,update" validate:"required,max=200"`
Done bool `json:"done" goninja:"list,retrieve,create,update,filter"`
}Author carries the reverse (has-many) side of the relation — Books
is a plain slice of Book, which GORM infers from Book.AuthorID by
convention:
// Author is the second prototype model, distinct in shape from Task, used
// to prove the generator isn't special-cased to a single struct. Book
// (below) references it as a belongs-to relation, proving automatic
// Preload on Retrieve; Books here is the reverse side — a has-many
// relation — proving codegen's has-many support.
type Author struct {
ID string `gorm:"primaryKey;type:uuid" json:"id" goninja:"list,retrieve"`
Name string `json:"name" goninja:"list,retrieve,create,update,filter" validate:"required,max=120"`
Bio string `json:"bio" goninja:"retrieve,create,update" validate:"max=2000"`
Books []Book `json:"books" goninja:"retrieve"`
}Book is the belongs-to side — AuthorID/Author is a pairing GORM
infers by naming convention, no explicit foreignKey tag needed. Only
Retrieve pulls in the full Author; List stays lean by design.
Price, Published, AuthorID, and CreatedAt are all filterable (and
CreatedAt is also orderable):
// Book carries a real belongs-to relation to Author. Only the retrieve
// schema pulls in the full Author — list stays lean by construction.
// Price/Published/AuthorID/CreatedAt are filter-tagged, together
// exercising:
// GET /books?published=true&price_min=10&created_at=2024-01-01T00:00:00Z&order=-created_at&limit=20
type Book struct {
ID string `gorm:"primaryKey;type:uuid" json:"id" goninja:"list,retrieve"`
Title string `json:"title" goninja:"list,retrieve,create,update" validate:"required,max=200"`
AuthorID string `json:"author_id" goninja:"list,retrieve,create,update,filter" validate:"required,uuid4"`
Author Author `json:"author" goninja:"retrieve"`
Price float64 `json:"price" goninja:"list,retrieve,create,update,filter" validate:"min=0"`
Published bool `json:"published" goninja:"list,retrieve,create,update,filter"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" goninja:"list,retrieve,filter"`
}A filter-tagged time.Time field like CreatedAt matches on an exact
RFC 3339 timestamp (?created_at=2024-01-01T00:00:00Z) — see
Filtering & Pagination for the full
exact-match/range rules per type.
Generate the resources
$ go run ./cmd/goninja generate \
-models ./examples/prototype/models \
-out ./examples/prototype/internal/api \
-package api \
-models-import github.com/caspel26/goninja/examples/prototype/models
Or, from the repo root, the equivalent Makefile target:
$ make generate-prototype
Wire it into a server
main.go opens Postgres, AutoMigrates all three models, mounts all
three generated resources, adds the custom publish action to
BookResource before mounting it, and mounts a Swagger UI over the
merged document:
func main() {
dsn := os.Getenv("PROTOTYPE_DSN")
if dsn == "" {
log.Fatal("PROTOTYPE_DSN is required, e.g. \"host=localhost user=youruser dbname=goninja_prototype sslmode=disable\"")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Warn)})
if err != nil {
log.Fatalf("connecting to database: %v", err)
}
if err := db.AutoMigrate(&models.Author{}, &models.Book{}, &models.Task{}); err != nil {
log.Fatalf("running migrations: %v", err)
}
mux := http.NewServeMux()
app := goninja.NewAPI("goninja prototype", "0.1.0")
// PROTOTYPE_API_KEY is optional — set it to see goninja.Authenticator
// protect create/update/delete (and, via actionAuth below, publish
// too) end to end; unset, the prototype stays fully public for
// frictionless local exploration. actionAuth is nil in that case, so
// bookActions leaves publish's Auth unset (falls back to whatever
// ResourceConfig.Auth/Config.DefaultAuth.Routes say, i.e. public here).
apiKey := os.Getenv("PROTOTYPE_API_KEY")
var actionAuth *goninja.RouteAuth
if apiKey != "" {
actionAuth = &goninja.RouteAuth{Auth: []goninja.Authenticator{newAPIKeyAuth(apiKey)}}
}
bookAPI := api.NewBookResource(db, goninja.Actions(bookActions, actionAuth)) // adds POST /books/{id}/publish; see bookpublish.go
resources := []goninja.Resource{
api.NewTaskResource(db),
api.NewAuthorResource(db),
bookAPI,
}
if apiKey != "" {
cfg := goninja.Config{
DefaultAuth: goninja.AuthPolicy{
Routes: []goninja.Route{goninja.RouteCreate, goninja.RouteUpdate, goninja.RouteDelete},
Auth: []goninja.Authenticator{newAPIKeyAuth(apiKey)},
},
}
app.MountWithConfig(mux, cfg, resources...)
log.Println("PROTOTYPE_API_KEY set: create/update/delete/publish require X-API-Key")
} else {
app.Mount(mux, resources...)
}
// docsui.ReDoc() is a drop-in alternative to docsui.SwaggerUI() here.
app.MountDocs(mux, "/docs", docsui.SwaggerUI())
log.Fatal(http.ListenAndServe(":8080", mux))
}PROTOTYPE_API_KEY is optional. When set, main.go switches from
app.Mount to app.MountWithConfig and wires auth.go’s
newAPIKeyAuth — a goninja.APIKeyHeader checking X-API-Key at
constant time — to protect create/update/delete (and, via actionAuth,
publish) on every resource:
type apiKeyUser struct{}
func (apiKeyUser) ID() string { return "api-key-client" }
// newAPIKeyAuth builds a goninja.APIKeyHeader — the built-in Authenticator
// for a credential carried in a header — against a real generated
// resource. A real deployment would look the key up against a store
// instead of comparing against one fixed value.
func newAPIKeyAuth(key string) goninja.APIKeyHeader {
return goninja.APIKeyHeader{
Verify: func(got string) (goninja.User, bool) {
if subtle.ConstantTimeCompare([]byte(got), []byte(key)) != 1 {
return nil, false
}
return apiKeyUser{}, true
},
}
}Add the custom publish action
bookpublish.go is never touched by the generator. It declares one
custom action — POST /books/{id}/publish — that flips a book’s
Published flag and returns the updated row by reusing the generated
Retrieve, rather than duplicating its Preload/error-mapping logic:
// bookActions returns the custom actions to declare on r via
// goninja.Actions (main.go) — auth may be nil, see the "Auth on an
// Action" section of Custom Actions for why it's a parameter here
// instead of a separate Config.DefaultAuth.Routes entry.
func bookActions(r *api.BookResource, auth *goninja.RouteAuth) []goninja.Action {
return []goninja.Action{
{
Name: "publish",
Detail: true,
Method: http.MethodPost,
UrlPath: "publish",
Handler: publishBookHandler(r),
Summary: "Publish a book",
Responses: map[string]openapi.Response{
"200": {Description: "OK"},
"404": {Description: "Not found"},
},
Auth: auth,
},
}
}
// publishBookHandler flips a book's Published flag and returns the
// updated row — reuses the generated Retrieve to build the response
// instead of duplicating its Preload/error-mapping logic.
func publishBookHandler(r *api.BookResource) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
id := req.PathValue("id")
if err := r.DB(ctx).Model(&models.Book{}).Where("id = ?", id).
Update("published", true).Error; err != nil {
goninja.Respond(w, r.ErrorMapper(), err)
return
}
out, err := r.Retrieve(ctx, id)
if err != nil {
goninja.Respond(w, r.ErrorMapper(), err)
return
}
goninja.RespondJSON(w, http.StatusOK, out)
}
}main.go wires it explicitly right next to app.Mount(...) —
api.NewBookResource(db, goninja.Actions(bookActions, actionAuth)) —
naming the resource, the action-builder, and the auth right at the call
site, rather than hiding any of it behind a custom constructor of your
own, so main.go alone still shows everything that’s actually mounted.
See Custom Actions for why goninja.Actions
attaches at construction instead of a separate SetActions call
afterward.
Run it
$ export PROTOTYPE_DSN="host=localhost user=$(whoami) dbname=goninja_prototype sslmode=disable"
$ make run-prototype
run-prototype regenerates the resources and then runs the server
(cd examples/prototype && go run .), listening on :8080.
Exercising the API
Create an author:
$ curl -s -X POST localhost:8080/authors \
-d '{"name":"Ursula K. Le Guin","bio":"American author."}' | jq
{
"id": "8f14e...",
"name": "Ursula K. Le Guin",
"bio": "American author.",
"books": []
}Create a book against that author:
$ curl -s -X POST localhost:8080/books \
-d '{"title":"The Left Hand of Darkness","author_id":"8f14e...","price":12.5,"published":false}' | jq
{
"id": "3ac91...",
"title": "The Left Hand of Darkness",
"author_id": "8f14e...",
"author": {
"id": "8f14e...",
"name": "Ursula K. Le Guin",
"bio": "American author.",
"books": []
},
"price": 12.5,
"published": false,
"created_at": "2026-08-20T10:15:00Z"
}List, filtered, ordered, and paginated:
$ curl -s "localhost:8080/books?published=false&price_min=10&order=-created_at&limit=10" | jq
{
"items": [
{
"id": "3ac91...",
"title": "The Left Hand of Darkness",
"author_id": "8f14e...",
"price": 12.5,
"published": false,
"created_at": "2026-08-20T10:15:00Z"
}
],
"total": 1,
"limit": 10,
"offset": 0
}Retrieve one book, with the full author nested:
$ curl -s localhost:8080/books/3ac91... | jq
Publish it with the custom action:
$ curl -s -X POST localhost:8080/books/3ac91.../publish | jq
{
"id": "3ac91...",
"title": "The Left Hand of Darkness",
"author_id": "8f14e...",
"author": { "id": "8f14e...", "name": "Ursula K. Le Guin", "bio": "American author.", "books": [] },
"price": 12.5,
"published": true,
"created_at": "2026-08-20T10:15:00Z"
}Testing a resource without Postgres
author_resource_test.go shows a generated resource tested end to end
in under 10 lines, using goninjatest against an in-memory SQLite
database rather than a real Postgres instance — generated resource code
has no Postgres-specific behavior, so plain GORM against SQLite exercises
it identically:
func TestAuthorResource_CreateAndList(t *testing.T) {
db := goninjatest.NewDB(t, &models.Author{}, &models.Book{})
srv := goninjatest.NewServer(t, api.NewAuthorResource(db))
body := `{"name":"Ursula K. Le Guin","bio":"American author."}`
resp, err := http.Post(srv.URL+"/authors", "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("POST /authors: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("POST /authors status = %d, want 201", resp.StatusCode)
}
listResp, err := http.Get(srv.URL + "/authors")
if err != nil {
t.Fatalf("GET /authors: %v", err)
}
defer listResp.Body.Close()
var envelope struct {
Total int `json:"total"`
}
if err := json.NewDecoder(listResp.Body).Decode(&envelope); err != nil {
t.Fatalf("decode list envelope: %v", err)
}
if envelope.Total != 1 {
t.Errorf("total = %d, want 1", envelope.Total)
}
}Adapting this to your own project
- Swap
models/*.gofor your own structs, keeping thegoninja/validate/gormtag pattern shown above. - Change
-package/-out/-models-importto match your module path. - Drop
PROTOTYPE_API_KEY’s check entirely if you don’t need auth yet, or replaceAPIKeyHeaderwith a different built-inAuthenticator— or your own — once you do (see Global Auth and Middleware). - Custom actions like
publishbelong in their own file next to the model they operate on, wired explicitly inmain.go(see Adding Custom Routes Beyond CRUD).