feat(api): serve the category content field on GET /_journal/api/v1/categories
- add the nullable TEXT column golem15_journal_categories.content (migration 202610090001) - map it as models.Category.Content and emit category content on every categories row - document the categories row shape and the stored-as-is HTML semantics
This commit is contained in:
14
README.md
14
README.md
@@ -25,7 +25,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later
|
||||
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve.
|
||||
- **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`.
|
||||
- **Phrasebooks.** Embedded `lang/en/lang.yaml` and `lang/pl/lang.yaml` hold plugin, journal, post, category, and tag UI strings. They are independent of model translation JSON.
|
||||
- **Schema.** Seven gormigrate steps create the six journal tables and the author-slug column. AutoMigrate is not used. Table names are `golem15_journal_*` only.
|
||||
- **Schema.** Eight gormigrate steps create the six journal tables, the author-slug column and the category `content` column. AutoMigrate is not used. Table names are `golem15_journal_*` only.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -80,6 +80,16 @@ GET /_journal/api/v1/authors/jane-doe
|
||||
GET /_journal/api/v1/authors/jane-doe/posts?page=2
|
||||
```
|
||||
|
||||
### Public categories API
|
||||
|
||||
`GET /_journal/api/v1/categories` returns `{"data":[...]}`, a tree of category rows. Each row has the keys `id`, `name`, `slug`, `description`, `content`, `parent_id`, `nest_depth` and `post_count`, plus `children` only when the category has listed children. A category with no published posts and no listed children is omitted unless the request adds `include_empty=1`.
|
||||
|
||||
`content` is the category's HTML body for a category landing page. It is stored and returned as is: it is not markdown and the API does not render, trim or sanitize it. It is `null` when unset, and it is the same in every locale, because it is a host column and not translatable. A consumer that renders it as HTML must apply its own gate or sanitizer first.
|
||||
|
||||
```json
|
||||
{"data":[{"id":4,"name":"Acme news","slug":"news","description":null,"content":"<h2>Acme news</h2><p>Pillar copy</p>","parent_id":null,"nest_depth":0,"post_count":3}]}
|
||||
```
|
||||
|
||||
### Public author API
|
||||
|
||||
`GET /_journal/api/v1/authors/{author}` returns one author's public profile without a token. `{author}` resolves in this order:
|
||||
@@ -157,7 +167,7 @@ func (Post) MorphName() string { return `Golem15\Journal\Models\Post
|
||||
| `models.Post.ShouldBeSearchable` | False when unpublished or `search_use_typesense` is off. |
|
||||
| `models.Post.ToSearchableArray` | Typesense document built after a committed save. |
|
||||
| `models.Post.CanEdit` | Owner or `golem15.journal.access_other_posts`. |
|
||||
| `models.Category` | Category row; `TableName` is `golem15_journal_categories`. |
|
||||
| `models.Category` | Category row; `TableName` is `golem15_journal_categories`; `Content` holds the HTML body served as `content`. |
|
||||
| `models.Category.MorphName` | PHP class string `Golem15\Journal\Models\Category`. |
|
||||
| `models.Category.Fillable` | `name`, `slug`, `code`, `description`, `parent_id`. |
|
||||
| `models.Tag` | Tag row; `TableName` is `golem15_journal_tags`. |
|
||||
|
||||
62
controllers/api/categories_test.go
Normal file
62
controllers/api/categories_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
)
|
||||
|
||||
func sortedCategoryKeys(row map[string]any) []string {
|
||||
keys := make([]string, 0, len(row))
|
||||
for k := range row {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func TestSerializeCategoryNodeContent(t *testing.T) {
|
||||
html := "<h2>Acme</h2>\n<p>Pillar copy</p>\n"
|
||||
var parentID uint = 1
|
||||
root := models.Category{ID: 1, Name: "News", Slug: "news", Content: &html}
|
||||
child := models.Category{ID: 2, Name: "Quiet", Slug: "quiet", ParentID: &parentID}
|
||||
byParent := map[uint][]models.Category{1: {child}}
|
||||
counts := map[uint]int64{1: 2}
|
||||
|
||||
row := serializeCategoryNode(&root, byParent, counts, true)
|
||||
if row == nil {
|
||||
t.Fatal("root row is nil")
|
||||
}
|
||||
if got, ok := row["content"].(string); !ok || got != html {
|
||||
t.Fatalf("root content = %#v, want %q", row["content"], html)
|
||||
}
|
||||
wantRoot := []string{"children", "content", "description", "id", "name", "nest_depth", "parent_id", "post_count", "slug"}
|
||||
if got := sortedCategoryKeys(row); !reflect.DeepEqual(got, wantRoot) {
|
||||
t.Fatalf("root keys = %v, want %v", got, wantRoot)
|
||||
}
|
||||
kids, ok := row["children"].([]map[string]any)
|
||||
if !ok || len(kids) != 1 {
|
||||
t.Fatalf("children = %#v, want one row", row["children"])
|
||||
}
|
||||
v, present := kids[0]["content"]
|
||||
if !present || v != nil {
|
||||
t.Fatalf("child content = %#v (present %v), want nil", v, present)
|
||||
}
|
||||
wantChild := []string{"content", "description", "id", "name", "nest_depth", "parent_id", "post_count", "slug"}
|
||||
if got := sortedCategoryKeys(kids[0]); !reflect.DeepEqual(got, wantChild) {
|
||||
t.Fatalf("child keys = %v, want %v", got, wantChild)
|
||||
}
|
||||
|
||||
listed := serializeCategoryNode(&root, byParent, counts, false)
|
||||
if listed == nil {
|
||||
t.Fatal("root omitted without include_empty")
|
||||
}
|
||||
if _, has := listed["children"]; has {
|
||||
t.Fatalf("empty child listed without include_empty: %#v", listed["children"])
|
||||
}
|
||||
if got, ok := listed["content"].(string); !ok || got != html {
|
||||
t.Fatalf("root content without include_empty = %#v, want %q", listed["content"], html)
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,10 @@ func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Catego
|
||||
if c.Description != nil {
|
||||
desc = *c.Description
|
||||
}
|
||||
var content any
|
||||
if c.Content != nil {
|
||||
content = *c.Content
|
||||
}
|
||||
var parent any
|
||||
if c.ParentID != nil {
|
||||
parent = *c.ParentID
|
||||
@@ -208,6 +212,7 @@ func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Catego
|
||||
"name": c.Name,
|
||||
"slug": c.Slug,
|
||||
"description": desc,
|
||||
"content": content,
|
||||
"parent_id": parent,
|
||||
"nest_depth": depth,
|
||||
"post_count": postCount,
|
||||
|
||||
131
journal_api_categories_test.go
Normal file
131
journal_api_categories_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package journal_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
)
|
||||
|
||||
// flattenCategoryRows walks the categories tree (data plus children) into one
|
||||
// slice of rows.
|
||||
func flattenCategoryRows(rows []map[string]any) []map[string]any {
|
||||
var out []map[string]any
|
||||
for _, row := range rows {
|
||||
out = append(out, row)
|
||||
if kids, ok := row["children"].([]any); ok {
|
||||
nested := make([]map[string]any, 0, len(kids))
|
||||
for _, k := range kids {
|
||||
if m, ok := k.(map[string]any); ok {
|
||||
nested = append(nested, m)
|
||||
}
|
||||
}
|
||||
out = append(out, flattenCategoryRows(nested)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func categoryRowsBySlug(t *testing.T, env *adminEnv, path string) map[string]map[string]any {
|
||||
t.Helper()
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, path, "", nil)
|
||||
var body struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("categories json: %s err=%v", rec.Body.String(), err)
|
||||
}
|
||||
out := map[string]map[string]any{}
|
||||
for _, row := range flattenCategoryRows(body.Data) {
|
||||
slug, _ := row["slug"].(string)
|
||||
out[slug] = row
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertNullContent(t *testing.T, rows map[string]map[string]any, slug string) {
|
||||
t.Helper()
|
||||
row, ok := rows[slug]
|
||||
if !ok {
|
||||
t.Fatalf("category %s not listed", slug)
|
||||
}
|
||||
v, present := row["content"]
|
||||
if !present || v != nil {
|
||||
t.Fatalf("%s content = %#v (present %v), want JSON null", slug, v, present)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalCategoryContentAPI(t *testing.T) {
|
||||
env := newPostsEnv(t)
|
||||
html := "<h2>Acme news</h2>\n<p>Pillar copy</p>"
|
||||
left, right, depth := 3, 4, 0
|
||||
news := models.Category{Name: "News", Slug: "news", Content: &html, NestLeft: &left, NestRight: &right, NestDepth: &depth}
|
||||
if err := env.gdb.Create(&news).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quiet := models.Category{Name: "Quiet", Slug: "quiet"}
|
||||
if err := env.gdb.Create(&quiet).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
past := time.Now().UTC().Add(-time.Hour)
|
||||
post := models.Post{Title: "Listed", Slug: "listed-content", Content: "x", Published: true, PublishedAt: &past}
|
||||
if err := env.gdb.Create(&post).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.gdb.Create(&models.PostCategory{PostID: post.ID, CategoryID: news.ID}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
all := categoryRowsBySlug(t, env, "/_journal/api/v1/categories?include_empty=1")
|
||||
if got, ok := all["news"]["content"].(string); !ok || got != html {
|
||||
t.Fatalf("news content = %#v, want %q", all["news"]["content"], html)
|
||||
}
|
||||
assertNullContent(t, all, "quiet")
|
||||
assertNullContent(t, all, "uncategorized")
|
||||
|
||||
listed := categoryRowsBySlug(t, env, "/_journal/api/v1/categories")
|
||||
if got, ok := listed["news"]["content"].(string); !ok || got != html {
|
||||
t.Fatalf("news content without include_empty = %#v, want %q", listed["news"]["content"], html)
|
||||
}
|
||||
if _, ok := listed["quiet"]; ok {
|
||||
t.Fatal("empty category quiet listed without include_empty")
|
||||
}
|
||||
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_journal/api/v1/posts", "", nil)
|
||||
var posts struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &posts); err != nil {
|
||||
t.Fatalf("posts json: %s err=%v", rec.Body.String(), err)
|
||||
}
|
||||
var found bool
|
||||
for _, p := range posts.Data {
|
||||
if p["slug"] != "listed-content" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
cats, ok := p["categories"].([]any)
|
||||
if !ok || len(cats) == 0 {
|
||||
t.Fatalf("post categories = %#v", p["categories"])
|
||||
}
|
||||
first, ok := cats[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("post category entry = %#v", cats[0])
|
||||
}
|
||||
keys := make([]string, 0, len(first))
|
||||
for k := range first {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if want := []string{"id", "name", "slug"}; !reflect.DeepEqual(keys, want) {
|
||||
t.Fatalf("post category keys = %v, want %v", keys, want)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("post listed-content missing from /posts: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ type Category struct {
|
||||
Slug string `gorm:"column:slug"`
|
||||
Code *string `gorm:"column:code"`
|
||||
Description *string `gorm:"column:description"`
|
||||
Content *string `gorm:"column:content"` // HTML body (D-21): stored and served as is, not translatable
|
||||
ParentID *uint `gorm:"column:parent_id"`
|
||||
NestLeft *int `gorm:"column:nest_left"`
|
||||
NestRight *int `gorm:"column:nest_right"`
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// addCategoryContent adds the category HTML body (D-21). It must stay the last
|
||||
// migration so RollbackLast drops only this column and keeps the rows.
|
||||
var addCategoryContent = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202610090001_add_content_to_golem15_journal_categories",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE golem15_journal_categories ADD COLUMN IF NOT EXISTS content TEXT NULL`).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`ALTER TABLE golem15_journal_categories DROP COLUMN IF EXISTS content`).Error
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(addCategoryContent...)
|
||||
}
|
||||
82
updates/category_content_migration_test.go
Normal file
82
updates/category_content_migration_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
)
|
||||
|
||||
func TestCategoryContentMigration(t *testing.T) {
|
||||
sqlDB := dedicatedDB(t, fmt.Sprintf("journal_catcontent_%d", time.Now().UnixNano()))
|
||||
gdb, err := lagoon.Use(t.Context(), sqlDB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := gormigrate.New(gdb, &gormigrate.Options{
|
||||
TableName: "summer_migrations_summercms_cabana",
|
||||
UseTransaction: true,
|
||||
}, lagoon.BackendAdminMigrations)
|
||||
if err := backend.Migrate(); err != nil {
|
||||
t.Fatalf("backend_users migrations: %v", err)
|
||||
}
|
||||
m := journalMigrator(gdb)
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
all := All()
|
||||
if last := all[len(all)-1].ID; last != "202610090001_add_content_to_golem15_journal_categories" {
|
||||
t.Fatalf("last migration = %s, want 202610090001_add_content_to_golem15_journal_categories", last)
|
||||
}
|
||||
if !gdb.Migrator().HasColumn("golem15_journal_categories", "content") {
|
||||
t.Fatal("golem15_journal_categories.content missing")
|
||||
}
|
||||
assertUdt(t, gdb, "golem15_journal_categories", "content", "text")
|
||||
var nullable string
|
||||
if err := gdb.WithContext(t.Context()).Raw(
|
||||
`SELECT is_nullable FROM information_schema.columns WHERE table_name = 'golem15_journal_categories' AND column_name = 'content'`,
|
||||
).Scan(&nullable).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nullable != "YES" {
|
||||
t.Fatalf("content is_nullable = %q, want YES", nullable)
|
||||
}
|
||||
|
||||
if err := gdb.Exec(`UPDATE golem15_journal_categories SET content = '<p>x</p>' WHERE slug = 'uncategorized'`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatalf("rollback last: %v", err)
|
||||
}
|
||||
if !gdb.Migrator().HasTable("golem15_journal_categories") {
|
||||
t.Fatal("golem15_journal_categories dropped by the content rollback")
|
||||
}
|
||||
if gdb.Migrator().HasColumn("golem15_journal_categories", "content") {
|
||||
t.Fatal("content column survived rollback")
|
||||
}
|
||||
var rows int64
|
||||
if err := gdb.Raw(`SELECT COUNT(*) FROM golem15_journal_categories WHERE slug = 'uncategorized'`).Scan(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows != 1 {
|
||||
t.Fatalf("uncategorized rows after rollback = %d, want 1", rows)
|
||||
}
|
||||
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("re-migrate: %v", err)
|
||||
}
|
||||
if !gdb.Migrator().HasColumn("golem15_journal_categories", "content") {
|
||||
t.Fatal("content column missing after re-migrate")
|
||||
}
|
||||
var content sql.NullString
|
||||
if err := gdb.Raw(`SELECT content FROM golem15_journal_categories WHERE slug = 'uncategorized'`).Scan(&content).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content.Valid {
|
||||
t.Fatalf("uncategorized content after re-migrate = %q, want NULL", content.String)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user