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

Custom Actions

Action + BaseResource.SetActions let you declare extra endpoints as data — a Name, whether it’s Detail (mounted under <base>/{id}/<UrlPath>) or collection-level (<base>/<UrlPath>), an HTTP Method, and a Handler — instead of writing route-mounting code by hand. Register(mux) mounts every action declared via SetActions automatically, after the generated CRUD routes, wrapped through ProtectAction — the same auth/middleware chain Protect gives the generated CRUD routes, but consulting the action’s own Auth field first (see Authentication) before falling back to Action.Name converted to goninja.Route(a.Name), the same key ResourceConfig.Auth/RouteList/RouteCreate/etc. use; a Summary on the Action gets it documented in OpenAPI() too, no extra step. Unlike hooks and method overrides, this needs no wrapper type or SetSelf — an Action already carries its own http.HandlerFunc, so there’s nothing to dispatch per request; call SetActions right after constructing the resource.

Keep the handler logic in its own file, next to the model it operates on — a function taking the already-built resource and returning its []Action — and call SetActions explicitly in main.go alongside the rest of your wiring, rather than hiding it behind a custom constructor. That way main.go stays the one place you can see everything that’s actually mounted:

// handlers/book.go — your file, never touched by the generator.
package handlers

import (
    "net/http"

    "github.com/caspel26/goninja"
    "github.com/caspel26/goninja/openapi"
    "myapp/internal/api"
    "myapp/models"
)

// BookActions returns the custom actions to declare on r via
// goninja.Actions (main.go) or SetActions — auth may be nil, see
// "Auth on an Action" below.
func BookActions(r *api.BookResource, auth *goninja.RouteAuth) []goninja.Action {
    return []goninja.Action{
        {
            Name:    "publish",
            Detail:  true,
            Method:  http.MethodPost,
            UrlPath: "publish",
            Handler: publishHandler(r),
            Summary: "Publish a book",
            Responses: map[string]openapi.Response{"200": {Description: "OK"}},
            Auth:    auth,
        },
    }
}

func publishHandler(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
func main() {
    // ... db setup, mux, app := goninja.NewAPI(...) ...

    bookAPI := api.NewBookResource(db, goninja.Actions(handlers.BookActions, actionAuth))

    app.Mount(mux, api.NewTaskResource(db), api.NewAuthorResource(db), bookAPI)
}

Every generated New<Model>Resource accepts optional <Model>Options (e.g. BookOption), applied in order — a resource with no actions (like api.NewTaskResource(db) here) just calls it with none, unchanged. goninja.Actions builds one of these Options from an action-builder function, so actions attach right at construction instead of a separate SetActions step afterward — nothing about what’s mounted is hidden, unlike wrapping construction and SetActions inside a resource-specific constructor of your own (which would trade that visibility away):

func Actions[R interface{ SetActions(...Action) }, A any](build func(R, A) []Action, arg A) func(R)

where handlers.BookActions is func(r *api.BookResource, auth *goninja.RouteAuth) []goninja.Actionarg’s type isn’t fixed to *RouteAuth, it’s whatever build’s second parameter needs. A build func that genuinely needs nothing beyond r doesn’t need goninja.Actions at all: r := api.NewBookResource(db); r.SetActions(handlers.BookActions(r)...) is already just as short. examples/prototype/main.go uses goninja.Actions for both Task and Book, threading the same *goninja.RouteAuth (nil when PROTOTYPE_API_KEY is unset) into both.

goninja.Respond/goninja.RespondJSON inside the handler are the same helpers the generated handlers use for the error and success paths, respectively — Respond maps an error through an ErrorMapper and writes it as JSON; RespondJSON writes any value with a given status. Leave an Action’s Summary empty to mount it without documenting it, which is fine for an internal-only route. A runnable version of this (flattened into package main rather than a separate handlers package) lives in examples/prototype/bookpublish.go, wired into main.go.

Auth on an Action

Set Auth right on the Action instead of separately re-listing Route("publish") in Config.DefaultAuth.Routes or ResourceConfig.Auth somewhere else — one less place for the two to drift apart as actions get added over time. Extending BookActions above with a second action that needs different auth than publish (rather than the same *RouteAuth passed straight through):

func BookActions(r *api.BookResource, agentAuth goninja.Authenticator) []goninja.Action {
    protected := &goninja.RouteAuth{Auth: []goninja.Authenticator{agentAuth}}
    return []goninja.Action{
        {
            Name: "publish", Detail: true, Method: http.MethodPost, UrlPath: "publish",
            Handler: publishHandler(r), Summary: "Publish a book",
            Responses: map[string]openapi.Response{"200": {Description: "OK"}},
            Auth: protected, // requires agentAuth, regardless of DefaultAuth.Routes
        },
        {
            Name: "preview", Detail: true, Method: http.MethodGet, UrlPath: "preview",
            Handler: previewHandler(r), Summary: "Preview an unpublished book",
            Responses: map[string]openapi.Response{"200": {Description: "OK"}},
            Auth: &goninja.RouteAuth{Public: true}, // explicitly open, not just unlisted
        },
    }
}

publish and preview share the same *RouteAuth pointer where they share the same policy — build it once, reuse it, rather than repeating the Authenticator list per action. Leave Auth nil on an action that should just follow whatever ResourceConfig.Auth/Config.DefaultAuth already say for Route("name") — the lookup this replaces, still available as the fallback.

If Config.StrictAuth is on (see Authentication), forgetting Auth entirely on a new action — leaving it neither protected nor explicitly Public anywhere — is a startup panic naming that action, instead of a route nobody actually decided about serving traffic unprotected.

More than one variable policy: bundle arg in a struct

goninja.Actions(build, arg) threads exactly one arg value into build — above, that’s enough because only one policy (agentAuth) varies from outside; preview’s Public is fixed in the function body. When two or more actions on the same resource each need their own externally-supplied policy (an agent-only action alongside an admin-only one, say), arg becomes a small struct bundling them instead of a single *RouteAuth:

type bookActionAuth struct {
    Publish, Archive *goninja.RouteAuth
}

func BookActions(r *api.BookResource, auth bookActionAuth) []goninja.Action {
    return []goninja.Action{
        {Name: "publish", ..., Auth: auth.Publish},
        {Name: "archive", ..., Auth: auth.Archive}, // a different policy than publish
    }
}
bookAPI := api.NewBookResource(db, goninja.Actions(BookActions, bookActionAuth{
    Publish: agentAuth,
    Archive: adminAuth,
}))

arg is still exactly one value — a struct is just what that value looks like once more than one independently-varying policy needs to reach build.