Files
summercms/docs/backend/admin-controllers.md

11 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 generated controller requires the permission acme.blog.access_posts, so declare it in the plugin's pact.HasPermissions or the admin API refuses to start with an unknown-permission error. Its NewRecord returns nil until you return the model, and the list and every write answer 500 until then. 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. The admin finds a model's columns through its struct fields, including those of an embedded struct such as gorm.Model. A field with a gorm:"column:..." tag is known by that column alone; an untagged field is known by GORM's default column name.

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. The declarations are enforced by the server, not only shown by the SPA. POST /{controller} needs a config_form.yaml and create in toolbar.buttons; PUT and DELETE /{controller}/{id} need a form (the form screen carries the delete button, as in WinterCMS); POST /{controller}/bulk-delete needs delete in toolbar.buttons, which in turn needs showCheckboxes: true. A write the controller does not declare answers 403 forbidden. See Partials and widgets for actions and the rest of the extension points.