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