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
This commit is contained in:
224
docs/backend/admin-controllers.md
Normal file
224
docs/backend/admin-controllers.md
Normal file
@@ -0,0 +1,224 @@
|
||||
---
|
||||
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 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).
|
||||
|
||||
## 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. |
|
||||
|
||||
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](partials-and-widgets.md) for actions and the rest of the extension points.
|
||||
Reference in New Issue
Block a user