feat(11.1-05): add the walkthrough's admin controller, publish command and published_at migration
- make:admin-controller, make:command and make:migration output, finished: the Posts controller serves models.Post behind acme.blog.access_posts, WinterCMS-style form and list YAML embedded through pact.AdminAssets, blog:publish sets published_at by slug with a bound parameter - the posts route lists published posts only, newest first - Docker tests migrate an ICU pl-PL database, roll back published_at, serve the route and run blog:publish with a published and an opened database - the page gains the admin controller, console command and added-column sections
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/docs/examples/blog"
|
"git.golem15.com/golem15/summercms/docs/examples/blog"
|
||||||
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
|
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
"git.golem15.com/golem15/summercms/modules/compass"
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"git.golem15.com/golem15/summercms/modules/party"
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
@@ -16,8 +17,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// activate boots acme.blog the way the generated main does: an application
|
// activate boots acme.blog the way the generated main does: an application
|
||||||
// config directory with the required HTTP limits, then party.Activate with
|
// config directory with the required HTTP limits and admin secret, then
|
||||||
// the plugin ID.
|
// party.Activate with the plugin ID.
|
||||||
func activate(t *testing.T) (*backpack.App, party.Plugin) {
|
func activate(t *testing.T) (*backpack.App, party.Plugin) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@@ -29,6 +30,10 @@ func activate(t *testing.T) (*backpack.App, party.Plugin) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// The admin controller makes the admin API mount, which needs a secret.
|
||||||
|
if err := cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
app := backpack.New(cfg)
|
app := backpack.New(cfg)
|
||||||
plugins, err := party.Activate(app, []string{"acme.blog"})
|
plugins, err := party.Activate(app, []string{"acme.blog"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -76,6 +81,23 @@ func TestPluginActivates(t *testing.T) {
|
|||||||
if len(hasMigrations.Migrations()) == 0 {
|
if len(hasMigrations.Migrations()) == 0 {
|
||||||
t.Error("Migrations is empty")
|
t.Error("Migrations is empty")
|
||||||
}
|
}
|
||||||
|
hasCommands, ok := p.(pact.HasCommands)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("plugin does not implement pact.HasCommands")
|
||||||
|
}
|
||||||
|
if !slices.ContainsFunc(hasCommands.Commands(), func(c bonfire.Command) bool { return c.Name == "blog:publish" }) {
|
||||||
|
t.Error("Commands does not include blog:publish")
|
||||||
|
}
|
||||||
|
hasAdmin, ok := p.(pact.HasAdminControllers)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("plugin does not implement pact.HasAdminControllers")
|
||||||
|
}
|
||||||
|
if ctls := hasAdmin.AdminControllers(); len(ctls) != 1 || ctls[0].ID() != "acme.blog.posts" {
|
||||||
|
t.Errorf("AdminControllers = %v, want acme.blog.posts", ctls)
|
||||||
|
}
|
||||||
|
if _, ok := p.(pact.AdminAssets); !ok {
|
||||||
|
t.Error("plugin does not implement pact.AdminAssets")
|
||||||
|
}
|
||||||
if got := app.Config.Int("acme.blog.per_page"); got != 15 {
|
if got := app.Config.Int("acme.blog.per_page"); got != 15 {
|
||||||
t.Errorf("acme.blog.per_page = %d, want the plugin default 15", got)
|
t.Errorf("acme.blog.per_page = %d, want the plugin default 15", got)
|
||||||
}
|
}
|
||||||
|
|||||||
43
docs/examples/blog/console/publish.go
Normal file
43
docs/examples/blog/console/publish.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Code generated by summer make. DO NOT EDIT.
|
||||||
|
|
||||||
|
package console
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublishCommand returns the blog:publish console command. withDB runs the
|
||||||
|
// command's work with the application's database; the plugin supplies it.
|
||||||
|
func PublishCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) error) bonfire.Command {
|
||||||
|
return bonfire.Command{
|
||||||
|
Name: "blog:publish",
|
||||||
|
Description: "Publish a blog post by its slug",
|
||||||
|
Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}},
|
||||||
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||||
|
slug, _ := in.Argument("slug")
|
||||||
|
if slug == "" {
|
||||||
|
return fmt.Errorf("blog:publish: a slug is required")
|
||||||
|
}
|
||||||
|
return withDB(ctx, func(db *gorm.DB) error {
|
||||||
|
// A bound parameter, never the slug spliced into SQL. Publishing
|
||||||
|
// twice keeps the first publication time.
|
||||||
|
res := db.WithContext(ctx).Model(&models.Post{}).
|
||||||
|
Where("slug = ?", slug).
|
||||||
|
Update("published_at", gorm.Expr("COALESCE(published_at, NOW())"))
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("blog:publish: no post has the slug %q", slug)
|
||||||
|
}
|
||||||
|
out.Printf("published %s\n", slug)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
44
docs/examples/blog/console/publish_test.go
Normal file
44
docs/examples/blog/console/publish_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package console_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog/console"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublishCommandShape(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
withDB := func(ctx context.Context, fn func(*gorm.DB) error) error {
|
||||||
|
called = true
|
||||||
|
return errors.New("no database in this test")
|
||||||
|
}
|
||||||
|
cmd := console.PublishCommand(withDB)
|
||||||
|
if cmd.Name != "blog:publish" {
|
||||||
|
t.Errorf("Name = %q, want blog:publish", cmd.Name)
|
||||||
|
}
|
||||||
|
if len(cmd.Args) != 1 || cmd.Args[0].Name != "slug" || !cmd.Args[0].Required {
|
||||||
|
t.Errorf("Args = %+v, want one required slug argument", cmd.Args)
|
||||||
|
}
|
||||||
|
if cmd.Description == "" {
|
||||||
|
t.Error("Description is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmds := []bonfire.Command{cmd}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := bonfire.Call(context.Background(), cmds, "blog:publish", nil, &out); err == nil {
|
||||||
|
t.Error("blog:publish without a slug returned no error")
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Error("blog:publish without a slug reached the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := bonfire.Call(context.Background(), cmds, "blog:publish", []string{"hello-world"}, &out)
|
||||||
|
if err == nil || !called {
|
||||||
|
t.Errorf("blog:publish hello-world: err = %v, called = %v; want the withDB error", err, called)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
docs/examples/blog/controllers/posts.go
Normal file
34
docs/examples/blog/controllers/posts.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// Code generated by summer make. DO NOT EDIT.
|
||||||
|
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ pact.AdminController = postsAdmin{}
|
||||||
|
_ pact.AdminRecordSource = postsAdmin{}
|
||||||
|
_ pact.AdminPermissioned = postsAdmin{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// postsAdmin is the Go form of the Posts backend controller with the List
|
||||||
|
// and Form behaviours.
|
||||||
|
type postsAdmin struct{}
|
||||||
|
|
||||||
|
func (postsAdmin) ID() string { return "acme.blog.posts" }
|
||||||
|
func (postsAdmin) ModelName() string { return "Post" }
|
||||||
|
func (postsAdmin) ConfigDir() string { return "controllers/posts" }
|
||||||
|
|
||||||
|
// NewRecord returns the model the generic admin handlers query and fill.
|
||||||
|
func (postsAdmin) NewRecord() any { return &models.Post{} }
|
||||||
|
|
||||||
|
// RequiredPermissions replaces $requiredPermissions: an administrator needs
|
||||||
|
// this permission before any schema or record is served.
|
||||||
|
func (postsAdmin) RequiredPermissions() []string {
|
||||||
|
return []string{"acme.blog.access_posts"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostsController returns the acme.blog.posts admin controller.
|
||||||
|
func PostsController() pact.AdminController { return postsAdmin{} }
|
||||||
12
docs/examples/blog/controllers/posts/config_form.yaml
Normal file
12
docs/examples/blog/controllers/posts/config_form.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: acme.blog::lang.posts.post
|
||||||
|
form: ~/plugins/acme/blog/models/posts/fields.yaml
|
||||||
|
modelClass: Post
|
||||||
|
defaultRedirect: acme/blog/posts
|
||||||
|
|
||||||
|
create:
|
||||||
|
redirect: acme/blog/posts/update/:id
|
||||||
|
redirectClose: acme/blog/posts
|
||||||
|
|
||||||
|
update:
|
||||||
|
redirect: acme/blog/posts
|
||||||
|
redirectClose: acme/blog/posts
|
||||||
13
docs/examples/blog/controllers/posts/config_list.yaml
Normal file
13
docs/examples/blog/controllers/posts/config_list.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
list: ~/plugins/acme/blog/models/posts/columns.yaml
|
||||||
|
modelClass: Post
|
||||||
|
title: acme.blog::lang.posts.title
|
||||||
|
recordUrl: acme/blog/posts/update/:id
|
||||||
|
recordsPerPage: 20
|
||||||
|
showCheckboxes: true
|
||||||
|
defaultSort:
|
||||||
|
column: published_at
|
||||||
|
direction: desc
|
||||||
|
toolbar:
|
||||||
|
buttons: [create, delete]
|
||||||
|
search:
|
||||||
|
prompt: backend::lang.list.search_prompt
|
||||||
99
docs/examples/blog/controllers/posts_test.go
Normal file
99
docs/examples/blog/controllers/posts_test.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package controllers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog"
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog/controllers"
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPostsControllerDeclaration(t *testing.T) {
|
||||||
|
ctl := controllers.PostsController()
|
||||||
|
if ctl.ID() != "acme.blog.posts" {
|
||||||
|
t.Errorf("ID = %q, want acme.blog.posts", ctl.ID())
|
||||||
|
}
|
||||||
|
if ctl.ModelName() != "Post" {
|
||||||
|
t.Errorf("ModelName = %q, want Post", ctl.ModelName())
|
||||||
|
}
|
||||||
|
if ctl.ConfigDir() != "controllers/posts" {
|
||||||
|
t.Errorf("ConfigDir = %q, want controllers/posts", ctl.ConfigDir())
|
||||||
|
}
|
||||||
|
perm, ok := ctl.(pact.AdminPermissioned)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the controller does not declare a permission (pact.AdminPermissioned)")
|
||||||
|
}
|
||||||
|
if got := perm.RequiredPermissions(); !slices.Equal(got, []string{"acme.blog.access_posts"}) {
|
||||||
|
t.Errorf("RequiredPermissions = %v, want [acme.blog.access_posts]", got)
|
||||||
|
}
|
||||||
|
src, ok := ctl.(pact.AdminRecordSource)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the controller does not implement pact.AdminRecordSource")
|
||||||
|
}
|
||||||
|
if _, ok := src.NewRecord().(*models.Post); !ok {
|
||||||
|
t.Errorf("NewRecord is %T, want *models.Post", src.NewRecord())
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin := &blog.Plugin{}
|
||||||
|
fsys := plugin.AdminFS()
|
||||||
|
form, err := cabana.CompileForm("acme.blog", ctl, fsys)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompileForm: %v", err)
|
||||||
|
}
|
||||||
|
var fields []string
|
||||||
|
for _, f := range form.Fields {
|
||||||
|
fields = append(fields, f.Name)
|
||||||
|
}
|
||||||
|
if want := []string{"title", "slug", "body"}; !slices.Equal(fields, want) {
|
||||||
|
t.Errorf("form fields = %v, want %v", fields, want)
|
||||||
|
}
|
||||||
|
list, err := cabana.CompileList("acme.blog", ctl, fsys)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompileList: %v", err)
|
||||||
|
}
|
||||||
|
if list.ModelClass != "Post" || list.DefaultSort == nil {
|
||||||
|
t.Errorf("list = %+v, want modelClass Post with a default sort", list)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"controllers/posts/config_form.yaml", "controllers/posts/config_list.yaml", "models/posts/fields.yaml", "models/posts/columns.yaml"} {
|
||||||
|
if _, err := fs.Stat(fsys, name); err != nil {
|
||||||
|
t.Errorf("AdminFS is missing %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cabana compiles every admin controller at start-up without a database.
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: "testing", Environ: []string{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
routes, err := cabana.Activate(backpack.New(cfg), []party.Plugin{plugin})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cabana.Activate: %v", err)
|
||||||
|
}
|
||||||
|
if routes == nil {
|
||||||
|
t.Fatal("cabana.Activate mounted no admin routes")
|
||||||
|
}
|
||||||
|
|
||||||
|
editor := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{"acme.blog.access_posts": true}}
|
||||||
|
visitor := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{}}
|
||||||
|
if !cabana.Allows(editor, perm.RequiredPermissions()) {
|
||||||
|
t.Error("an administrator with acme.blog.access_posts is refused")
|
||||||
|
}
|
||||||
|
if cabana.Allows(visitor, perm.RequiredPermissions()) {
|
||||||
|
t.Error("an administrator without acme.blog.access_posts is allowed")
|
||||||
|
}
|
||||||
|
declared := slices.ContainsFunc(plugin.Permissions(), func(p pact.Permission) bool { return p.Code == "acme.blog.access_posts" })
|
||||||
|
if !declared {
|
||||||
|
t.Error("the plugin does not declare acme.blog.access_posts in Permissions")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,12 @@
|
|||||||
{}
|
plugin:
|
||||||
|
name: Blog
|
||||||
|
description: A simple blog.
|
||||||
|
permissions:
|
||||||
|
access_posts: Manage blog posts
|
||||||
|
posts:
|
||||||
|
title: Posts
|
||||||
|
post: Post
|
||||||
|
title_field: Title
|
||||||
|
slug: Slug
|
||||||
|
body: Body
|
||||||
|
published_at: Published
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Post struct {
|
|||||||
Title string `gorm:"column:title"`
|
Title string `gorm:"column:title"`
|
||||||
Slug string `gorm:"column:slug"`
|
Slug string `gorm:"column:slug"`
|
||||||
Body string `gorm:"column:body"`
|
Body string `gorm:"column:body"`
|
||||||
|
PublishedAt *time.Time `gorm:"column:published_at"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at"`
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,15 @@ func (Post) TableName() string { return "acme_blog_posts" }
|
|||||||
// set from a request.
|
// set from a request.
|
||||||
func (Post) Fillable() []string { return []string{"title", "slug", "body"} }
|
func (Post) Fillable() []string { return []string{"title", "slug", "body"} }
|
||||||
|
|
||||||
|
// Rules is the Go form of $rules. The admin API checks them with
|
||||||
|
// lagoon.Validate on every save; unique ignores the post being updated.
|
||||||
|
func (Post) Rules() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"title": "required|max:255",
|
||||||
|
"slug": "required|max:255|unique:acme_blog_posts",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewPost is the Go form of Post::make($input): it copies only the fillable
|
// NewPost is the Go form of Post::make($input): it copies only the fillable
|
||||||
// keys of input onto a new post and drops the rest, such as id.
|
// keys of input onto a new post and drops the rest, such as id.
|
||||||
func NewPost(input map[string]any) (*Post, error) {
|
func NewPost(input map[string]any) (*Post, error) {
|
||||||
|
|||||||
10
docs/examples/blog/models/posts/columns.yaml
Normal file
10
docs/examples/blog/models/posts/columns.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
columns:
|
||||||
|
title:
|
||||||
|
label: acme.blog::lang.posts.title_field
|
||||||
|
searchable: true
|
||||||
|
slug:
|
||||||
|
label: acme.blog::lang.posts.slug
|
||||||
|
searchable: true
|
||||||
|
published_at:
|
||||||
|
label: acme.blog::lang.posts.published_at
|
||||||
|
type: datetime
|
||||||
15
docs/examples/blog/models/posts/fields.yaml
Normal file
15
docs/examples/blog/models/posts/fields.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
fields:
|
||||||
|
title:
|
||||||
|
label: acme.blog::lang.posts.title_field
|
||||||
|
type: text
|
||||||
|
span: left
|
||||||
|
required: true
|
||||||
|
slug:
|
||||||
|
label: acme.blog::lang.posts.slug
|
||||||
|
type: text
|
||||||
|
span: right
|
||||||
|
required: true
|
||||||
|
body:
|
||||||
|
label: acme.blog::lang.posts.body
|
||||||
|
type: textarea
|
||||||
|
size: large
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
package blog
|
package blog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog/console"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"git.golem15.com/golem15/summercms/modules/party"
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
"github.com/go-gormigrate/gormigrate/v2"
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -20,6 +24,9 @@ var (
|
|||||||
_ pact.HasCommands = (*Plugin)(nil)
|
_ pact.HasCommands = (*Plugin)(nil)
|
||||||
_ pact.HasJobs = (*Plugin)(nil)
|
_ pact.HasJobs = (*Plugin)(nil)
|
||||||
_ pact.HasAdminControllers = (*Plugin)(nil)
|
_ pact.HasAdminControllers = (*Plugin)(nil)
|
||||||
|
_ pact.AdminAssets = (*Plugin)(nil)
|
||||||
|
_ pact.HasPermissions = (*Plugin)(nil)
|
||||||
|
_ pact.HasNavigation = (*Plugin)(nil)
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed config
|
//go:embed config
|
||||||
@@ -31,6 +38,9 @@ var langFS embed.FS
|
|||||||
//go:embed views/mail
|
//go:embed views/mail
|
||||||
var mailFS embed.FS
|
var mailFS embed.FS
|
||||||
|
|
||||||
|
//go:embed controllers/*/*.yaml models/*/*.yaml
|
||||||
|
var adminFS embed.FS
|
||||||
|
|
||||||
// Plugin is the acme.blog plugin, the Go form of Plugin.php.
|
// Plugin is the acme.blog plugin, the Go form of Plugin.php.
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
app *backpack.App
|
app *backpack.App
|
||||||
@@ -57,12 +67,56 @@ func (p *Plugin) MailLayouts() map[string]string { return nil }
|
|||||||
|
|
||||||
func (p *Plugin) Models() []any { return generatedModels() }
|
func (p *Plugin) Models() []any { return generatedModels() }
|
||||||
func (p *Plugin) Migrations() []*gormigrate.Migration { return generatedMigrations() }
|
func (p *Plugin) Migrations() []*gormigrate.Migration { return generatedMigrations() }
|
||||||
func (p *Plugin) Commands() []bonfire.Command { return generatedCommands() }
|
|
||||||
func (p *Plugin) Jobs() []pact.Job { return generatedJobs() }
|
func (p *Plugin) Jobs() []pact.Job { return generatedJobs() }
|
||||||
func (p *Plugin) AdminControllers() []pact.AdminController {
|
func (p *Plugin) AdminControllers() []pact.AdminController {
|
||||||
return generatedAdminControllers()
|
return generatedAdminControllers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Commands returns the generated commands plus blog:publish, which needs the
|
||||||
|
// database and so is built here with the plugin's withDB.
|
||||||
|
func (p *Plugin) Commands() []bonfire.Command {
|
||||||
|
return append(generatedCommands(), console.PublishCommand(p.withDB))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminFS is the admin YAML the controllers read: controllers/posts and
|
||||||
|
// models/posts.
|
||||||
|
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||||
|
|
||||||
|
// Permissions replaces registerPermissions().
|
||||||
|
func (p *Plugin) Permissions() []pact.Permission {
|
||||||
|
return []pact.Permission{{
|
||||||
|
Code: "acme.blog.access_posts",
|
||||||
|
Tab: "acme.blog::lang.plugin.name",
|
||||||
|
Label: "acme.blog::lang.permissions.access_posts",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation replaces registerNavigation().
|
||||||
|
func (p *Plugin) 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",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withDB runs fn with the application's database: the one the serve command
|
||||||
|
// published, or, when a console command runs, one opened from the config for
|
||||||
|
// the duration of fn.
|
||||||
|
func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error {
|
||||||
|
if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil {
|
||||||
|
return fn(gdb)
|
||||||
|
}
|
||||||
|
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
return fn(gdb)
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
party.Register(&Plugin{})
|
party.Register(&Plugin{})
|
||||||
}
|
}
|
||||||
|
|||||||
344
docs/examples/blog/postgres_test.go
Normal file
344
docs/examples/blog/postgres_test.go
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package blog_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
"github.com/testcontainers/testcontainers-go"
|
||||||
|
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Docker tests run against a throwaway testcontainers Postgres, never a
|
||||||
|
// developer or shared database. Under -short the container is not started
|
||||||
|
// and the tests skip; in a full run a missing Docker daemon fails the
|
||||||
|
// package.
|
||||||
|
var (
|
||||||
|
pgContainer *postgres.PostgresContainer
|
||||||
|
pgAdmin *sql.DB
|
||||||
|
pgDSN string
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
if !isShort() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
err := startPostgres(ctx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "blog: testcontainers postgres: %v\n", err)
|
||||||
|
stopPostgres()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
code := m.Run()
|
||||||
|
stopPostgres()
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isShort() bool {
|
||||||
|
for _, a := range os.Args {
|
||||||
|
if a == "-test.short" || a == "-test.short=true" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func startPostgres(ctx context.Context) error {
|
||||||
|
ctr, err := postgres.Run(ctx,
|
||||||
|
"postgres:16-alpine",
|
||||||
|
postgres.WithDatabase("blog"),
|
||||||
|
postgres.WithUsername("blog"),
|
||||||
|
postgres.WithPassword("blog"),
|
||||||
|
postgres.BasicWaitStrategies(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pgContainer = ctr
|
||||||
|
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pgAdmin, pgDSN = db, dsn
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopPostgres() {
|
||||||
|
if pgAdmin != nil {
|
||||||
|
_ = pgAdmin.Close()
|
||||||
|
}
|
||||||
|
if pgContainer != nil {
|
||||||
|
_ = testcontainers.TerminateContainer(pgContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// icuDatabase creates a database for one test with the ICU pl-PL locale
|
||||||
|
// lagoon requires, and drops it when the test ends. It returns the pool
|
||||||
|
// opened on it and its DSN.
|
||||||
|
func icuDatabase(t *testing.T) (*sql.DB, string) {
|
||||||
|
t.Helper()
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("requires testcontainers postgres")
|
||||||
|
}
|
||||||
|
if pgAdmin == nil {
|
||||||
|
t.Fatal("postgres unavailable: the container was not started")
|
||||||
|
}
|
||||||
|
name := "blog_" + strings.ToLower(strings.NewReplacer("/", "_", "-", "_").Replace(t.Name()))
|
||||||
|
quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
||||||
|
if _, err := pgAdmin.ExecContext(t.Context(), `CREATE DATABASE `+quoted+` TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'`); err != nil {
|
||||||
|
t.Fatalf("create %s: %v", name, err)
|
||||||
|
}
|
||||||
|
u, err := url.Parse(pgDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u.Path = "/" + name
|
||||||
|
dsn := u.String()
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = db.Close()
|
||||||
|
_, _ = pgAdmin.ExecContext(context.Background(), `DROP DATABASE IF EXISTS `+quoted+` WITH (FORCE)`)
|
||||||
|
})
|
||||||
|
return db, dsn
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrated activates acme.blog, migrates a fresh ICU database and publishes
|
||||||
|
// it on the application, as the serve command does at start-up.
|
||||||
|
func migrated(t *testing.T) (*backpack.App, party.Plugin, *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
sqlDB, _ := icuDatabase(t)
|
||||||
|
gdb, err := lagoon.Use(t.Context(), sqlDB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lagoon.Use: %v", err)
|
||||||
|
}
|
||||||
|
app, p := activate(t)
|
||||||
|
if err := lagoon.Migrate(gdb, []party.Plugin{p}); err != nil {
|
||||||
|
t.Fatalf("lagoon.Migrate: %v", err)
|
||||||
|
}
|
||||||
|
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
||||||
|
t.Fatalf("lagoon.Publish: %v", err)
|
||||||
|
}
|
||||||
|
return app, p, gdb
|
||||||
|
}
|
||||||
|
|
||||||
|
// createPost writes a post through the fill allow-list, as a real write path
|
||||||
|
// would, and sets published_at when publishedAt is not nil.
|
||||||
|
func createPost(t *testing.T, gdb *gorm.DB, input map[string]any, publishedAt *time.Time) *models.Post {
|
||||||
|
t.Helper()
|
||||||
|
post, err := models.NewPost(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPost: %v", err)
|
||||||
|
}
|
||||||
|
post.PublishedAt = publishedAt
|
||||||
|
if err := gdb.WithContext(t.Context()).Create(post).Error; err != nil {
|
||||||
|
t.Fatalf("create %v: %v", input, err)
|
||||||
|
}
|
||||||
|
return post
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateUpAndRollback(t *testing.T) {
|
||||||
|
_, p, gdb := migrated(t)
|
||||||
|
m := gdb.Migrator()
|
||||||
|
for _, col := range []string{"id", "title", "slug", "body", "published_at", "created_at", "updated_at"} {
|
||||||
|
if !m.HasColumn(&models.Post{}, col) {
|
||||||
|
t.Errorf("acme_blog_posts has no %s column after migrate", col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins := []party.Plugin{p}
|
||||||
|
if err := lagoon.RollbackLast(gdb, plugins, "acme.blog"); err != nil {
|
||||||
|
t.Fatalf("RollbackLast: %v", err)
|
||||||
|
}
|
||||||
|
if m.HasColumn(&models.Post{}, "published_at") {
|
||||||
|
t.Error("published_at is still there after rolling back the last migration")
|
||||||
|
}
|
||||||
|
if !m.HasTable(&models.Post{}) {
|
||||||
|
t.Error("rolling back the last migration dropped the table too")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lagoon.Migrate(gdb, plugins); err != nil {
|
||||||
|
t.Fatalf("migrate again: %v", err)
|
||||||
|
}
|
||||||
|
if !m.HasColumn(&models.Post{}, "published_at") {
|
||||||
|
t.Error("published_at is missing after migrating again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostsRouteAgainstDatabase(t *testing.T) {
|
||||||
|
app, p, gdb := migrated(t)
|
||||||
|
older := time.Date(2026, 1, 2, 10, 0, 0, 0, time.UTC)
|
||||||
|
newer := time.Date(2026, 1, 3, 10, 0, 0, 0, time.UTC)
|
||||||
|
createPost(t, gdb, map[string]any{"title": "First", "slug": "first", "body": "One."}, &older)
|
||||||
|
createPost(t, gdb, map[string]any{"title": "Second", "slug": "second", "body": "Two."}, &newer)
|
||||||
|
createPost(t, gdb, map[string]any{"title": "Draft", "slug": "draft", "body": "Not yet."}, nil)
|
||||||
|
// A forged id is dropped by the allow-list, so the database assigns one.
|
||||||
|
forged := createPost(t, gdb, map[string]any{"id": 9999, "title": "Third", "slug": "third"}, &older)
|
||||||
|
if forged.ID == 9999 {
|
||||||
|
t.Fatal("the fill allow-list let the request choose the id")
|
||||||
|
}
|
||||||
|
|
||||||
|
h, err := surf.Assemble(app, []party.Plugin{p})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Assemble: %v", err)
|
||||||
|
}
|
||||||
|
get := func(target string) (int, map[string]any) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("GET %s: %v\n%s", target, err, rec.Body.String())
|
||||||
|
}
|
||||||
|
return rec.Code, body
|
||||||
|
}
|
||||||
|
|
||||||
|
code, body := get("/api/blog/posts")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /api/blog/posts = %d, want 200: %v", code, body)
|
||||||
|
}
|
||||||
|
data, _ := body["data"].([]any)
|
||||||
|
var slugs []string
|
||||||
|
for _, row := range data {
|
||||||
|
slugs = append(slugs, row.(map[string]any)["slug"].(string))
|
||||||
|
}
|
||||||
|
if got, want := strings.Join(slugs, ","), "second,third,first"; got != want {
|
||||||
|
t.Errorf("slugs = %s, want %s (published only, newest first)", got, want)
|
||||||
|
}
|
||||||
|
if first, ok := data[0].(map[string]any); ok {
|
||||||
|
if first["published_at"] != "2026-01-03T10:00:00+00:00" {
|
||||||
|
t.Errorf("published_at = %v, want the Carbon form 2026-01-03T10:00:00+00:00", first["published_at"])
|
||||||
|
}
|
||||||
|
if _, leaked := first["created_at"]; leaked {
|
||||||
|
t.Error("the response leaks created_at, which postJSON does not declare")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meta, _ := body["meta"].(map[string]any)
|
||||||
|
if meta["total"] != float64(3) || meta["per_page"] != float64(15) || meta["current_page"] != float64(1) {
|
||||||
|
t.Errorf("meta = %v, want total 3, per_page 15 (the plugin default), current_page 1", meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, body = get("/api/blog/posts?page=2&per_page=2")
|
||||||
|
data, _ = body["data"].([]any)
|
||||||
|
if code != http.StatusOK || len(data) != 1 || data[0].(map[string]any)["slug"] != "first" {
|
||||||
|
t.Errorf("page 2 of 2 = %d %v, want only first", code, body)
|
||||||
|
}
|
||||||
|
meta, _ = body["meta"].(map[string]any)
|
||||||
|
if meta["last_page"] != float64(2) {
|
||||||
|
t.Errorf("meta = %v, want last_page 2", meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, body = get("/api/blog/posts?per_page=100000")
|
||||||
|
meta, _ = body["meta"].(map[string]any)
|
||||||
|
if meta["per_page"] != float64(100) {
|
||||||
|
t.Errorf("per_page=100000 gave meta %v, want per_page clamped to 100", meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishCommandAgainstDatabase(t *testing.T) {
|
||||||
|
_, p, gdb := migrated(t)
|
||||||
|
createPost(t, gdb, map[string]any{"title": "Hello", "slug": "hello-world", "body": "Hi."}, nil)
|
||||||
|
cmds := p.(pact.HasCommands).Commands()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := bonfire.Call(t.Context(), cmds, "blog:publish", []string{"hello-world"}, &out); err != nil {
|
||||||
|
t.Fatalf("blog:publish hello-world: %v\n%s", err, out.String())
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(out.String()); got != "published hello-world" {
|
||||||
|
t.Errorf("output = %q, want %q", got, "published hello-world")
|
||||||
|
}
|
||||||
|
var post models.Post
|
||||||
|
if err := gdb.Where("slug = ?", "hello-world").First(&post).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if post.PublishedAt == nil {
|
||||||
|
t.Fatal("published_at is still NULL after blog:publish")
|
||||||
|
}
|
||||||
|
first := *post.PublishedAt
|
||||||
|
|
||||||
|
// Publishing again keeps the first publication time.
|
||||||
|
out.Reset()
|
||||||
|
if err := bonfire.Call(t.Context(), cmds, "blog:publish", []string{"hello-world"}, &out); err != nil {
|
||||||
|
t.Fatalf("second blog:publish: %v", err)
|
||||||
|
}
|
||||||
|
if err := gdb.Where("slug = ?", "hello-world").First(&post).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !post.PublishedAt.Equal(first) {
|
||||||
|
t.Errorf("published_at changed from %v to %v on a second publish", first, *post.PublishedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slug that is SQL is only ever a bound value.
|
||||||
|
for _, slug := range []string{"missing", "x' OR '1'='1"} {
|
||||||
|
err := bonfire.Call(t.Context(), cmds, "blog:publish", []string{slug}, &out)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "no post has the slug") {
|
||||||
|
t.Errorf("blog:publish %q: err = %v, want no post has the slug", slug, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPublishCommandOpensDatabase runs blog:publish the way the application
|
||||||
|
// binary does: nothing is published on the app, so the command opens the
|
||||||
|
// database from database.dsn for the duration of its work.
|
||||||
|
func TestPublishCommandOpensDatabase(t *testing.T) {
|
||||||
|
_, _, gdb := migrated(t)
|
||||||
|
createPost(t, gdb, map[string]any{"title": "Hello", "slug": "hello-world"}, nil)
|
||||||
|
var name string
|
||||||
|
if err := gdb.Raw("SELECT current_database()").Scan(&name).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u, err := url.Parse(pgDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u.Path = "/" + name
|
||||||
|
|
||||||
|
app, p := activate(t)
|
||||||
|
if err := app.Config.Set("database.dsn", u.String()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))
|
||||||
|
if err := app.Config.Set("app.key", key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := bonfire.Call(t.Context(), p.(pact.HasCommands).Commands(), "blog:publish", []string{"hello-world"}, &out); err != nil {
|
||||||
|
t.Fatalf("blog:publish: %v\n%s", err, out.String())
|
||||||
|
}
|
||||||
|
var post models.Post
|
||||||
|
if err := gdb.Where("slug = ?", "hello-world").First(&post).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if post.PublishedAt == nil {
|
||||||
|
t.Error("published_at is still NULL after blog:publish opened its own database")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
package blog
|
package blog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog/controllers"
|
||||||
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
|
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
|
||||||
"git.golem15.com/golem15/summercms/docs/examples/blog/updates"
|
"git.golem15.com/golem15/summercms/docs/examples/blog/updates"
|
||||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
@@ -19,6 +20,7 @@ func generatedModels() []any {
|
|||||||
func generatedMigrations() []*gormigrate.Migration {
|
func generatedMigrations() []*gormigrate.Migration {
|
||||||
return []*gormigrate.Migration{
|
return []*gormigrate.Migration{
|
||||||
updates.CreatePosts(),
|
updates.CreatePosts(),
|
||||||
|
updates.AddPublishedAt(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,5 +33,7 @@ func generatedJobs() []pact.Job {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func generatedAdminControllers() []pact.AdminController {
|
func generatedAdminControllers() []pact.AdminController {
|
||||||
return nil
|
return []pact.AdminController{
|
||||||
|
controllers.PostsController(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ type postJSON struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
CreatedAt wire.Time `json:"created_at"`
|
PublishedAt wire.Time `json:"published_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// listPosts answers GET /api/blog/posts?page=N&per_page=M with one page of
|
// listPosts answers GET /api/blog/posts?page=N&per_page=M with one page of
|
||||||
// posts, newest first, in the {data, meta} shape of Laravel's paginator.
|
// published posts, newest first, in the {data, meta} shape of Laravel's
|
||||||
|
// paginator. Drafts, whose published_at is NULL, are never listed.
|
||||||
func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) {
|
func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) {
|
||||||
db, ok := p.app.Lookup[*gorm.DB]()
|
db, ok := p.app.Lookup[*gorm.DB]()
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -41,14 +42,14 @@ func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) {
|
|||||||
page := queryInt(r, "page", 1, 1, 10000)
|
page := queryInt(r, "page", 1, 1, 10000)
|
||||||
perPage := queryInt(r, "per_page", p.app.Config.Int("acme.blog.per_page"), 1, 100)
|
perPage := queryInt(r, "per_page", p.app.Config.Int("acme.blog.per_page"), 1, 100)
|
||||||
|
|
||||||
q := db.WithContext(r.Context()).Model(&models.Post{})
|
q := db.WithContext(r.Context()).Model(&models.Post{}).Where("published_at IS NOT NULL")
|
||||||
var total int64
|
var total int64
|
||||||
if err := q.Count(&total).Error; err != nil {
|
if err := q.Count(&total).Error; err != nil {
|
||||||
wire.WriteOpaque500(w)
|
wire.WriteOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var posts []models.Post
|
var posts []models.Post
|
||||||
if err := q.Order("created_at DESC, id DESC").Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
|
if err := q.Order("published_at DESC, id DESC").Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
|
||||||
wire.WriteOpaque500(w)
|
wire.WriteOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -59,7 +60,7 @@ func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) {
|
|||||||
Title: post.Title,
|
Title: post.Title,
|
||||||
Slug: post.Slug,
|
Slug: post.Slug,
|
||||||
Body: post.Body,
|
Body: post.Body,
|
||||||
CreatedAt: wire.Time{Time: post.CreatedAt.UTC().Truncate(time.Second)},
|
PublishedAt: wire.Time{Time: post.PublishedAt.UTC().Truncate(time.Second)},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))
|
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Code generated by summer make. DO NOT EDIT.
|
||||||
|
|
||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddPublishedAt returns the 20260101000100_add_published_at gormigrate entry.
|
||||||
|
func AddPublishedAt() *gormigrate.Migration {
|
||||||
|
return &gormigrate.Migration{
|
||||||
|
ID: "20260101000100_add_published_at",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec("ALTER TABLE acme_blog_posts ADD COLUMN published_at TIMESTAMPTZ NULL").Error
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec("ALTER TABLE acme_blog_posts DROP COLUMN IF EXISTS published_at").Error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ order: 50
|
|||||||
|
|
||||||
This walkthrough ports a small WinterCMS plugin, `Acme.Blog`, to SummerCMS. The plugin has what most real plugins have: a registration class, a `Post` model, `version.yaml` updates, a `routes.php` API endpoint, a backend `Posts` controller with its YAML, and an artisan command. Each section shows the WinterCMS file first and the SummerCMS file that replaces it.
|
This walkthrough ports a small WinterCMS plugin, `Acme.Blog`, to SummerCMS. The plugin has what most real plugins have: a registration class, a `Post` model, `version.yaml` updates, a `routes.php` API endpoint, a backend `Posts` controller with its YAML, and an artisan command. Each section shows the WinterCMS file first and the SummerCMS file that replaces it.
|
||||||
|
|
||||||
The name `acme/blog` is a neutral example. The SummerCMS code on this page is not a sketch: every Go and YAML block is a copy of a file under `docs/examples/blog` in the framework repository, a compiled plugin whose tests activate it, serve its route and run its migrations against PostgreSQL. Read [Coming from WinterCMS](coming-from-wintercms.md) first for the map of concepts.
|
The name `acme/blog` is a neutral example. The SummerCMS code on this page is not a sketch: every Go and YAML block is a copy of a file under `docs/examples/blog` in the framework repository, a compiled plugin whose tests activate it, serve its route, run its command and run its migrations up and down against PostgreSQL. Read [Coming from WinterCMS](coming-from-wintercms.md) first for the map of concepts.
|
||||||
|
|
||||||
## Plugin registration
|
## Plugin registration
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ In WinterCMS, `Plugin.php` describes the plugin and registers what it adds:
|
|||||||
```php
|
```php
|
||||||
<?php namespace Acme\Blog;
|
<?php namespace Acme\Blog;
|
||||||
|
|
||||||
|
use Backend;
|
||||||
use System\Classes\PluginBase;
|
use System\Classes\PluginBase;
|
||||||
|
|
||||||
class Plugin extends PluginBase
|
class Plugin extends PluginBase
|
||||||
@@ -24,32 +25,59 @@ class Plugin extends PluginBase
|
|||||||
public function pluginDetails()
|
public function pluginDetails()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => 'Blog',
|
'name' => 'acme.blog::lang.plugin.name',
|
||||||
'description' => 'A simple blog',
|
'description' => 'acme.blog::lang.plugin.description',
|
||||||
'author' => 'Acme',
|
'author' => 'Acme',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot()
|
public function register()
|
||||||
{
|
{
|
||||||
|
$this->registerConsoleCommand('blog.publish', \Acme\Blog\Console\Publish::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerPermissions()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'acme.blog.access_posts' => [
|
||||||
|
'tab' => 'acme.blog::lang.plugin.name',
|
||||||
|
'label' => 'acme.blog::lang.permissions.access_posts',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerNavigation()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'blog' => [
|
||||||
|
'label' => 'acme.blog::lang.plugin.name',
|
||||||
|
'url' => Backend::url('acme/blog/posts'),
|
||||||
|
'icon' => 'icon-pencil',
|
||||||
|
'permissions' => ['acme.blog.access_posts'],
|
||||||
|
],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
In SummerCMS the plugin is a Go type in the plugin's root package, `plugin.go`. `summer make:plugin acme.blog` writes it with every capability a new plugin usually needs: embedded config, language and mail files, models, migrations, commands, jobs and admin controllers. The plugin registers itself from `init`, and the application imports the package so that `init` runs:
|
In SummerCMS the plugin is a Go type in the plugin's root package, `plugin.go`. `summer make:plugin acme.blog` writes it with every capability a new plugin usually needs: embedded config, language and mail files, models, migrations, commands, jobs and admin controllers. The plugin registers itself from `init`, and the application imports the package so that `init` runs. Here is the finished file; the sections below explain each part:
|
||||||
|
|
||||||
```go src=docs/examples/blog/plugin.go
|
```go src=docs/examples/blog/plugin.go
|
||||||
package blog
|
package blog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog/console"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"git.golem15.com/golem15/summercms/modules/party"
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
"github.com/go-gormigrate/gormigrate/v2"
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -61,6 +89,9 @@ var (
|
|||||||
_ pact.HasCommands = (*Plugin)(nil)
|
_ pact.HasCommands = (*Plugin)(nil)
|
||||||
_ pact.HasJobs = (*Plugin)(nil)
|
_ pact.HasJobs = (*Plugin)(nil)
|
||||||
_ pact.HasAdminControllers = (*Plugin)(nil)
|
_ pact.HasAdminControllers = (*Plugin)(nil)
|
||||||
|
_ pact.AdminAssets = (*Plugin)(nil)
|
||||||
|
_ pact.HasPermissions = (*Plugin)(nil)
|
||||||
|
_ pact.HasNavigation = (*Plugin)(nil)
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed config
|
//go:embed config
|
||||||
@@ -72,6 +103,9 @@ var langFS embed.FS
|
|||||||
//go:embed views/mail
|
//go:embed views/mail
|
||||||
var mailFS embed.FS
|
var mailFS embed.FS
|
||||||
|
|
||||||
|
//go:embed controllers/*/*.yaml models/*/*.yaml
|
||||||
|
var adminFS embed.FS
|
||||||
|
|
||||||
// Plugin is the acme.blog plugin, the Go form of Plugin.php.
|
// Plugin is the acme.blog plugin, the Go form of Plugin.php.
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
app *backpack.App
|
app *backpack.App
|
||||||
@@ -98,18 +132,62 @@ func (p *Plugin) MailLayouts() map[string]string { return nil }
|
|||||||
|
|
||||||
func (p *Plugin) Models() []any { return generatedModels() }
|
func (p *Plugin) Models() []any { return generatedModels() }
|
||||||
func (p *Plugin) Migrations() []*gormigrate.Migration { return generatedMigrations() }
|
func (p *Plugin) Migrations() []*gormigrate.Migration { return generatedMigrations() }
|
||||||
func (p *Plugin) Commands() []bonfire.Command { return generatedCommands() }
|
|
||||||
func (p *Plugin) Jobs() []pact.Job { return generatedJobs() }
|
func (p *Plugin) Jobs() []pact.Job { return generatedJobs() }
|
||||||
func (p *Plugin) AdminControllers() []pact.AdminController {
|
func (p *Plugin) AdminControllers() []pact.AdminController {
|
||||||
return generatedAdminControllers()
|
return generatedAdminControllers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Commands returns the generated commands plus blog:publish, which needs the
|
||||||
|
// database and so is built here with the plugin's withDB.
|
||||||
|
func (p *Plugin) Commands() []bonfire.Command {
|
||||||
|
return append(generatedCommands(), console.PublishCommand(p.withDB))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminFS is the admin YAML the controllers read: controllers/posts and
|
||||||
|
// models/posts.
|
||||||
|
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||||
|
|
||||||
|
// Permissions replaces registerPermissions().
|
||||||
|
func (p *Plugin) Permissions() []pact.Permission {
|
||||||
|
return []pact.Permission{{
|
||||||
|
Code: "acme.blog.access_posts",
|
||||||
|
Tab: "acme.blog::lang.plugin.name",
|
||||||
|
Label: "acme.blog::lang.permissions.access_posts",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation replaces registerNavigation().
|
||||||
|
func (p *Plugin) 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",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withDB runs fn with the application's database: the one the serve command
|
||||||
|
// published, or, when a console command runs, one opened from the config for
|
||||||
|
// the duration of fn.
|
||||||
|
func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error {
|
||||||
|
if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil {
|
||||||
|
return fn(gdb)
|
||||||
|
}
|
||||||
|
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
return fn(gdb)
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
party.Register(&Plugin{})
|
party.Register(&Plugin{})
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`Boot` keeps the application, because the route handler below reaches the database through it. The capability methods return `generatedModels()`, `generatedMigrations()` and the other accessors from `registry.gen.go`, which the `make:` commands rewrite each time they add a model, a migration, a command, a job or an admin controller.
|
Each `var _ pact.HasX = (*Plugin)(nil)` line is a compile-time check that the plugin really implements a capability; the application finds the capabilities by type assertion. `Boot` keeps the application, because the route handler and the command reach the database through it. `Models`, `Migrations`, `Jobs` and `AdminControllers` return `generatedModels()` and the other accessors from `registry.gen.go`, which the `make:` commands rewrite each time they add a model, a migration, a command, a job or an admin controller. `AdminFS`, `Permissions` and `Navigation` were added by hand for the backend, and `Commands` for the console command.
|
||||||
|
|
||||||
The plugin's defaults live in `config/config.yaml`, the equivalent of the plugin's `config/config.php`. They are merged under the plugin ID, so this value is read as `acme.blog.per_page`:
|
The plugin's defaults live in `config/config.yaml`, the equivalent of the plugin's `config/config.php`. They are merged under the plugin ID, so this value is read as `acme.blog.per_page`:
|
||||||
|
|
||||||
@@ -119,9 +197,26 @@ The plugin's defaults live in `config/config.yaml`, the equivalent of the plugin
|
|||||||
per_page: 15
|
per_page: 15
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The language strings stay in YAML, one file per locale, and keep the WinterCMS `acme.blog::lang.` keys:
|
||||||
|
|
||||||
|
```yaml src=docs/examples/blog/lang/en/lang.yaml
|
||||||
|
plugin:
|
||||||
|
name: Blog
|
||||||
|
description: A simple blog.
|
||||||
|
permissions:
|
||||||
|
access_posts: Manage blog posts
|
||||||
|
posts:
|
||||||
|
title: Posts
|
||||||
|
post: Post
|
||||||
|
title_field: Title
|
||||||
|
slug: Slug
|
||||||
|
body: Body
|
||||||
|
published_at: Published
|
||||||
|
```
|
||||||
|
|
||||||
## The Post model
|
## The Post model
|
||||||
|
|
||||||
The WinterCMS model extends Eloquent and lists its mass-assignable columns in `$fillable`:
|
The WinterCMS model extends Eloquent and lists its mass-assignable columns in `$fillable` and its validation rules in `$rules`:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php namespace Acme\Blog\Models;
|
<?php namespace Acme\Blog\Models;
|
||||||
@@ -130,13 +225,22 @@ use Model;
|
|||||||
|
|
||||||
class Post extends Model
|
class Post extends Model
|
||||||
{
|
{
|
||||||
|
use \Winter\Storm\Database\Traits\Validation;
|
||||||
|
|
||||||
public $table = 'acme_blog_posts';
|
public $table = 'acme_blog_posts';
|
||||||
|
|
||||||
protected $fillable = ['title', 'slug', 'body'];
|
protected $fillable = ['title', 'slug', 'body'];
|
||||||
|
|
||||||
|
protected $dates = ['published_at'];
|
||||||
|
|
||||||
|
public $rules = [
|
||||||
|
'title' => 'required|max:255',
|
||||||
|
'slug' => 'required|max:255|unique:acme_blog_posts',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`summer make:model acme.blog Post` writes `models/post.go` and a migration that creates the table. The model is a GORM struct; add its columns, keep the table name with `TableName`, and turn `$fillable` into a `Fillable` method:
|
`summer make:model acme.blog Post` writes `models/post.go` and a migration that creates the table. The model is a GORM struct; add its columns, keep the table name with `TableName`, and turn `$fillable` and `$rules` into methods:
|
||||||
|
|
||||||
```go src=docs/examples/blog/models/post.go#Post
|
```go src=docs/examples/blog/models/post.go#Post
|
||||||
// Post is a blog post, the Go form of the WinterCMS Acme\Blog\Models\Post
|
// Post is a blog post, the Go form of the WinterCMS Acme\Blog\Models\Post
|
||||||
@@ -146,6 +250,7 @@ type Post struct {
|
|||||||
Title string `gorm:"column:title"`
|
Title string `gorm:"column:title"`
|
||||||
Slug string `gorm:"column:slug"`
|
Slug string `gorm:"column:slug"`
|
||||||
Body string `gorm:"column:body"`
|
Body string `gorm:"column:body"`
|
||||||
|
PublishedAt *time.Time `gorm:"column:published_at"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at"`
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
}
|
}
|
||||||
@@ -157,7 +262,18 @@ type Post struct {
|
|||||||
func (Post) Fillable() []string { return []string{"title", "slug", "body"} }
|
func (Post) Fillable() []string { return []string{"title", "slug", "body"} }
|
||||||
```
|
```
|
||||||
|
|
||||||
`Post::make($input)` becomes a function that fills a new post through `lagoon.Fill` with that allow-list. Keys outside it, such as `id`, are dropped, so a request can never set them:
|
```go src=docs/examples/blog/models/post.go#Post.Rules
|
||||||
|
// Rules is the Go form of $rules. The admin API checks them with
|
||||||
|
// lagoon.Validate on every save; unique ignores the post being updated.
|
||||||
|
func (Post) Rules() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"title": "required|max:255",
|
||||||
|
"slug": "required|max:255|unique:acme_blog_posts",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Post::make($input)` becomes a function that fills a new post through `lagoon.Fill` with that allow-list. Keys outside it, such as `id` or `published_at`, are dropped, so a request can never set them:
|
||||||
|
|
||||||
```go src=docs/examples/blog/models/post.go#NewPost
|
```go src=docs/examples/blog/models/post.go#NewPost
|
||||||
// NewPost is the Go form of Post::make($input): it copies only the fillable
|
// NewPost is the Go form of Post::make($input): it copies only the fillable
|
||||||
@@ -171,7 +287,7 @@ func NewPost(input map[string]any) (*Post, error) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
See [Models](../database/models.md) for the other Eloquent conventions and [Casts and validation](../database/casts-and-validation.md) for validating the input before you fill it.
|
The admin API uses the same `Fillable` list and `Rules` for every save. See [Models](../database/models.md) for the other Eloquent conventions and [Casts and validation](../database/casts-and-validation.md) for the rule strings.
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
@@ -181,6 +297,9 @@ WinterCMS lists a plugin's updates in `updates/version.yaml`, each version namin
|
|||||||
1.0.1:
|
1.0.1:
|
||||||
- 'Create the posts table'
|
- 'Create the posts table'
|
||||||
- create_posts_table.php
|
- create_posts_table.php
|
||||||
|
1.0.2:
|
||||||
|
- 'Add the publication date'
|
||||||
|
- add_published_at.php
|
||||||
```
|
```
|
||||||
|
|
||||||
```php
|
```php
|
||||||
@@ -209,7 +328,7 @@ class CreatePostsTable extends Migration
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
In SummerCMS each migration is a gormigrate entry in `updates/`, named after a UTC timestamp so the files sort in the order they run. There is no `version.yaml`: the plugin returns its migrations in order from `pact.HasMigrations`, and each plugin keeps its own history table. The migration `summer make:model` wrote creates the table with `id` and the timestamps; add the model's columns to it:
|
In SummerCMS each migration is a gormigrate entry in `updates/`, in a file named after a UTC timestamp so the files sort in the order they run. There is no `version.yaml`: the plugin returns its migrations in order from `pact.HasMigrations`, and each plugin keeps its own history table. The migration `summer make:model` wrote creates the table with `id` and the timestamps; add the model's columns to it:
|
||||||
|
|
||||||
```go src=docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go#CreatePosts
|
```go src=docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go#CreatePosts
|
||||||
// CreatePosts returns the 20260101000000_create_acme_blog_posts gormigrate entry.
|
// CreatePosts returns the 20260101000000_create_acme_blog_posts gormigrate entry.
|
||||||
@@ -233,7 +352,44 @@ func CreatePosts() *gormigrate.Migration {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`summer migrate` runs every plugin's pending migrations in plugin order. See [Migrations](../database/migrations.md) for the history tables and the rollback commands.
|
`summer migrate` runs every plugin's pending migrations in plugin order. See [Migrations](../database/migrations.md) for the history tables.
|
||||||
|
|
||||||
|
### Adding a column
|
||||||
|
|
||||||
|
The second WinterCMS update adds the publication date:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Schema::table('acme_blog_posts', function ($table) {
|
||||||
|
$table->timestamp('published_at')->nullable();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`summer make:migration acme.blog AddPublishedAt` writes an empty migration in `updates/` and adds it to the plugin's list. Fill in `Migrate` and give `Rollback` a real inverse:
|
||||||
|
|
||||||
|
```go src=docs/examples/blog/updates/20260101000100_add_published_at.go#AddPublishedAt
|
||||||
|
// AddPublishedAt returns the 20260101000100_add_published_at gormigrate entry.
|
||||||
|
func AddPublishedAt() *gormigrate.Migration {
|
||||||
|
return &gormigrate.Migration{
|
||||||
|
ID: "20260101000100_add_published_at",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec("ALTER TABLE acme_blog_posts ADD COLUMN published_at TIMESTAMPTZ NULL").Error
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec("ALTER TABLE acme_blog_posts DROP COLUMN IF EXISTS published_at").Error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the `PublishedAt` field to the model (shown above), then apply the migration. While you develop, roll back the plugin's last migration, edit it and apply it again:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
summer migrate
|
||||||
|
summer migrate:rollback --plugin acme.blog
|
||||||
|
summer migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
`migrate:rollback` undoes one migration of one plugin, here `published_at` only; the posts table stays.
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
@@ -245,11 +401,13 @@ A WinterCMS plugin declares its API endpoints in `routes.php`:
|
|||||||
use Acme\Blog\Models\Post;
|
use Acme\Blog\Models\Post;
|
||||||
|
|
||||||
Route::get('api/blog/posts', function () {
|
Route::get('api/blog/posts', function () {
|
||||||
return Post::orderBy('created_at', 'desc')->paginate(15);
|
return Post::whereNotNull('published_at')
|
||||||
|
->orderBy('published_at', 'desc')
|
||||||
|
->paginate(15);
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
The SummerCMS plugin implements `pact.HasRoutes` in `routes.go` and declares the route on a `pact.Router`. The handler reads the database the application published, counts and loads one page with bound parameters, and answers in the `{data, meta}` shape of Laravel's paginator through `lagoon.Paginate` and `wire.WriteJSON`:
|
The SummerCMS plugin implements `pact.HasRoutes` in `routes.go` and declares the route on a `pact.Router`. The handler reads the database the application published, counts and loads one page of published posts with bound parameters, and answers in the `{data, meta}` shape of Laravel's paginator through `lagoon.Paginate` and `wire.WriteJSON`:
|
||||||
|
|
||||||
```go src=docs/examples/blog/routes.go
|
```go src=docs/examples/blog/routes.go
|
||||||
package blog
|
package blog
|
||||||
@@ -281,11 +439,12 @@ type postJSON struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
CreatedAt wire.Time `json:"created_at"`
|
PublishedAt wire.Time `json:"published_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// listPosts answers GET /api/blog/posts?page=N&per_page=M with one page of
|
// listPosts answers GET /api/blog/posts?page=N&per_page=M with one page of
|
||||||
// posts, newest first, in the {data, meta} shape of Laravel's paginator.
|
// published posts, newest first, in the {data, meta} shape of Laravel's
|
||||||
|
// paginator. Drafts, whose published_at is NULL, are never listed.
|
||||||
func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) {
|
func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) {
|
||||||
db, ok := p.app.Lookup[*gorm.DB]()
|
db, ok := p.app.Lookup[*gorm.DB]()
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -295,14 +454,14 @@ func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) {
|
|||||||
page := queryInt(r, "page", 1, 1, 10000)
|
page := queryInt(r, "page", 1, 1, 10000)
|
||||||
perPage := queryInt(r, "per_page", p.app.Config.Int("acme.blog.per_page"), 1, 100)
|
perPage := queryInt(r, "per_page", p.app.Config.Int("acme.blog.per_page"), 1, 100)
|
||||||
|
|
||||||
q := db.WithContext(r.Context()).Model(&models.Post{})
|
q := db.WithContext(r.Context()).Model(&models.Post{}).Where("published_at IS NOT NULL")
|
||||||
var total int64
|
var total int64
|
||||||
if err := q.Count(&total).Error; err != nil {
|
if err := q.Count(&total).Error; err != nil {
|
||||||
wire.WriteOpaque500(w)
|
wire.WriteOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var posts []models.Post
|
var posts []models.Post
|
||||||
if err := q.Order("created_at DESC, id DESC").Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
|
if err := q.Order("published_at DESC, id DESC").Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
|
||||||
wire.WriteOpaque500(w)
|
wire.WriteOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -313,7 +472,7 @@ func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) {
|
|||||||
Title: post.Title,
|
Title: post.Title,
|
||||||
Slug: post.Slug,
|
Slug: post.Slug,
|
||||||
Body: post.Body,
|
Body: post.Body,
|
||||||
CreatedAt: wire.Time{Time: post.CreatedAt.UTC().Truncate(time.Second)},
|
PublishedAt: wire.Time{Time: post.PublishedAt.UTC().Truncate(time.Second)},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))
|
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))
|
||||||
@@ -330,4 +489,272 @@ func queryInt(r *http.Request, name string, def, lo, hi int) int {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The response is built from a separate `postJSON` type rather than the model, so a column added later does not appear in the API by accident. `page` and `per_page` are clamped, so a client cannot ask for the whole table at once. See [Routing](../services/routing.md) for groups, middleware and authentication, and [Queries and pagination](../database/queries-and-pagination.md) for sorting by a column the client names.
|
The response is built from a separate `postJSON` type rather than the model, so a column added later does not appear in the API by accident, and `wire.Time` writes timestamps in the form Laravel does (`2026-01-03T10:00:00+00:00`). `page` and `per_page` are clamped, so a client cannot ask for the whole table at once. See [Routing](../services/routing.md) for groups, middleware and authentication, and [Queries and pagination](../database/queries-and-pagination.md) for sorting by a column the client names.
|
||||||
|
|
||||||
|
## Admin controller
|
||||||
|
|
||||||
|
The WinterCMS backend controller implements the List and Form behaviours, requires a permission and names its YAML:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php namespace Acme\Blog\Controllers;
|
||||||
|
|
||||||
|
use BackendMenu;
|
||||||
|
use Backend\Classes\Controller;
|
||||||
|
|
||||||
|
class Posts extends Controller
|
||||||
|
{
|
||||||
|
public $implement = [
|
||||||
|
\Backend\Behaviors\ListController::class,
|
||||||
|
\Backend\Behaviors\FormController::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
public $listConfig = 'config_list.yaml';
|
||||||
|
public $formConfig = 'config_form.yaml';
|
||||||
|
|
||||||
|
public $requiredPermissions = ['acme.blog.access_posts'];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
BackendMenu::setContext('Acme.Blog', 'blog');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`summer make:admin-controller acme.blog Posts` writes `controllers/posts.go` and four YAML files. In SummerCMS the controller has no actions or views: the framework's generic admin API lists, shows, creates, updates and deletes records, and the admin SPA draws the screens from the YAML. The controller only says which model it serves, where its YAML is and which permission it needs:
|
||||||
|
|
||||||
|
```go src=docs/examples/blog/controllers/posts.go
|
||||||
|
// Code generated by summer make. DO NOT EDIT.
|
||||||
|
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ pact.AdminController = postsAdmin{}
|
||||||
|
_ pact.AdminRecordSource = postsAdmin{}
|
||||||
|
_ pact.AdminPermissioned = postsAdmin{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// postsAdmin is the Go form of the Posts backend controller with the List
|
||||||
|
// and Form behaviours.
|
||||||
|
type postsAdmin struct{}
|
||||||
|
|
||||||
|
func (postsAdmin) ID() string { return "acme.blog.posts" }
|
||||||
|
func (postsAdmin) ModelName() string { return "Post" }
|
||||||
|
func (postsAdmin) ConfigDir() string { return "controllers/posts" }
|
||||||
|
|
||||||
|
// NewRecord returns the model the generic admin handlers query and fill.
|
||||||
|
func (postsAdmin) NewRecord() any { return &models.Post{} }
|
||||||
|
|
||||||
|
// RequiredPermissions replaces $requiredPermissions: an administrator needs
|
||||||
|
// this permission before any schema or record is served.
|
||||||
|
func (postsAdmin) RequiredPermissions() []string {
|
||||||
|
return []string{"acme.blog.access_posts"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostsController returns the acme.blog.posts admin controller.
|
||||||
|
func PostsController() pact.AdminController { return postsAdmin{} }
|
||||||
|
```
|
||||||
|
|
||||||
|
`RequiredPermissions` replaces `$requiredPermissions` and is checked before any schema or record is served. `NewRecord` gives the admin API the model to query, and its `Fillable` and `Rules` decide what a save may write. The YAML keeps its WinterCMS syntax; `modelClass` must equal `ModelName`:
|
||||||
|
|
||||||
|
```yaml src=docs/examples/blog/controllers/posts/config_list.yaml
|
||||||
|
list: ~/plugins/acme/blog/models/posts/columns.yaml
|
||||||
|
modelClass: Post
|
||||||
|
title: acme.blog::lang.posts.title
|
||||||
|
recordUrl: acme/blog/posts/update/:id
|
||||||
|
recordsPerPage: 20
|
||||||
|
showCheckboxes: true
|
||||||
|
defaultSort:
|
||||||
|
column: published_at
|
||||||
|
direction: desc
|
||||||
|
toolbar:
|
||||||
|
buttons: [create, delete]
|
||||||
|
search:
|
||||||
|
prompt: backend::lang.list.search_prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml src=docs/examples/blog/controllers/posts/config_form.yaml
|
||||||
|
name: acme.blog::lang.posts.post
|
||||||
|
form: ~/plugins/acme/blog/models/posts/fields.yaml
|
||||||
|
modelClass: Post
|
||||||
|
defaultRedirect: acme/blog/posts
|
||||||
|
|
||||||
|
create:
|
||||||
|
redirect: acme/blog/posts/update/:id
|
||||||
|
redirectClose: acme/blog/posts
|
||||||
|
|
||||||
|
update:
|
||||||
|
redirect: acme/blog/posts
|
||||||
|
redirectClose: acme/blog/posts
|
||||||
|
```
|
||||||
|
|
||||||
|
The form and list fields sit under `models/posts/`, the controller's name, where `make:admin-controller` puts them (WinterCMS keeps them under `models/post/`, the model's name):
|
||||||
|
|
||||||
|
```yaml src=docs/examples/blog/models/posts/fields.yaml
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
label: acme.blog::lang.posts.title_field
|
||||||
|
type: text
|
||||||
|
span: left
|
||||||
|
required: true
|
||||||
|
slug:
|
||||||
|
label: acme.blog::lang.posts.slug
|
||||||
|
type: text
|
||||||
|
span: right
|
||||||
|
required: true
|
||||||
|
body:
|
||||||
|
label: acme.blog::lang.posts.body
|
||||||
|
type: textarea
|
||||||
|
size: large
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml src=docs/examples/blog/models/posts/columns.yaml
|
||||||
|
columns:
|
||||||
|
title:
|
||||||
|
label: acme.blog::lang.posts.title_field
|
||||||
|
searchable: true
|
||||||
|
slug:
|
||||||
|
label: acme.blog::lang.posts.slug
|
||||||
|
searchable: true
|
||||||
|
published_at:
|
||||||
|
label: acme.blog::lang.posts.published_at
|
||||||
|
type: datetime
|
||||||
|
```
|
||||||
|
|
||||||
|
The WinterCMS form had a date picker for `published_at`. SummerCMS forms have no date picker (see [Forms](../backend/forms.md) for the field types), so the form leaves the column out and `blog:publish` sets it; the list still shows it as a `datetime` column. `published_at` is not in `Fillable` either, so no form save can set it.
|
||||||
|
|
||||||
|
The plugin embeds the YAML through `pact.AdminAssets` and declares the permission and the menu entry, as `registerPermissions` and `registerNavigation` did:
|
||||||
|
|
||||||
|
```go src=docs/examples/blog/plugin.go#Plugin.AdminFS
|
||||||
|
// AdminFS is the admin YAML the controllers read: controllers/posts and
|
||||||
|
// models/posts.
|
||||||
|
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||||
|
```
|
||||||
|
|
||||||
|
```go src=docs/examples/blog/plugin.go#Plugin.Permissions
|
||||||
|
// Permissions replaces registerPermissions().
|
||||||
|
func (p *Plugin) Permissions() []pact.Permission {
|
||||||
|
return []pact.Permission{{
|
||||||
|
Code: "acme.blog.access_posts",
|
||||||
|
Tab: "acme.blog::lang.plugin.name",
|
||||||
|
Label: "acme.blog::lang.permissions.access_posts",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Admin controllers](../backend/admin-controllers.md) for the admin API and its hooks, and [Users and permissions](../backend/users-and-permissions.md) for granting the permission.
|
||||||
|
|
||||||
|
## Console command
|
||||||
|
|
||||||
|
The WinterCMS artisan command publishes a post by its slug:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php namespace Acme\Blog\Console;
|
||||||
|
|
||||||
|
use Acme\Blog\Models\Post;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class Publish extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'blog:publish {slug}';
|
||||||
|
|
||||||
|
protected $description = 'Publish a blog post by its slug';
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$post = Post::where('slug', $this->argument('slug'))->firstOrFail();
|
||||||
|
$post->published_at = $post->published_at ?: now();
|
||||||
|
$post->save();
|
||||||
|
$this->info('published ' . $post->slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`summer make:command acme.blog Publish` writes `console/publish.go` with a `bonfire.Command` named `blog:publish`. The command needs the database, which only the plugin can reach, so the finished function takes it as a parameter:
|
||||||
|
|
||||||
|
```go src=docs/examples/blog/console/publish.go
|
||||||
|
// Code generated by summer make. DO NOT EDIT.
|
||||||
|
|
||||||
|
package console
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublishCommand returns the blog:publish console command. withDB runs the
|
||||||
|
// command's work with the application's database; the plugin supplies it.
|
||||||
|
func PublishCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) error) bonfire.Command {
|
||||||
|
return bonfire.Command{
|
||||||
|
Name: "blog:publish",
|
||||||
|
Description: "Publish a blog post by its slug",
|
||||||
|
Args: []bonfire.Arg{{Name: "slug", Description: "Slug of the post to publish", Required: true}},
|
||||||
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||||
|
slug, _ := in.Argument("slug")
|
||||||
|
if slug == "" {
|
||||||
|
return fmt.Errorf("blog:publish: a slug is required")
|
||||||
|
}
|
||||||
|
return withDB(ctx, func(db *gorm.DB) error {
|
||||||
|
// A bound parameter, never the slug spliced into SQL. Publishing
|
||||||
|
// twice keeps the first publication time.
|
||||||
|
res := db.WithContext(ctx).Model(&models.Post{}).
|
||||||
|
Where("slug = ?", slug).
|
||||||
|
Update("published_at", gorm.Expr("COALESCE(published_at, NOW())"))
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("blog:publish: no post has the slug %q", slug)
|
||||||
|
}
|
||||||
|
out.Printf("published %s\n", slug)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the function now takes a parameter, the generated accessor no longer lists it; the plugin adds it in `Commands` and passes its `withDB`, which uses the database the server published or, when the command runs from the console, opens one from the application config for the command's duration:
|
||||||
|
|
||||||
|
```go src=docs/examples/blog/plugin.go#Plugin.Commands
|
||||||
|
// Commands returns the generated commands plus blog:publish, which needs the
|
||||||
|
// database and so is built here with the plugin's withDB.
|
||||||
|
func (p *Plugin) Commands() []bonfire.Command {
|
||||||
|
return append(generatedCommands(), console.PublishCommand(p.withDB))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```go src=docs/examples/blog/plugin.go#Plugin.withDB
|
||||||
|
// withDB runs fn with the application's database: the one the serve command
|
||||||
|
// published, or, when a console command runs, one opened from the config for
|
||||||
|
// the duration of fn.
|
||||||
|
func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error {
|
||||||
|
if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil {
|
||||||
|
return fn(gdb)
|
||||||
|
}
|
||||||
|
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
return fn(gdb)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the application and run the command on its binary:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
summer build
|
||||||
|
./bin/acme blog:publish hello-world
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Writing commands](../console/writing-commands.md) for arguments, flags, prompts and output.
|
||||||
|
|||||||
Reference in New Issue
Block a user