feat(11.1-05): add the acme.blog walkthrough plugin with its model, migration and posts route
- docs/examples/blog: scaffolder output for acme.blog (make:plugin, make:model) in the root module, with a Post model, a fill allow-list and NewPost, the create migration and GET /api/blog/posts paginated through lagoon - short tests activate the plugin, check the route with surf, the fill allow-list and the migration order - docs/setup/porting-a-plugin.md: registration, model, migrations and routes sections with src= copies of the plugin - TestDocsRequiredPages requires setup/porting-a-plugin
This commit is contained in:
@@ -124,6 +124,7 @@ var requiredPages = []string{
|
|||||||
"services/search",
|
"services/search",
|
||||||
"services/parity-testing",
|
"services/parity-testing",
|
||||||
"services/frontend-and-ajax",
|
"services/frontend-and-ajax",
|
||||||
|
"setup/porting-a-plugin",
|
||||||
}
|
}
|
||||||
|
|
||||||
// sectionOrder is the D-08 sidebar order: WinterCMS's documentation order,
|
// sectionOrder is the D-08 sidebar order: WinterCMS's documentation order,
|
||||||
|
|||||||
97
docs/examples/blog/blog_test.go
Normal file
97
docs/examples/blog/blog_test.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package blog_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func activate(t *testing.T) (*backpack.App, party.Plugin) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
httpYAML := "body_limits:\n default_bytes: 1048576\n upload_bytes: 10485760\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte(httpYAML), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "testing", Environ: []string{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app := backpack.New(cfg)
|
||||||
|
plugins, err := party.Activate(app, []string{"acme.blog"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Activate: %v", err)
|
||||||
|
}
|
||||||
|
if len(plugins) != 1 {
|
||||||
|
t.Fatalf("Activate returned %d plugins, want 1", len(plugins))
|
||||||
|
}
|
||||||
|
return app, plugins[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginActivates(t *testing.T) {
|
||||||
|
app, p := activate(t)
|
||||||
|
if p.ID() != "acme.blog" {
|
||||||
|
t.Fatalf("ID = %q, want acme.blog", p.ID())
|
||||||
|
}
|
||||||
|
if _, ok := p.(*blog.Plugin); !ok {
|
||||||
|
t.Fatalf("activated plugin is %T, want *blog.Plugin", p)
|
||||||
|
}
|
||||||
|
if len(p.Requires()) != 0 {
|
||||||
|
t.Errorf("Requires = %v, want none", p.Requires())
|
||||||
|
}
|
||||||
|
if _, ok := p.(pact.HasRoutes); !ok {
|
||||||
|
t.Error("plugin does not implement pact.HasRoutes")
|
||||||
|
}
|
||||||
|
if _, ok := p.(pact.HasConfig); !ok {
|
||||||
|
t.Error("plugin does not implement pact.HasConfig")
|
||||||
|
}
|
||||||
|
if _, ok := p.(pact.HasLang); !ok {
|
||||||
|
t.Error("plugin does not implement pact.HasLang")
|
||||||
|
}
|
||||||
|
hasModels, ok := p.(pact.HasModels)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("plugin does not implement pact.HasModels")
|
||||||
|
}
|
||||||
|
if got := hasModels.Models(); len(got) != 1 {
|
||||||
|
t.Errorf("Models = %v, want one model", got)
|
||||||
|
} else if _, ok := got[0].(*models.Post); !ok {
|
||||||
|
t.Errorf("Models()[0] is %T, want *models.Post", got[0])
|
||||||
|
}
|
||||||
|
hasMigrations, ok := p.(pact.HasMigrations)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("plugin does not implement pact.HasMigrations")
|
||||||
|
}
|
||||||
|
if len(hasMigrations.Migrations()) == 0 {
|
||||||
|
t.Error("Migrations is empty")
|
||||||
|
}
|
||||||
|
if got := app.Config.Int("acme.blog.per_page"); got != 15 {
|
||||||
|
t.Errorf("acme.blog.per_page = %d, want the plugin default 15", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutesRegistered(t *testing.T) {
|
||||||
|
app, p := activate(t)
|
||||||
|
router, err := surf.BuildRouter(app, []party.Plugin{p})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildRouter: %v", err)
|
||||||
|
}
|
||||||
|
routes := router.Routes()
|
||||||
|
found := slices.ContainsFunc(routes, func(r surf.RouteInfo) bool {
|
||||||
|
return r.Method == "GET" && r.Pattern == "/api/blog/posts" && r.PluginID == "acme.blog"
|
||||||
|
})
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("GET /api/blog/posts is not registered for acme.blog: %+v", routes)
|
||||||
|
}
|
||||||
|
}
|
||||||
2
docs/examples/blog/classes/doc.go
Normal file
2
docs/examples/blog/classes/doc.go
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Package classes holds services and hooks for the acme.blog plugin.
|
||||||
|
package classes
|
||||||
3
docs/examples/blog/config/config.yaml
Normal file
3
docs/examples/blog/config/config.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Defaults for acme.blog, merged under the plugin ID: the application reads
|
||||||
|
# this value as acme.blog.per_page and can override it in its own config.
|
||||||
|
per_page: 15
|
||||||
2
docs/examples/blog/console/doc.go
Normal file
2
docs/examples/blog/console/doc.go
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Package console holds bonfire commands for the acme.blog plugin.
|
||||||
|
package console
|
||||||
2
docs/examples/blog/controllers/doc.go
Normal file
2
docs/examples/blog/controllers/doc.go
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Package controllers holds HTTP handlers and admin controllers for the acme.blog plugin.
|
||||||
|
package controllers
|
||||||
2
docs/examples/blog/jobs/doc.go
Normal file
2
docs/examples/blog/jobs/doc.go
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Package jobs holds background jobs for the acme.blog plugin.
|
||||||
|
package jobs
|
||||||
1
docs/examples/blog/lang/en/lang.yaml
Normal file
1
docs/examples/blog/lang/en/lang.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
2
docs/examples/blog/middleware/doc.go
Normal file
2
docs/examples/blog/middleware/doc.go
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Package middleware holds named HTTP middleware for the acme.blog plugin.
|
||||||
|
package middleware
|
||||||
2
docs/examples/blog/models/doc.go
Normal file
2
docs/examples/blog/models/doc.go
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Package models holds GORM models for the acme.blog plugin.
|
||||||
|
package models
|
||||||
37
docs/examples/blog/models/post.go
Normal file
37
docs/examples/blog/models/post.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Code generated by summer make. DO NOT EDIT.
|
||||||
|
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName keeps the WinterCMS table name.
|
||||||
|
func (Post) TableName() string { return "acme_blog_posts" }
|
||||||
|
|
||||||
|
// Fillable is the Go form of $fillable: the only columns lagoon.Fill may
|
||||||
|
// set from a request.
|
||||||
|
func (Post) Fillable() []string { return []string{"title", "slug", "body"} }
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
post := &Post{}
|
||||||
|
if err := lagoon.Fill(post, post.Fillable(), input, true); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return post, nil
|
||||||
|
}
|
||||||
44
docs/examples/blog/models/post_test.go
Normal file
44
docs/examples/blog/models/post_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package models_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ lagoon.HasFillable = models.Post{}
|
||||||
|
|
||||||
|
func TestPostTableAndFill(t *testing.T) {
|
||||||
|
if got := (models.Post{}).TableName(); got != "acme_blog_posts" {
|
||||||
|
t.Fatalf("TableName = %q, want acme_blog_posts", got)
|
||||||
|
}
|
||||||
|
if got, want := (models.Post{}).Fillable(), []string{"title", "slug", "body"}; !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("Fillable = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
input := map[string]any{"id": 99, "title": "Hello", "slug": "hello-world", "body": "First post.", "created_at": "2020-01-01"}
|
||||||
|
post, err := models.NewPost(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPost: %v", err)
|
||||||
|
}
|
||||||
|
if post.ID != 0 {
|
||||||
|
t.Errorf("ID = %d, want 0: id is not fillable", post.ID)
|
||||||
|
}
|
||||||
|
if !post.CreatedAt.IsZero() {
|
||||||
|
t.Errorf("CreatedAt = %v, want zero: created_at is not fillable", post.CreatedAt)
|
||||||
|
}
|
||||||
|
if post.Title != "Hello" || post.Slug != "hello-world" || post.Body != "First post." {
|
||||||
|
t.Errorf("post = %+v, want the title, slug and body from input", post)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lagoon.Fill with the same allow-list, as NewPost does, drops id too.
|
||||||
|
var direct models.Post
|
||||||
|
if err := lagoon.Fill(&direct, direct.Fillable(), map[string]any{"id": 7, "title": "Direct"}, true); err != nil {
|
||||||
|
t.Fatalf("Fill: %v", err)
|
||||||
|
}
|
||||||
|
if direct.ID != 0 || direct.Title != "Direct" {
|
||||||
|
t.Errorf("Fill result = %+v, want ID 0 and title Direct", direct)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
docs/examples/blog/plugin.go
Normal file
68
docs/examples/blog/plugin.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package blog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ pact.HasConfig = (*Plugin)(nil)
|
||||||
|
_ pact.HasLang = (*Plugin)(nil)
|
||||||
|
_ pact.HasMailTemplates = (*Plugin)(nil)
|
||||||
|
_ pact.HasModels = (*Plugin)(nil)
|
||||||
|
_ pact.HasMigrations = (*Plugin)(nil)
|
||||||
|
_ pact.HasCommands = (*Plugin)(nil)
|
||||||
|
_ pact.HasJobs = (*Plugin)(nil)
|
||||||
|
_ pact.HasAdminControllers = (*Plugin)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed config
|
||||||
|
var configFS embed.FS
|
||||||
|
|
||||||
|
//go:embed lang
|
||||||
|
var langFS embed.FS
|
||||||
|
|
||||||
|
//go:embed views/mail
|
||||||
|
var mailFS embed.FS
|
||||||
|
|
||||||
|
// Plugin is the acme.blog plugin, the Go form of Plugin.php.
|
||||||
|
type Plugin struct {
|
||||||
|
app *backpack.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) ID() string { return "acme.blog" }
|
||||||
|
func (p *Plugin) Requires() []string { return nil }
|
||||||
|
|
||||||
|
func (p *Plugin) Register(*backpack.App) error { return nil }
|
||||||
|
|
||||||
|
// Boot keeps the application, so route handlers and commands can reach its
|
||||||
|
// services, such as the database, when they run.
|
||||||
|
func (p *Plugin) Boot(app *backpack.App) error {
|
||||||
|
p.app = app
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) ConfigFS() fs.FS { return configFS }
|
||||||
|
func (p *Plugin) LangFS() fs.FS { return langFS }
|
||||||
|
|
||||||
|
func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS }
|
||||||
|
func (p *Plugin) MailTemplates() []string { return nil }
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
party.Register(&Plugin{})
|
||||||
|
}
|
||||||
35
docs/examples/blog/registry.gen.go
Normal file
35
docs/examples/blog/registry.gen.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Code generated by summer make. DO NOT EDIT.
|
||||||
|
|
||||||
|
package blog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func generatedModels() []any {
|
||||||
|
return []any{
|
||||||
|
&models.Post{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedMigrations() []*gormigrate.Migration {
|
||||||
|
return []*gormigrate.Migration{
|
||||||
|
updates.CreatePosts(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedCommands() []bonfire.Command {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedJobs() []pact.Job {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedAdminControllers() []pact.AdminController {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
76
docs/examples/blog/routes.go
Normal file
76
docs/examples/blog/routes.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package blog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/wire"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ pact.HasRoutes = (*Plugin)(nil)
|
||||||
|
|
||||||
|
// Routes replaces routes.php.
|
||||||
|
func (p *Plugin) Routes(r pact.Router) error {
|
||||||
|
r.Get("/api/blog/posts", p.listPosts)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
db, ok := p.app.Lookup[*gorm.DB]()
|
||||||
|
if !ok {
|
||||||
|
wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "database unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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{})
|
||||||
|
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 {
|
||||||
|
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)},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryInt reads an integer query parameter, falling back to def when it is
|
||||||
|
// missing or not a number, and clamps it to [lo, hi].
|
||||||
|
func queryInt(r *http.Request, name string, def, lo, hi int) int {
|
||||||
|
n, err := strconv.Atoi(r.URL.Query().Get(name))
|
||||||
|
if err != nil {
|
||||||
|
n = def
|
||||||
|
}
|
||||||
|
return min(max(n, lo), hi)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Code generated by summer make. DO NOT EDIT.
|
||||||
|
|
||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreatePosts returns the 20260101000000_create_acme_blog_posts gormigrate entry.
|
||||||
|
func CreatePosts() *gormigrate.Migration {
|
||||||
|
return &gormigrate.Migration{
|
||||||
|
ID: "20260101000000_create_acme_blog_posts",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec(`CREATE TABLE acme_blog_posts (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`).Error
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec("DROP TABLE IF EXISTS acme_blog_posts").Error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
2
docs/examples/blog/updates/doc.go
Normal file
2
docs/examples/blog/updates/doc.go
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Package updates holds the gormigrate set for the acme.blog plugin.
|
||||||
|
package updates
|
||||||
57
docs/examples/blog/updates/updates_test.go
Normal file
57
docs/examples/blog/updates/updates_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package updates_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog"
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog/updates"
|
||||||
|
)
|
||||||
|
|
||||||
|
var migrationFile = regexp.MustCompile(`^(\d{14})_[a-z0-9_]+\.go$`)
|
||||||
|
|
||||||
|
func TestMigrationIDs(t *testing.T) {
|
||||||
|
if got := updates.CreatePosts().ID; got != "20260101000000_create_acme_blog_posts" {
|
||||||
|
t.Errorf("CreatePosts ID = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plugin returns the set in registry.gen.go's order, which must be
|
||||||
|
// ascending by ID: gormigrate applies it in slice order.
|
||||||
|
var ids []string
|
||||||
|
for _, m := range (&blog.Plugin{}).Migrations() {
|
||||||
|
if m.Migrate == nil || m.Rollback == nil {
|
||||||
|
t.Errorf("migration %s has no Migrate or Rollback", m.ID)
|
||||||
|
}
|
||||||
|
ids = append(ids, m.ID)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
t.Fatal("the plugin has no migrations")
|
||||||
|
}
|
||||||
|
if !slices.IsSorted(ids) {
|
||||||
|
t.Errorf("migration IDs are not in ascending order: %v", ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every migration file is named after its ID, and every ID has a file.
|
||||||
|
entries, err := os.ReadDir(".")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var files []string
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if e.IsDir() || name == "doc.go" || strings.HasSuffix(name, "_test.go") || !strings.HasSuffix(name, ".go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !migrationFile.MatchString(name) {
|
||||||
|
t.Errorf("%s does not start with a 14-digit timestamp", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, strings.TrimSuffix(name, ".go"))
|
||||||
|
}
|
||||||
|
if !slices.Equal(files, ids) {
|
||||||
|
t.Errorf("migration files %v, want one per ID %v", files, ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
docs/examples/blog/views/mail/welcome.htm
Normal file
4
docs/examples/blog/views/mail/welcome.htm
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
subject = "Welcome"
|
||||||
|
description = "Placeholder mail template"
|
||||||
|
==
|
||||||
|
Hello.
|
||||||
333
docs/setup/porting-a-plugin.md
Normal file
333
docs/setup/porting-a-plugin.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
---
|
||||||
|
title: Porting a plugin
|
||||||
|
description: Take a WinterCMS acme/blog plugin with a model, migrations, a route, a backend controller and an artisan command to a compiled SummerCMS plugin, step by step.
|
||||||
|
section: setup
|
||||||
|
order: 50
|
||||||
|
---
|
||||||
|
# Porting a plugin
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Plugin registration
|
||||||
|
|
||||||
|
In WinterCMS, `Plugin.php` describes the plugin and registers what it adds:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php namespace Acme\Blog;
|
||||||
|
|
||||||
|
use System\Classes\PluginBase;
|
||||||
|
|
||||||
|
class Plugin extends PluginBase
|
||||||
|
{
|
||||||
|
public function pluginDetails()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'Blog',
|
||||||
|
'description' => 'A simple blog',
|
||||||
|
'author' => 'Acme',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```go src=docs/examples/blog/plugin.go
|
||||||
|
package blog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ pact.HasConfig = (*Plugin)(nil)
|
||||||
|
_ pact.HasLang = (*Plugin)(nil)
|
||||||
|
_ pact.HasMailTemplates = (*Plugin)(nil)
|
||||||
|
_ pact.HasModels = (*Plugin)(nil)
|
||||||
|
_ pact.HasMigrations = (*Plugin)(nil)
|
||||||
|
_ pact.HasCommands = (*Plugin)(nil)
|
||||||
|
_ pact.HasJobs = (*Plugin)(nil)
|
||||||
|
_ pact.HasAdminControllers = (*Plugin)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed config
|
||||||
|
var configFS embed.FS
|
||||||
|
|
||||||
|
//go:embed lang
|
||||||
|
var langFS embed.FS
|
||||||
|
|
||||||
|
//go:embed views/mail
|
||||||
|
var mailFS embed.FS
|
||||||
|
|
||||||
|
// Plugin is the acme.blog plugin, the Go form of Plugin.php.
|
||||||
|
type Plugin struct {
|
||||||
|
app *backpack.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) ID() string { return "acme.blog" }
|
||||||
|
func (p *Plugin) Requires() []string { return nil }
|
||||||
|
|
||||||
|
func (p *Plugin) Register(*backpack.App) error { return nil }
|
||||||
|
|
||||||
|
// Boot keeps the application, so route handlers and commands can reach its
|
||||||
|
// services, such as the database, when they run.
|
||||||
|
func (p *Plugin) Boot(app *backpack.App) error {
|
||||||
|
p.app = app
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) ConfigFS() fs.FS { return configFS }
|
||||||
|
func (p *Plugin) LangFS() fs.FS { return langFS }
|
||||||
|
|
||||||
|
func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS }
|
||||||
|
func (p *Plugin) MailTemplates() []string { return nil }
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```yaml src=docs/examples/blog/config/config.yaml
|
||||||
|
# Defaults for acme.blog, merged under the plugin ID: the application reads
|
||||||
|
# this value as acme.blog.per_page and can override it in its own config.
|
||||||
|
per_page: 15
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Post model
|
||||||
|
|
||||||
|
The WinterCMS model extends Eloquent and lists its mass-assignable columns in `$fillable`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php namespace Acme\Blog\Models;
|
||||||
|
|
||||||
|
use Model;
|
||||||
|
|
||||||
|
class Post extends Model
|
||||||
|
{
|
||||||
|
public $table = 'acme_blog_posts';
|
||||||
|
|
||||||
|
protected $fillable = ['title', 'slug', 'body'];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`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:
|
||||||
|
|
||||||
|
```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"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```go src=docs/examples/blog/models/post.go#Post.Fillable
|
||||||
|
// Fillable is the Go form of $fillable: the only columns lagoon.Fill may
|
||||||
|
// set from a request.
|
||||||
|
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#NewPost
|
||||||
|
// 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) {
|
||||||
|
post := &Post{}
|
||||||
|
if err := lagoon.Fill(post, post.Fillable(), input, true); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return post, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
WinterCMS lists a plugin's updates in `updates/version.yaml`, each version naming the migration scripts it runs:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
1.0.1:
|
||||||
|
- 'Create the posts table'
|
||||||
|
- create_posts_table.php
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php namespace Acme\Blog\Updates;
|
||||||
|
|
||||||
|
use Schema;
|
||||||
|
use Winter\Storm\Database\Updates\Migration;
|
||||||
|
|
||||||
|
class CreatePostsTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::create('acme_blog_posts', function ($table) {
|
||||||
|
$table->increments('id');
|
||||||
|
$table->string('title');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->text('body');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('acme_blog_posts');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```go src=docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go#CreatePosts
|
||||||
|
// CreatePosts returns the 20260101000000_create_acme_blog_posts gormigrate entry.
|
||||||
|
func CreatePosts() *gormigrate.Migration {
|
||||||
|
return &gormigrate.Migration{
|
||||||
|
ID: "20260101000000_create_acme_blog_posts",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec(`CREATE TABLE acme_blog_posts (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`).Error
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec("DROP TABLE IF EXISTS acme_blog_posts").Error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`summer migrate` runs every plugin's pending migrations in plugin order. See [Migrations](../database/migrations.md) for the history tables and the rollback commands.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
A WinterCMS plugin declares its API endpoints in `routes.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Acme\Blog\Models\Post;
|
||||||
|
|
||||||
|
Route::get('api/blog/posts', function () {
|
||||||
|
return Post::orderBy('created_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`:
|
||||||
|
|
||||||
|
```go src=docs/examples/blog/routes.go
|
||||||
|
package blog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/wire"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ pact.HasRoutes = (*Plugin)(nil)
|
||||||
|
|
||||||
|
// Routes replaces routes.php.
|
||||||
|
func (p *Plugin) Routes(r pact.Router) error {
|
||||||
|
r.Get("/api/blog/posts", p.listPosts)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func (p *Plugin) listPosts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
db, ok := p.app.Lookup[*gorm.DB]()
|
||||||
|
if !ok {
|
||||||
|
wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "database unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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{})
|
||||||
|
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 {
|
||||||
|
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)},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryInt reads an integer query parameter, falling back to def when it is
|
||||||
|
// missing or not a number, and clamps it to [lo, hi].
|
||||||
|
func queryInt(r *http.Request, name string, def, lo, hi int) int {
|
||||||
|
n, err := strconv.Atoi(r.URL.Query().Get(name))
|
||||||
|
if err != nil {
|
||||||
|
n = def
|
||||||
|
}
|
||||||
|
return min(max(n, lo), hi)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user