- GET /_faq/api/v1/faqs/{slug}?locale=xx, anonymous, faq-public-api bucket (120/min per client IP)
- locale by enabled code or the default locale; active row, lowest id among duplicates
- description and answers passed through seaglass.Sanitize on output; 404 {"error":"FAQ not found"}
- TestFaqAPISmoke; README with API, CORS paths, import workflow and Winter adaptations
- go mod tidy records the test dependencies
149 lines
4.5 KiB
Go
149 lines
4.5 KiB
Go
// Package api holds the FAQ plugin's public read API.
|
|
package api
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"regexp"
|
|
|
|
"git.golem15.com/golem15/sm-faq-plugin/models"
|
|
translate "git.golem15.com/golem15/sm-translate-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/seaglass"
|
|
"git.golem15.com/golem15/summercms/modules/wire"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// slugPattern is the shape of a FAQ slug the API serves: lowercase letters,
|
|
// digits and dashes, starting with a letter or digit. Anything else is the
|
|
// JSON 404, so every miss has the same body.
|
|
var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9\-]*$`)
|
|
|
|
const errNotFound = "FAQ not found"
|
|
|
|
// Question is one row of a FAQ's content: a plain-text question and its
|
|
// answer as sanitized HTML.
|
|
type Question struct {
|
|
Question string `json:"question"`
|
|
Answer string `json:"answer"`
|
|
}
|
|
|
|
// FAQ is the data object of GET /_faq/api/v1/faqs/{slug}.
|
|
type FAQ struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Locale string `json:"locale"`
|
|
Description *string `json:"description"`
|
|
Content []Question `json:"content"`
|
|
}
|
|
|
|
// Show is GET /_faq/api/v1/faqs/{slug}?locale=xx, anonymous. The locale is
|
|
// the enabled translate locale with code ?locale, else the default locale.
|
|
// It serves the active FAQ with that slug and locale; when several rows
|
|
// share them, the one with the lowest id. The description and every answer
|
|
// are passed through seaglass.Sanitize on output, so rows that never went
|
|
// through the admin save (imports, SQL copies) are served clean too.
|
|
// Questions are plain strings. An unknown slug, an inactive row and an
|
|
// unknown or disabled locale are 404 {"error":"FAQ not found"}.
|
|
func Show(app *backpack.App) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
slug := r.PathValue("slug")
|
|
if !slugPattern.MatchString(slug) {
|
|
writeError(w, http.StatusNotFound, errNotFound)
|
|
return
|
|
}
|
|
db, err := gdb(app)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
db = db.WithContext(r.Context())
|
|
locale, err := findLocale(db, r.URL.Query().Get("locale"))
|
|
if err != nil {
|
|
slog.Default().Error("faq api: locale", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
if locale == nil {
|
|
writeError(w, http.StatusNotFound, errNotFound)
|
|
return
|
|
}
|
|
var rows []models.FAQ
|
|
if err := db.Where("slug = ? AND locale_id = ? AND is_active = ?", slug, locale.ID, true).
|
|
Order("id ASC").Limit(1).Find(&rows).Error; err != nil {
|
|
slog.Default().Error("faq api: load faq", "slug", slug, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
if len(rows) == 0 {
|
|
writeError(w, http.StatusNotFound, errNotFound)
|
|
return
|
|
}
|
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serialize(rows[0], locale.Code)})
|
|
}
|
|
}
|
|
|
|
// findLocale returns the enabled locale with code, or the enabled default
|
|
// locale when code is empty; nil when there is none.
|
|
func findLocale(db *gorm.DB, code string) (*translate.Locale, error) {
|
|
q := db.Where("is_enabled = ?", true)
|
|
if code != "" {
|
|
q = q.Where("code = ?", code)
|
|
} else {
|
|
q = q.Where("is_default = ?", true)
|
|
}
|
|
var locales []translate.Locale
|
|
if err := q.Order("id ASC").Limit(1).Find(&locales).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(locales) == 0 {
|
|
return nil, nil
|
|
}
|
|
return &locales[0], nil
|
|
}
|
|
|
|
func serialize(f models.FAQ, locale string) FAQ {
|
|
out := FAQ{Name: f.Name, Slug: f.Slug, Locale: locale, Content: []Question{}}
|
|
if f.Description != nil {
|
|
clean := seaglass.Sanitize(*f.Description)
|
|
out.Description = &clean
|
|
}
|
|
for _, row := range f.Items() {
|
|
out.Content = append(out.Content, Question{
|
|
Question: text(row["question"]),
|
|
Answer: seaglass.Sanitize(text(row["answer"])),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// text is a stored repeater value as a string: "" for null, the value for a
|
|
// string, its formatting otherwise (PHP rows may hold numbers).
|
|
func text(v any) string {
|
|
switch s := v.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return s
|
|
default:
|
|
return fmt.Sprint(s)
|
|
}
|
|
}
|
|
|
|
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("faq api: application is not configured")
|
|
}
|
|
db, ok := app.Lookup[*gorm.DB]()
|
|
if !ok || db == nil {
|
|
return nil, errors.New("faq api: database is not configured")
|
|
}
|
|
return db, nil
|
|
}
|