Files
sm-menu-plugin/controllers/api/menus.go
Jakub Zych 33a65ad802 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
2026-10-12 17:51:27 +02:00

102 lines
3.2 KiB
Go

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