227 lines
12 KiB
Markdown
227 lines
12 KiB
Markdown
---
|
|
title: Admin controllers
|
|
description: Declare admin controllers with pact.AdminController and WinterCMS-shaped YAML, and let the generic JSON admin API list, show, create, update and delete records.
|
|
section: backend
|
|
order: 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](../../modules/cabana/README.md) 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`:
|
|
|
|
```go src=modules/cabana/example_controller_test.go
|
|
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:
|
|
|
|
```sh
|
|
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](forms.md). 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:
|
|
|
|
```go src=modules/cabana/example_test.go#ExampleActivate
|
|
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. |
|
|
|
|
A hook or scope that has to read the database during a write should use the transaction the write runs in, `cabana.TxFromContext(ctx)`, rather than the application pool: it then sees the write's own snapshot and takes no second connection while the transaction holds row locks. The handle is valid only until the hook returns, and the list route, which runs no transaction, reports none.
|
|
|
|
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](partials-and-widgets.md) for actions and the rest of the extension points.
|