feat(17-04): public menu API with registry resolution, throttle bucket and README
- GET /_menu/api/v1/menus/{code}?locale=xx, anonymous, menu-public-api bucket (120/min per client IP)
- classes.BuildTree: published entries nested by parent, sort_order then id, scheme-checked URLs
- registry entries resolved through signpost with url_pattern as the item override and the locale's alternate
- list types keep stored URL and title, generated children follow stored ones; unresolvable entries left out
- TestMenuAPISmoke; README with API, CORS paths, import workflow and Winter adaptations
- go mod tidy records the test dependencies
This commit is contained in:
227
classes/tree.go
Normal file
227
classes/tree.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"git.golem15.com/golem15/sm-menu-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// Item is one entry of the public menu tree (GET /_menu/api/v1/menus/{code}).
|
||||
// ID is the entry id, nil for a child a registry type generated. Type is the
|
||||
// stored entry type; a generated child carries its parent's registry type.
|
||||
// URL is nil for dividers and for links that fail AllowedURL. Target is
|
||||
// _self, _blank or nil. CSSClass, Icon and Code are nil when empty. Items is
|
||||
// never nil, so it encodes as [].
|
||||
type Item struct {
|
||||
ID *uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
URL *string `json:"url"`
|
||||
Target *string `json:"target"`
|
||||
CSSClass *string `json:"css_class"`
|
||||
Icon *string `json:"icon"`
|
||||
Code *string `json:"code"`
|
||||
Items []Item `json:"items"`
|
||||
}
|
||||
|
||||
// Resolver is the part of the framework's menu item type registry the tree
|
||||
// reads; *signpost.Registry implements it.
|
||||
type Resolver interface {
|
||||
Type(key string) (pact.MenuItemType, bool)
|
||||
Resolve(ctx context.Context, key, reference, itemPattern string, nesting bool, unprefixedLocale string) (*pact.ResolvedMenuItem, error)
|
||||
}
|
||||
|
||||
// TreeOptions are the request inputs of BuildTree.
|
||||
type TreeOptions struct {
|
||||
// Registry resolves registry entries; nil leaves every registry entry
|
||||
// out.
|
||||
Registry Resolver
|
||||
// Locale picks the alternate of a resolved item whose locale matches;
|
||||
// "" or a locale without an alternate keeps the resolved URL and title.
|
||||
Locale string
|
||||
// Logger records entries left out; nil uses slog.Default.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// BuildTree nests a menu's entries under their parents. entries must be the
|
||||
// published entries of one menu ordered by sort_order then id; that order
|
||||
// is kept within each parent. An entry whose parent is not among entries
|
||||
// (unpublished or missing) is unreachable and left out with its subtree.
|
||||
//
|
||||
// Built-in types: a url entry serves its stored URL when AllowedURL accepts
|
||||
// it (else nil), a divider a nil URL. A registry entry is resolved through
|
||||
// opts.Registry with its reference, its url_pattern as the item override and
|
||||
// its type's Nesting. When the resolved item has a URL (a single type), the
|
||||
// entry serves the URL of the alternate for opts.Locale, else the resolved
|
||||
// URL, and its stored title when not empty, else the resolved title for the
|
||||
// locale. When the resolved item has no URL (a list type), the entry keeps
|
||||
// its stored URL (scheme-checked) and title. The resolved sub-items follow
|
||||
// the stored children in Items as generated children. An entry whose type is
|
||||
// not registered or whose reference no longer resolves is left out with its
|
||||
// subtree and logged.
|
||||
func BuildTree(ctx context.Context, entries []models.MenuEntry, opts TreeOptions) []Item {
|
||||
b := treeBuilder{ctx: ctx, opts: opts, children: map[uint][]*models.MenuEntry{}, seen: map[uint]bool{}}
|
||||
if b.ctx == nil {
|
||||
b.ctx = context.Background()
|
||||
}
|
||||
if b.opts.Logger == nil {
|
||||
b.opts.Logger = slog.Default()
|
||||
}
|
||||
var roots []*models.MenuEntry
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
if e.ParentID == nil || *e.ParentID == 0 {
|
||||
roots = append(roots, e)
|
||||
continue
|
||||
}
|
||||
b.children[*e.ParentID] = append(b.children[*e.ParentID], e)
|
||||
}
|
||||
return b.items(roots)
|
||||
}
|
||||
|
||||
type treeBuilder struct {
|
||||
ctx context.Context
|
||||
opts TreeOptions
|
||||
children map[uint][]*models.MenuEntry
|
||||
seen map[uint]bool
|
||||
}
|
||||
|
||||
func (b *treeBuilder) items(entries []*models.MenuEntry) []Item {
|
||||
out := []Item{}
|
||||
for _, e := range entries {
|
||||
if b.seen[e.ID] {
|
||||
continue
|
||||
}
|
||||
b.seen[e.ID] = true
|
||||
if item, ok := b.item(e); ok {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *treeBuilder) item(e *models.MenuEntry) (Item, bool) {
|
||||
id := e.ID
|
||||
item := Item{
|
||||
ID: &id,
|
||||
Title: models.Text(e.Title),
|
||||
Type: e.Type,
|
||||
Target: target(e.Target),
|
||||
CSSClass: optional(e.CSSClass),
|
||||
Icon: optional(e.Icon),
|
||||
Code: optional(e.Code),
|
||||
}
|
||||
var generated []Item
|
||||
switch e.Type {
|
||||
case models.TypeURL:
|
||||
item.URL = allowed(models.Text(e.URL))
|
||||
case models.TypeDivider:
|
||||
default:
|
||||
resolved, ok := b.resolve(e)
|
||||
if !ok {
|
||||
return Item{}, false
|
||||
}
|
||||
if resolved.URL == "" {
|
||||
item.URL = allowed(models.Text(e.URL))
|
||||
} else {
|
||||
url, title := pick(resolved, b.opts.Locale)
|
||||
item.URL = allowed(url)
|
||||
if item.Title == "" {
|
||||
item.Title = title
|
||||
}
|
||||
}
|
||||
generated = b.generated(e.Type, resolved.Items)
|
||||
}
|
||||
item.Items = append(b.items(b.children[e.ID]), generated...)
|
||||
return item, true
|
||||
}
|
||||
|
||||
// resolve resolves a registry entry; false leaves it out.
|
||||
func (b *treeBuilder) resolve(e *models.MenuEntry) (*pact.ResolvedMenuItem, bool) {
|
||||
reg := b.opts.Registry
|
||||
if reg == nil {
|
||||
b.opts.Logger.Warn("menu: entry left out: no menu item type registry", "entry", e.ID, "type", e.Type)
|
||||
return nil, false
|
||||
}
|
||||
t, ok := reg.Type(e.Type)
|
||||
if !ok {
|
||||
b.opts.Logger.Warn("menu: entry left out: menu item type is not registered", "entry", e.ID, "type", e.Type)
|
||||
return nil, false
|
||||
}
|
||||
resolved, err := reg.Resolve(b.ctx, e.Type, models.Text(e.Reference), models.Text(e.URLPattern), t.Nesting, "")
|
||||
if err != nil {
|
||||
b.opts.Logger.Warn("menu: entry left out: resolve failed", "entry", e.ID, "type", e.Type, "reference", models.Text(e.Reference), "error", err)
|
||||
return nil, false
|
||||
}
|
||||
if resolved == nil {
|
||||
b.opts.Logger.Warn("menu: entry left out: reference does not resolve", "entry", e.ID, "type", e.Type, "reference", models.Text(e.Reference))
|
||||
return nil, false
|
||||
}
|
||||
return resolved, true
|
||||
}
|
||||
|
||||
// generated turns resolved sub-items into children without an id, carrying
|
||||
// the parent's registry type.
|
||||
func (b *treeBuilder) generated(typ string, resolved []pact.ResolvedMenuItem) []Item {
|
||||
out := make([]Item, 0, len(resolved))
|
||||
for i := range resolved {
|
||||
url, title := pick(&resolved[i], b.opts.Locale)
|
||||
out = append(out, Item{
|
||||
Title: title,
|
||||
Type: typ,
|
||||
URL: allowed(url),
|
||||
Items: b.generated(typ, resolved[i].Items),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pick returns the URL and title of the alternate for locale, falling back
|
||||
// to the resolved URL and title for a missing locale or empty values.
|
||||
func pick(r *pact.ResolvedMenuItem, locale string) (url, title string) {
|
||||
url, title = r.URL, r.Title
|
||||
if locale == "" {
|
||||
return url, title
|
||||
}
|
||||
for _, alt := range r.Alternates {
|
||||
if alt.Locale != locale {
|
||||
continue
|
||||
}
|
||||
if alt.URL != "" {
|
||||
url = alt.URL
|
||||
}
|
||||
if alt.Title != "" {
|
||||
title = alt.Title
|
||||
}
|
||||
break
|
||||
}
|
||||
return url, title
|
||||
}
|
||||
|
||||
// allowed is url when it is not empty and AllowedURL accepts it, else nil.
|
||||
func allowed(url string) *string {
|
||||
if url == "" || !AllowedURL(url) {
|
||||
return nil
|
||||
}
|
||||
return &url
|
||||
}
|
||||
|
||||
// target is the stored target when it is _self or _blank, else nil.
|
||||
func target(s *string) *string {
|
||||
v := models.Text(s)
|
||||
if v == "" || !AllowedTarget(v) {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// optional is the stored value, nil when null or empty.
|
||||
func optional(s *string) *string {
|
||||
if s == nil || *s == "" {
|
||||
return nil
|
||||
}
|
||||
v := *s
|
||||
return &v
|
||||
}
|
||||
Reference in New Issue
Block a user