diff --git a/docs/examples/blog/blog_test.go b/docs/examples/blog/blog_test.go index 9c3f626..09048bd 100644 --- a/docs/examples/blog/blog_test.go +++ b/docs/examples/blog/blog_test.go @@ -9,6 +9,7 @@ import ( "git.golem15.com/golem15/summercms/docs/examples/blog" "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/compass" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" @@ -16,8 +17,8 @@ import ( ) // activate boots acme.blog the way the generated main does: an application -// config directory with the required HTTP limits, then party.Activate with -// the plugin ID. +// config directory with the required HTTP limits and admin secret, then +// party.Activate with the plugin ID. func activate(t *testing.T) (*backpack.App, party.Plugin) { t.Helper() dir := t.TempDir() @@ -29,6 +30,10 @@ func activate(t *testing.T) (*backpack.App, party.Plugin) { if err != nil { 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) plugins, err := party.Activate(app, []string{"acme.blog"}) if err != nil { @@ -76,6 +81,23 @@ func TestPluginActivates(t *testing.T) { if len(hasMigrations.Migrations()) == 0 { 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 { t.Errorf("acme.blog.per_page = %d, want the plugin default 15", got) } diff --git a/docs/examples/blog/console/publish.go b/docs/examples/blog/console/publish.go new file mode 100644 index 0000000..ed15705 --- /dev/null +++ b/docs/examples/blog/console/publish.go @@ -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 + }) + }, + } +} diff --git a/docs/examples/blog/console/publish_test.go b/docs/examples/blog/console/publish_test.go new file mode 100644 index 0000000..5f3822e --- /dev/null +++ b/docs/examples/blog/console/publish_test.go @@ -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) + } +} diff --git a/docs/examples/blog/controllers/posts.go b/docs/examples/blog/controllers/posts.go new file mode 100644 index 0000000..c44e54a --- /dev/null +++ b/docs/examples/blog/controllers/posts.go @@ -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{} } diff --git a/docs/examples/blog/controllers/posts/config_form.yaml b/docs/examples/blog/controllers/posts/config_form.yaml new file mode 100644 index 0000000..8628115 --- /dev/null +++ b/docs/examples/blog/controllers/posts/config_form.yaml @@ -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 diff --git a/docs/examples/blog/controllers/posts/config_list.yaml b/docs/examples/blog/controllers/posts/config_list.yaml new file mode 100644 index 0000000..e73379d --- /dev/null +++ b/docs/examples/blog/controllers/posts/config_list.yaml @@ -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 diff --git a/docs/examples/blog/controllers/posts_test.go b/docs/examples/blog/controllers/posts_test.go new file mode 100644 index 0000000..7a5a88c --- /dev/null +++ b/docs/examples/blog/controllers/posts_test.go @@ -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") + } +} diff --git a/docs/examples/blog/lang/en/lang.yaml b/docs/examples/blog/lang/en/lang.yaml index 0967ef4..ca17ce6 100644 --- a/docs/examples/blog/lang/en/lang.yaml +++ b/docs/examples/blog/lang/en/lang.yaml @@ -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 diff --git a/docs/examples/blog/models/post.go b/docs/examples/blog/models/post.go index 1e6763e..550ed34 100644 --- a/docs/examples/blog/models/post.go +++ b/docs/examples/blog/models/post.go @@ -11,12 +11,13 @@ import ( // Post is a blog post, the Go form of the WinterCMS Acme\Blog\Models\Post // model. type Post struct { - ID uint `gorm:"column:id;primaryKey"` - Title string `gorm:"column:title"` - Slug string `gorm:"column:slug"` - Body string `gorm:"column:body"` - CreatedAt time.Time `gorm:"column:created_at"` - UpdatedAt time.Time `gorm:"column:updated_at"` + ID uint `gorm:"column:id;primaryKey"` + Title string `gorm:"column:title"` + Slug string `gorm:"column:slug"` + Body string `gorm:"column:body"` + PublishedAt *time.Time `gorm:"column:published_at"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` } // TableName keeps the WinterCMS table name. @@ -26,6 +27,15 @@ func (Post) TableName() string { return "acme_blog_posts" } // set from a request. 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 // keys of input onto a new post and drops the rest, such as id. func NewPost(input map[string]any) (*Post, error) { diff --git a/docs/examples/blog/models/posts/columns.yaml b/docs/examples/blog/models/posts/columns.yaml new file mode 100644 index 0000000..9ab57c1 --- /dev/null +++ b/docs/examples/blog/models/posts/columns.yaml @@ -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 diff --git a/docs/examples/blog/models/posts/fields.yaml b/docs/examples/blog/models/posts/fields.yaml new file mode 100644 index 0000000..7f118ad --- /dev/null +++ b/docs/examples/blog/models/posts/fields.yaml @@ -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 diff --git a/docs/examples/blog/plugin.go b/docs/examples/blog/plugin.go index 36c3b81..849a71e 100644 --- a/docs/examples/blog/plugin.go +++ b/docs/examples/blog/plugin.go @@ -1,14 +1,18 @@ package blog import ( + "context" "embed" "io/fs" + "git.golem15.com/golem15/summercms/docs/examples/blog/console" "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" "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" ) var ( @@ -20,6 +24,9 @@ var ( _ pact.HasCommands = (*Plugin)(nil) _ pact.HasJobs = (*Plugin)(nil) _ pact.HasAdminControllers = (*Plugin)(nil) + _ pact.AdminAssets = (*Plugin)(nil) + _ pact.HasPermissions = (*Plugin)(nil) + _ pact.HasNavigation = (*Plugin)(nil) ) //go:embed config @@ -31,6 +38,9 @@ var langFS embed.FS //go:embed views/mail 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. type Plugin struct { 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) Migrations() []*gormigrate.Migration { return generatedMigrations() } -func (p *Plugin) Commands() []bonfire.Command { return generatedCommands() } func (p *Plugin) Jobs() []pact.Job { return generatedJobs() } func (p *Plugin) AdminControllers() []pact.AdminController { 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() { party.Register(&Plugin{}) } diff --git a/docs/examples/blog/postgres_test.go b/docs/examples/blog/postgres_test.go new file mode 100644 index 0000000..99e4ef5 --- /dev/null +++ b/docs/examples/blog/postgres_test.go @@ -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") + } +} diff --git a/docs/examples/blog/registry.gen.go b/docs/examples/blog/registry.gen.go index e6a1a32..808e897 100644 --- a/docs/examples/blog/registry.gen.go +++ b/docs/examples/blog/registry.gen.go @@ -3,6 +3,7 @@ package blog 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/updates" "git.golem15.com/golem15/summercms/modules/bonfire" @@ -19,6 +20,7 @@ func generatedModels() []any { func generatedMigrations() []*gormigrate.Migration { return []*gormigrate.Migration{ updates.CreatePosts(), + updates.AddPublishedAt(), } } @@ -31,5 +33,7 @@ func generatedJobs() []pact.Job { } func generatedAdminControllers() []pact.AdminController { - return nil + return []pact.AdminController{ + controllers.PostsController(), + } } diff --git a/docs/examples/blog/routes.go b/docs/examples/blog/routes.go index f4261ff..cd3a1f6 100644 --- a/docs/examples/blog/routes.go +++ b/docs/examples/blog/routes.go @@ -23,15 +23,16 @@ func (p *Plugin) Routes(r pact.Router) error { // postJSON is the response shape of one post. It is built field by field, // so a column added to the model never leaks into the API. type postJSON struct { - ID uint `json:"id"` - Title string `json:"title"` - Slug string `json:"slug"` - Body string `json:"body"` - CreatedAt wire.Time `json:"created_at"` + ID uint `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + Body string `json:"body"` + PublishedAt wire.Time `json:"published_at"` } // 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) { db, ok := p.app.Lookup[*gorm.DB]() if !ok { @@ -41,25 +42,25 @@ func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) { page := queryInt(r, "page", 1, 1, 10000) 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 if err := q.Count(&total).Error; err != nil { wire.WriteOpaque500(w) return } 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) return } rows := make([]postJSON, 0, len(posts)) for _, post := range posts { rows = append(rows, postJSON{ - ID: post.ID, - Title: post.Title, - Slug: post.Slug, - Body: post.Body, - CreatedAt: wire.Time{Time: post.CreatedAt.UTC().Truncate(time.Second)}, + ID: post.ID, + Title: post.Title, + Slug: post.Slug, + Body: post.Body, + PublishedAt: wire.Time{Time: post.PublishedAt.UTC().Truncate(time.Second)}, }) } wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total)) diff --git a/docs/examples/blog/updates/20260101000100_add_published_at.go b/docs/examples/blog/updates/20260101000100_add_published_at.go new file mode 100644 index 0000000..0a65028 --- /dev/null +++ b/docs/examples/blog/updates/20260101000100_add_published_at.go @@ -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 + }, + } +} diff --git a/docs/setup/porting-a-plugin.md b/docs/setup/porting-a-plugin.md index aab7812..88658ec 100644 --- a/docs/setup/porting-a-plugin.md +++ b/docs/setup/porting-a-plugin.md @@ -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. -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 @@ -17,6 +17,7 @@ In WinterCMS, `Plugin.php` describes the plugin and registers what it adds: ```php 'Blog', - 'description' => 'A simple blog', + 'name' => 'acme.blog::lang.plugin.name', + 'description' => 'acme.blog::lang.plugin.description', '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 package blog import ( + "context" "embed" "io/fs" + "git.golem15.com/golem15/summercms/docs/examples/blog/console" "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" "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" ) var ( @@ -61,6 +89,9 @@ var ( _ pact.HasCommands = (*Plugin)(nil) _ pact.HasJobs = (*Plugin)(nil) _ pact.HasAdminControllers = (*Plugin)(nil) + _ pact.AdminAssets = (*Plugin)(nil) + _ pact.HasPermissions = (*Plugin)(nil) + _ pact.HasNavigation = (*Plugin)(nil) ) //go:embed config @@ -72,6 +103,9 @@ var langFS embed.FS //go:embed views/mail 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. type Plugin struct { 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) Migrations() []*gormigrate.Migration { return generatedMigrations() } -func (p *Plugin) Commands() []bonfire.Command { return generatedCommands() } func (p *Plugin) Jobs() []pact.Job { return generatedJobs() } func (p *Plugin) AdminControllers() []pact.AdminController { 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() { 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`: @@ -119,9 +197,26 @@ The plugin's defaults live in `config/config.yaml`, the equivalent of the plugin 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 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 '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 // Post is a blog post, the Go form of the WinterCMS Acme\Blog\Models\Post // model. type Post struct { - ID uint `gorm:"column:id;primaryKey"` - Title string `gorm:"column:title"` - Slug string `gorm:"column:slug"` - Body string `gorm:"column:body"` - CreatedAt time.Time `gorm:"column:created_at"` - UpdatedAt time.Time `gorm:"column:updated_at"` + ID uint `gorm:"column:id;primaryKey"` + Title string `gorm:"column:title"` + Slug string `gorm:"column:slug"` + Body string `gorm:"column:body"` + PublishedAt *time.Time `gorm:"column:published_at"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` } ``` @@ -157,7 +262,18 @@ type Post struct { 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 // 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 @@ -181,6 +297,9 @@ WinterCMS lists a plugin's updates in `updates/version.yaml`, each version namin 1.0.1: - 'Create the posts table' - create_posts_table.php +1.0.2: + - 'Add the publication date' + - add_published_at.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 // 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 @@ -245,11 +401,13 @@ A WinterCMS plugin declares its API endpoints in `routes.php`: use Acme\Blog\Models\Post; 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 package blog @@ -277,15 +435,16 @@ func (p *Plugin) Routes(r pact.Router) error { // postJSON is the response shape of one post. It is built field by field, // so a column added to the model never leaks into the API. type postJSON struct { - ID uint `json:"id"` - Title string `json:"title"` - Slug string `json:"slug"` - Body string `json:"body"` - CreatedAt wire.Time `json:"created_at"` + ID uint `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + Body string `json:"body"` + PublishedAt wire.Time `json:"published_at"` } // 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) { db, ok := p.app.Lookup[*gorm.DB]() if !ok { @@ -295,25 +454,25 @@ func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) { page := queryInt(r, "page", 1, 1, 10000) 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 if err := q.Count(&total).Error; err != nil { wire.WriteOpaque500(w) return } 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) return } rows := make([]postJSON, 0, len(posts)) for _, post := range posts { rows = append(rows, postJSON{ - ID: post.ID, - Title: post.Title, - Slug: post.Slug, - Body: post.Body, - CreatedAt: wire.Time{Time: post.CreatedAt.UTC().Truncate(time.Second)}, + ID: post.ID, + Title: post.Title, + Slug: post.Slug, + Body: post.Body, + PublishedAt: wire.Time{Time: post.PublishedAt.UTC().Truncate(time.Second)}, }) } 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 +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.