Files
summercms/docs/backend/admin-controllers.md
Jakub Zych 44bd1446f5 feat(11.1-04): add the Backend section, the remaining Services pages and the concept map links
- docs/backend: admin controllers, forms, lists and filters, relation
  manager, users and permissions, settings, partials and widgets, admin SPA
- docs/services: storage, outbound HTTP, realtime, Web Push, search, parity
  testing and the Frontend and AJAX (not provided) page
- Examples for cabana (with testdata/docs YAML), fetchguard, lighthouse and
  its centrifugo driver, flare, beachcomber and typesense, tide; lighthouse
  and beachcomber TestDocs* regions run on their Postgres harnesses
- concept map rows link their guide pages and the not-provided rows the
  Frontend and AJAX page; index lists Backend, Database and Services
- TestDocsRequiredPages asserts the D-08 section order
2026-09-30 23:18:35 +02:00

10 KiB

title, description, section, order
title description section order
Admin controllers Declare admin controllers with pact.AdminController and WinterCMS-shaped YAML, and let the generic JSON admin API list, show, create, update and delete records. backend 10

Admin controllers

A WinterCMS backend controller extends Backend\Classes\Controller, implements the List, Form and Relation behaviours, and describes its screens in config_list.yaml, config_form.yaml and the model's columns.yaml and fields.yaml. SummerCMS keeps the YAML, and drops the controller class: cabana compiles the YAML at boot and serves one generic JSON admin API for every controller, and the admin SPA renders the screens from the compiled schemas.

Declaring a controller

A controller is a small Go type that implements pact.AdminController: its ID, the model name that modelClass in the YAML must match, and the directory that holds its YAML. It usually also implements pact.AdminRecordSource, which returns the GORM model to query, and pact.AdminPermissioned, the permissions an administrator needs. The plugin returns its controllers from pact.HasAdminControllers and its embedded YAML tree from pact.AdminAssets:

package cabana_test

import (
	"io/fs"
	"os"
	"time"

	"git.golem15.com/golem15/summercms/modules/backpack"
	"git.golem15.com/golem15/summercms/modules/pact"
)

// Post is the model behind the acme.blog posts controller.
type Post struct {
	ID          uint       `gorm:"column:id;primaryKey"`
	Title       string     `gorm:"column:title"`
	Slug        string     `gorm:"column:slug"`
	Status      string     `gorm:"column:status"`
	Published   bool       `gorm:"column:published"`
	PublishedAt *time.Time `gorm:"column:published_at"`
	Content     string     `gorm:"column:content"`
}

func (Post) TableName() string { return "acme_blog_posts" }

// Fillable lists the columns the admin form may write.
func (Post) Fillable() []string {
	return []string{"title", "slug", "status", "published", "content"}
}

// PostsController is the admin controller for posts: the Go form of a
// WinterCMS controller with the List and Form behaviours.
type PostsController struct{}

var (
	_ pact.AdminController     = PostsController{}
	_ pact.AdminRecordSource   = PostsController{}
	_ pact.AdminPermissioned   = PostsController{}
	_ pact.HasAdminControllers = (*BlogPlugin)(nil)
)

// ID is the controller ID; its admin API path is /acme/blog/posts.
func (PostsController) ID() string { return "acme.blog.posts" }

// ModelName must equal modelClass in the YAML.
func (PostsController) ModelName() string { return "Post" }

// ConfigDir holds config_list.yaml, config_form.yaml and config_filter.yaml.
func (PostsController) ConfigDir() string { return "controllers/posts" }

// NewRecord returns the model the generic admin handlers query.
func (PostsController) NewRecord() any { return &Post{} }

// RequiredPermissions are checked before any schema or query.
func (PostsController) RequiredPermissions() []string {
	return []string{"acme.blog.access_posts"}
}

// BlogSettings is the singleton row behind the plugin's settings page.
type BlogSettings struct {
	ID              uint `gorm:"column:id;primaryKey"`
	PostsPerPage    int  `gorm:"column:posts_per_page"`
	CommentsEnabled bool `gorm:"column:comments_enabled"`
}

func (BlogSettings) TableName() string { return "acme_blog_settings" }

func (BlogSettings) Fillable() []string { return []string{"posts_per_page", "comments_enabled"} }

// Rules validates a settings save, as a WinterCMS settings model's $rules.
func (BlogSettings) Rules() map[string]string {
	return map[string]string{"posts_per_page": "required|integer|between:1,100"}
}

// BlogPlugin is the acme.blog plugin; only its backend surface is shown.
type BlogPlugin struct{}

var (
	_ pact.AdminAssets    = (*BlogPlugin)(nil)
	_ pact.HasPermissions = (*BlogPlugin)(nil)
	_ pact.HasNavigation  = (*BlogPlugin)(nil)
	_ pact.HasSettings    = (*BlogPlugin)(nil)
)

func (p *BlogPlugin) ID() string                       { return "acme.blog" }
func (p *BlogPlugin) Requires() []string               { return nil }
func (p *BlogPlugin) Register(app *backpack.App) error { return nil }
func (p *BlogPlugin) Boot(app *backpack.App) error     { return nil }

// AdminControllers registers the plugin's admin controllers.
func (p *BlogPlugin) AdminControllers() []pact.AdminController {
	return []pact.AdminController{PostsController{}}
}

// AdminFS is the plugin's embedded controllers/ and models/ tree. A real
// plugin returns an embed.FS; the example reads the same files from testdata.
func (p *BlogPlugin) AdminFS() fs.FS { return os.DirFS("testdata/docs") }

// Permissions replaces registerPermissions().
func (p *BlogPlugin) Permissions() []pact.Permission {
	return []pact.Permission{
		{Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.posts"},
		{Code: "acme.blog.access_settings", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.settings"},
	}
}

// Navigation replaces registerNavigation().
func (p *BlogPlugin) Navigation() []pact.NavigationItem {
	return []pact.NavigationItem{{
		Code:        "blog",
		Label:       "acme.blog::lang.plugin.name",
		Icon:        "icon-pencil",
		Permissions: []string{"acme.blog.access_posts"},
		Controller:  "acme.blog.posts",
		SideMenu: []pact.NavigationItem{
			{Code: "posts", Label: "acme.blog::lang.posts.title", Controller: "acme.blog.posts"},
		},
	}}
}

// Settings replaces registerSettings().
func (p *BlogPlugin) Settings() []pact.SettingsItem {
	return []pact.SettingsItem{{
		Code:        "blog",
		Label:       "acme.blog::lang.settings.label",
		Description: "acme.blog::lang.settings.description",
		Category:    "acme.blog::lang.plugin.name",
		Icon:        "icon-pencil",
		Model:       "BlogSettings",
		Permissions: []string{"acme.blog.access_settings"},
		Form:        "models/settings/fields.yaml",
		NewModel:    func() any { return &BlogSettings{} },
	}}
}

summer make:admin-controller writes the controller type and its four YAML files:

summer make:admin-controller acme.blog Posts

The controller ID maps to the admin API path: acme.blog.posts is served under <prefix>/api/v1/acme/blog/posts. The admin prefix is backend.uri, /backend by default. A model's Fillable method decides which form fields the API may write; see Forms.

Compilation at boot

At start-up cabana.Activate compiles every plugin's controllers, settings, navigation and permissions once. Any schema mistake stops the start-up with an error that names the plugin, the controller and the file: an unknown YAML key, a modelClass that does not match pact.AdminController.ModelName, a list column the model does not have, an unknown field type. Nothing is parsed per request. The example below activates the admin for the plugin above:

cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
	fmt.Println(err)
	return
}
// Set through SUMMER_ADMIN__JWT__SECRET in a real deployment.
_ = cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes")
_ = cfg.Set("backend.uri", "/admin")

routes, err := cabana.Activate(backpack.New(cfg), []party.Plugin{&BlogPlugin{}})
if err != nil {
	fmt.Println(err)
	return
}
fmt.Println(routes.Prefix)

editor := &bouncer.Principal{ID: 7, Backend: true, PermissionGrants: map[string]bool{"acme.blog.*": true}}
fmt.Println(cabana.Allows(editor, PostsController{}.RequiredPermissions()))
fmt.Println(cabana.Allows(editor, []string{"acme.shop.access_orders"}))
// Output:
// /admin
// true
// false

admin.jwt.secret is required as soon as any plugin registers an admin controller. With no admin controllers, no admin routes exist and no secret is needed.

The admin API

Every controller gets the same routes, relative to <prefix>/api/v1/{vendor}/{plugin}/{controller}:

Method and path Does
GET /schema/list, GET /schema/form The list and form schemas, translated into the request locale.
GET / Lists records with search, sort, filters and pagination, allowed only on columns the schema declares.
POST / Creates a record.
GET /{id}, PUT /{id}, DELETE /{id} Shows, updates and deletes a record.
POST /bulk-delete Deletes a set of records in one transaction.

The cabana README lists the full route table, including relation, options, widget, toolbar and partial routes. Every response uses one JSON envelope (cabana.Envelope), and a validation failure is a 422 validation_failed error with messages per field.

Request parameters never reach SQL directly: search, sort and filters apply only to declared columns, and a write passes only the form's writable fields, filled through lagoon.Fill and validated through lagoon.Validate in a transaction.

Hooks

Behaviour overrides such as formBeforeCreate or listExtendQuery become optional interfaces on the controller. cabana checks for each one and calls it at the matching point:

Interface Runs
pact.ListExtendQuery Scopes every list query, for example to the administrator's own records.
pact.FormExtendQuery Scopes every show, update and delete lookup, so a record outside the scope is a 404.
pact.FormBeforeCreate, pact.FormAfterCreate Around a create, inside its transaction.
pact.FormBeforeUpdate, pact.FormAfterUpdate Around an update, inside its transaction.
pact.FormBeforeDelete, pact.FormAfterDelete Around a delete, inside its transaction.
pact.DropdownOptionsProvider Supplies the options of a dropdown field that names a method.

Scope reads and writes with pact.ListExtendQuery and pact.FormExtendQuery rather than checking in a hook: the scope then applies to every route, including relation and action routes.

Toolbar actions

toolbar.buttons in config_list.yaml lists the built-in create and delete and any action the controller registers through pact.HasAdminActions. See Partials and widgets for actions and the rest of the extension points.