Documentation
¶
Overview ¶
Package admin provides a Django-admin-style auto-CRUD panel for the lagodev framework. You register your domain models against a Panel, and the panel serves a self-contained web UI — model index, paginated/searchable list views, detail pages, and create/edit/delete forms — entirely from struct reflection. It is stdlib-only (net/http, html/template, reflect, sort) and returns a plain http.Handler the application mounts wherever it likes (conventionally /admin/).
The panel never talks to a database directly. Persistence is abstracted behind the DataSource interface, so the same UI works against an in-memory slice (SliceSource, shipped for tests and demos), an orm-backed adapter the application supplies, or any other store. This keeps the admin package free of a hard dependency on a live DB and trivially unit-testable.
Field metadata (columns, labels, editability) is derived by reflecting over the registered model's struct tags — `column:"…"` and `orm:"…"`, matching the rest of the framework — and may be overridden per resource. Well-known fields are recognised automatically: ID is the primary key, CreatedAt / UpdatedAt are read-only timestamps, and a DeletedAt field marks the model as soft-delete aware.
Basic usage:
type User struct {
ID uint64 `column:"id" orm:"primary"`
Name string `column:"name"`
Email string `column:"email"`
CreatedAt time.Time `column:"created_at"`
}
src := admin.NewSliceSource("id")
src.Seed(map[string]any{"id": 1, "name": "Ada", "email": "ada@example.com"})
p := admin.New(admin.WithTitle("Acme Admin"))
p.Register(User{}, admin.Resource{
Source: src,
Search: []string{"name", "email"},
Filters: []string{"email"},
PerPage: 25,
})
http.Handle("/admin/", http.StripPrefix("/admin", p.Handler()))
Security: the panel is fail-closed by default. With no Authorizer installed it denies every request (HTTP 403) until you configure one via WithAuthorizer — or explicitly opt out of the gate with WithInsecureAllowAll for local/dev use or when the mount is already protected by upstream auth middleware. Every response auto-escapes through html/template, and mutating requests (create/update/delete) are POST-only and CSRF-guarded with a double-submit token. The Authorizer hook gates every action by (request, action, model) and yields 403 on denial.
Example ¶
Example shows a real user registering a model with the admin Panel against the shipped in-memory SliceSource, mounting Handler(), and driving it over HTTP with httptest: it fetches the resource index and a list page and proves the resource is listed by asserting on stable rendered substrings and the HTTP status codes (never the volatile CSRF token or full HTML).
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"github.com/devituz/lagodev/admin"
)
func main() {
// A domain model: its struct tags drive column/field discovery.
type User struct {
ID uint64 `column:"id" orm:"primary"`
Name string `column:"name"`
Email string `column:"email"`
}
// In-memory data source seeded with two rows.
src := admin.NewSliceSource("id")
src.Seed(
map[string]any{"id": 1, "name": "Ada", "email": "ada@example.com"},
map[string]any{"id": 2, "name": "Linus", "email": "linus@example.com"},
)
// Register the model and mount the panel under /admin. The panel is
// fail-closed: with no Authorizer it denies every request, so this example
// opts out of the gate with WithInsecureAllowAll for a self-contained demo.
// Production code installs WithAuthorizer (and/or upstream auth middleware)
// instead.
p := admin.New(admin.WithTitle("Acme Admin"), admin.WithInsecureAllowAll())
p.Register(User{}, admin.Resource{
Source: src,
Search: []string{"name", "email"},
})
mux := http.NewServeMux()
mux.Handle("/admin/", http.StripPrefix("/admin", p.Handler()))
srv := httptest.NewServer(mux)
defer srv.Close()
get := func(path string) (int, string) {
resp, err := http.Get(srv.URL + path)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(body)
}
// Index page: lists the registered resource and links to its list view.
idxStatus, idxBody := get("/admin/")
fmt.Println("index status:", idxStatus)
fmt.Println("index lists User:", strings.Contains(idxBody, "<td>User</td>"))
fmt.Println("index links to list:", strings.Contains(idxBody, `href="/admin/user"`))
// List page: shows both seeded rows with the deterministic total counter.
listStatus, listBody := get("/admin/user")
fmt.Println("list status:", listStatus)
fmt.Println("list shows Ada:", strings.Contains(listBody, "<td>Ada</td>"))
fmt.Println("list shows Linus:", strings.Contains(listBody, "<td>linus@example.com</td>"))
fmt.Println("list total:", strings.Contains(listBody, "2 total"))
}
Output: index status: 200 index lists User: true index links to list: true list status: 200 list shows Ada: true list shows Linus: true list total: true
Index ¶
- Constants
- Variables
- type Authorizer
- type DataSource
- type Field
- type ListParams
- type Option
- type Panel
- type Resource
- type SliceSource
- func (s *SliceSource) Create(_ context.Context, data map[string]any) (map[string]any, error)
- func (s *SliceSource) Delete(_ context.Context, id string) error
- func (s *SliceSource) Get(_ context.Context, id string) (map[string]any, error)
- func (s *SliceSource) Len() int
- func (s *SliceSource) List(_ context.Context, params ListParams) ([]map[string]any, int, error)
- func (s *SliceSource) Seed(rows ...map[string]any)
- func (s *SliceSource) Update(_ context.Context, id string, data map[string]any) (map[string]any, error)
Examples ¶
Constants ¶
const ( ActionList = "list" ActionView = "view" ActionCreate = "create" ActionUpdate = "update" ActionDelete = "delete" )
Action enumerates the operations the panel performs on a resource. They are the verbs passed to an Authorizer and used internally to gate handlers.
const DefaultPerPage = 20
DefaultPerPage is the list page size used when Resource.PerPage is unset.
Variables ¶
var ErrNotFound = errors.New("admin: record not found")
ErrNotFound is returned by DataSource.Get/Update/Delete when no row matches the supplied id. Handlers translate it to HTTP 404.
Functions ¶
This section is empty.
Types ¶
type Authorizer ¶
Authorizer decides whether the request may perform action on the named model (the model's registered slug). Returning false yields HTTP 403. A nil Authorizer allows everything. action is one of the Action* constants; model is empty for the top-level index page.
type DataSource ¶
type DataSource interface {
// List returns one page of rows plus the total match count across all
// pages (used for pagination). Rows are keyed by column name.
List(ctx context.Context, params ListParams) (rows []map[string]any, total int, err error)
// Get returns the single row identified by id (the primary-key value as a
// string). It returns ErrNotFound when no row matches.
Get(ctx context.Context, id string) (map[string]any, error)
// Create inserts a new row and returns the stored representation (including
// any generated id).
Create(ctx context.Context, data map[string]any) (map[string]any, error)
// Update mutates the row identified by id with the supplied fields and
// returns the stored representation. It returns ErrNotFound when absent.
Update(ctx context.Context, id string, data map[string]any) (map[string]any, error)
// Delete removes the row identified by id. Soft-delete-aware sources may
// set the soft-delete column instead of physically removing the row. It
// returns ErrNotFound when absent.
Delete(ctx context.Context, id string) error
}
DataSource is the persistence contract the admin programs against. The panel never imports a database package directly; an application wires whichever backend it likes (orm-backed, SQL, in-memory) behind this interface. A reference in-memory implementation is provided by SliceSource.
Implementations represent a row as a flat map[string]any keyed by column name (the same keys reflectFields derives). They must be safe for concurrent use. The ctx should be honoured for cancellation where the backend supports it.
type Field ¶
type Field struct {
// Name is the Go struct field name (e.g. "CreatedAt").
Name string
// Column is the storage/data-source key. It comes from the `column:` tag,
// or the snake_case of Name when the tag is absent.
Column string
// Label is the human-readable header/label (Title Case of Name).
Label string
// Kind classifies the input control to render: "text", "number", "bool",
// "datetime", or "text" as the catch-all.
Kind string
// IsPrimary marks the primary-key field (the row identifier).
IsPrimary bool
// IsCreatedAt / IsUpdatedAt / IsDeletedAt mark managed timestamp columns.
IsCreatedAt bool
IsUpdatedAt bool
IsDeletedAt bool
// IsRelation marks slice/pointer-to-struct fields (skipped by default).
IsRelation bool
// InList is true when the field appears in the list table. Populated from
// Resource.Columns (or the default) at registration.
InList bool
// Editable is true when the field is rendered on the create/edit form.
Editable bool
}
Field is the admin's metadata for a single model attribute, derived by reflecting over the struct definition and its `column:` / `orm:` tags. It drives both the list table (which columns to show) and the form (which inputs to render and their kind).
type ListParams ¶
type ListParams struct {
// Page is 1-indexed; the source clamps values < 1 to 1.
Page int
// PerPage is the page size; the source clamps values < 1 to the resource
// default.
PerPage int
// Search is the free-text query (case-insensitive substring). Empty means
// no text filtering.
Search string
// SearchFields lists the columns Search is matched against (supplied by the
// panel from the resource configuration).
SearchFields []string
// Filters constrains rows to those whose column equals the given value
// (string-compared). Multiple filters are AND-ed.
Filters map[string]string
// IncludeDeleted, when true, asks soft-delete-aware sources to return
// soft-deleted rows as well. The panel leaves it false by default.
IncludeDeleted bool
// SoftDeleteColumn names the soft-delete timestamp column, or "" when the
// model is not soft-delete aware.
SoftDeleteColumn string
}
ListParams describes a list query: pagination, a free-text search term matched against the resource's searchable fields, and exact-match filters keyed by column. The data source is responsible for honouring whichever of these it can; SliceSource honours all of them in memory.
type Option ¶
type Option func(*Panel)
Option configures a Panel at construction.
func WithAuthorizer ¶
func WithAuthorizer(a Authorizer) Option
WithAuthorizer installs an RBAC gate consulted before every action. Installing one is the recommended way to make the panel reachable; the panel denies every request until either an Authorizer or WithInsecureAllowAll is configured.
func WithBasePath ¶
WithBasePath sets the URL prefix the panel is mounted under (used to build links). It defaults to "/admin". Mount the Handler() under the matching prefix, e.g. http.StripPrefix("/admin", p.Handler()).
func WithInsecureAllowAll ¶ added in v0.26.0
func WithInsecureAllowAll() Option
WithInsecureAllowAll disables the panel's fail-closed default, allowing every action through with no authorization check. It exists only for local development, tests, and demos, or for deployments that gate the mount entirely with external auth middleware. NEVER enable it on a route reachable without an upstream auth layer: it exposes full CRUD over every registered model to anyone who can hit the path. Prefer WithAuthorizer in production.
type Panel ¶
type Panel struct {
// contains filtered or unexported fields
}
Panel is the registry and HTTP front-end for the admin UI. Register models against it, then mount Handler(). A Panel is safe for concurrent use after registration; register all resources during setup before serving.
func (*Panel) Handler ¶
Handler returns the http.Handler exposing every admin route. Mount it under the panel's base path:
mux.Handle("/admin/", http.StripPrefix("/admin", p.Handler()))
Routes (relative to the mount point):
GET / resource index
GET /{resource} paginated, searchable list
GET /{resource}/new create form
POST /{resource}/new create
GET /{resource}/{id} edit form
POST /{resource}/{id} update
POST /{resource}/{id}/delete delete (soft-delete aware)
func (*Panel) Register ¶
Register adds a model to the panel. model is a zero/sample value of the struct type (e.g. User{} or &User{}); its tags drive field discovery. cfg supplies the DataSource and any overrides. Register panics on misuse (non-struct model, missing Source, duplicate slug) because these are programmer errors surfaced at startup, matching the framework's other registries.
type Resource ¶
type Resource struct {
// Source is the persistence backend for this model. Required.
Source DataSource
// Slug overrides the URL segment for the model. Defaults to the snake_case
// plural-ish form of the type name (e.g. "User" -> "user").
Slug string
// Label overrides the human-readable model name shown in the UI. Defaults
// to the humanized (Title Case) type name, e.g. "BlogPost" -> "Blog Post".
Label string
// Columns names the fields shown in the list table, in order. Defaults to
// every non-relation field reflected from the model.
Columns []string
// Editable names the fields rendered as inputs on the create/edit form.
// Defaults to every field except the primary key and read-only timestamps
// (CreatedAt / UpdatedAt / DeletedAt).
Editable []string
// Search names the fields matched (case-insensitive substring) by the list
// view's search box. Empty disables search.
Search []string
// Filters names the fields offered as exact-match filter inputs on the
// list view. Empty disables filtering.
Filters []string
// PerPage is the list page size. Defaults to DefaultPerPage when < 1.
PerPage int
}
Resource declares how a model is exposed in the admin. Every field is optional except Source: sensible defaults are reflected from the model's struct tags when a field is left zero.
type SliceSource ¶
type SliceSource struct {
// contains filtered or unexported fields
}
SliceSource is an in-memory DataSource backed by a slice of row maps. It is the reference implementation used by tests and demos, and a drop-in for prototyping before an orm-backed source is wired. It honours search, equality filters, pagination, sorting by primary key, and soft deletes (when the resource declares a DeletedAt column and the row carries that key).
An application that wants live persistence supplies its own DataSource (for example, an adapter over the framework's orm.Query) instead of SliceSource; the HTTP layer is identical either way.
func NewSliceSource ¶
func NewSliceSource(pkColumn string) *SliceSource
NewSliceSource creates an empty in-memory source whose primary-key column is pkColumn (commonly "id"). New rows without a pk value are assigned an auto-incrementing integer id.
func (*SliceSource) Delete ¶
func (s *SliceSource) Delete(_ context.Context, id string) error
Delete implements DataSource. When the row carries a soft-delete column key, the column is stamped (logical delete) rather than the row being removed; otherwise the row is dropped.
func (*SliceSource) Len ¶
func (s *SliceSource) Len() int
Len reports the number of stored rows (including soft-deleted ones). Useful for test assertions on mutation.
func (*SliceSource) List ¶
func (s *SliceSource) List(_ context.Context, params ListParams) ([]map[string]any, int, error)
List implements DataSource.
func (*SliceSource) Seed ¶
func (s *SliceSource) Seed(rows ...map[string]any)
Seed inserts pre-built rows directly (bypassing id generation when a row already carries the pk). It is intended for fixtures and demos.