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:
Jakub Zych
2026-09-30 23:28:41 +02:00
parent 4e83d06025
commit 41a3190956
20 changed files with 798 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
// Package models holds GORM models for the acme.blog plugin.
package models

View 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
}

View 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)
}
}