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:
101
controllers/api/menus.go
Normal file
101
controllers/api/menus.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Package api holds the menu plugin's public read API.
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"git.golem15.com/golem15/sm-menu-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-menu-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// codePattern is the shape of a menu code the API serves: letters, digits,
|
||||
// dashes and underscores. Anything else is the JSON 404, so every miss has
|
||||
// the same body.
|
||||
var codePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
// Menu is the data object of GET /_menu/api/v1/menus/{code}.
|
||||
type Menu struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
CSSClass *string `json:"css_class"`
|
||||
Items []classes.Item `json:"items"`
|
||||
}
|
||||
|
||||
// Show is GET /_menu/api/v1/menus/{code}?locale=xx, anonymous. It serves the
|
||||
// active menu with code and its published entries as a tree
|
||||
// (classes.BuildTree), resolving registry entries through the framework's
|
||||
// menu item type registry for the requested locale. An unknown or inactive
|
||||
// menu is 404 {"error":"Menu not found"}.
|
||||
func Show(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
if !codePattern.MatchString(code) {
|
||||
writeError(w, http.StatusNotFound, "Menu not found")
|
||||
return
|
||||
}
|
||||
db, err := gdb(app)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
db = db.WithContext(r.Context())
|
||||
var menus []models.Menu
|
||||
if err := db.Where("code = ? AND is_active = ?", code, true).Order("id ASC").Limit(1).Find(&menus).Error; err != nil {
|
||||
slog.Default().Error("menu api: load menu", "code", code, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
if len(menus) == 0 {
|
||||
writeError(w, http.StatusNotFound, "Menu not found")
|
||||
return
|
||||
}
|
||||
menu := menus[0]
|
||||
var entries []models.MenuEntry
|
||||
if err := db.Where("menu_id = ? AND is_published = ?", menu.ID, true).
|
||||
Order("sort_order ASC NULLS LAST, id ASC").Find(&entries).Error; err != nil {
|
||||
slog.Default().Error("menu api: load entries", "code", code, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
var registry classes.Resolver
|
||||
if reg, ok := app.Lookup[*signpost.Registry](); ok && reg != nil {
|
||||
registry = reg
|
||||
}
|
||||
items := classes.BuildTree(r.Context(), entries, classes.TreeOptions{
|
||||
Registry: registry,
|
||||
Locale: r.URL.Query().Get("locale"),
|
||||
})
|
||||
var css *string
|
||||
if menu.CSSClass != nil && *menu.CSSClass != "" {
|
||||
css = menu.CSSClass
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": Menu{
|
||||
Code: menu.Code,
|
||||
Name: menu.Name,
|
||||
CSSClass: css,
|
||||
Items: items,
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
wire.WriteJSON(w, status, map[string]any{"error": message})
|
||||
}
|
||||
|
||||
func gdb(app *backpack.App) (*gorm.DB, error) {
|
||||
if app == nil {
|
||||
return nil, errors.New("menu api: application is not configured")
|
||||
}
|
||||
db, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || db == nil {
|
||||
return nil, errors.New("menu api: database is not configured")
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
Reference in New Issue
Block a user