Skip to content
Snapshot of the v0.4 release. Fixes and additions since then are not in it. Current documentation →

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 CRUD
  • GET /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
goninja does not generate migrations. The example calls 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 int64goninja.NewUUID fills it in on Create:

models/task.go
// 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:

models/author.go
// 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, and AuthorID are filterable, and CreatedAt is orderable:

models/book.go
// 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 are filter-tagged and CreatedAt is orderable:
// GET /books?published=true&price_min=10&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"`
}

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:

main.go
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")

    bookAPI := api.NewBookResource(db)
    bookAPI.SetActions(bookActions(bookAPI)...) // adds POST /books/{id}/publish; see bookpublish.go

    resources := []goninja.Resource{
        api.NewTaskResource(db),
        api.NewAuthorResource(db),
        bookAPI,
    }

    // PROTOTYPE_API_KEY is optional — set it to see goninja.Authenticator
    // protect create/update/delete end to end; unset, the prototype stays
    // fully public for frictionless local exploration.
    if apiKey := os.Getenv("PROTOTYPE_API_KEY"); 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 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 on every resource:

auth.go
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:

bookpublish.go
// bookActions returns the custom actions to declare on r via SetActions.
func bookActions(r *api.BookResource) []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"},
            },
        },
    }
}

// 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(...)bookAPI.SetActions(bookActions(bookAPI)...) — rather than hiding it behind a custom constructor, so main.go alone shows everything that’s actually mounted.

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:

author_resource_test.go
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/*.go for your own structs, keeping the goninja/ validate/gorm tag pattern shown above.
  • Change -package/-out/-models-import to match your module path.
  • Drop PROTOTYPE_API_KEY’s check entirely if you don’t need auth yet, or replace APIKeyHeader with a different built-in Authenticator — or your own — once you do (see Global Auth and Middleware).
  • Custom actions like publish belong in their own file next to the model they operate on, wired explicitly in main.go (see Adding Custom Routes Beyond CRUD).