aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/api_entity.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 21:44:49 -0400
committerhistoria <[not public]>2026-09-18 21:44:49 -0400
commite7848ea79a9a57fdf391d30c48ce649f3d2f6180 (patch)
treea2972facd5bbe88006f40ff96a1eeadd570ac15b /internal/admin/api_entity.go
parenta8a67482bbff8297a96adff65d2fd7029ed998cc (diff)
downloadthehouseoficarus-e7848ea79a9a57fdf391d30c48ce649f3d2f6180.tar.gz
refactor: dedup admin entity handlers and server construction
Diffstat (limited to 'internal/admin/api_entity.go')
-rw-r--r--internal/admin/api_entity.go343
1 files changed, 343 insertions, 0 deletions
diff --git a/internal/admin/api_entity.go b/internal/admin/api_entity.go
new file mode 100644
index 0000000..b32ab89
--- /dev/null
+++ b/internal/admin/api_entity.go
@@ -0,0 +1,343 @@
+package admin
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// The editor exposes the same list/get/create/update/delete contract for
+// several YAML families. entityCRUD captures everything that actually
+// differed between the hand-written copies; the handlers below are shared by
+// every family.
+//
+// Two wire styles predate this file and must be preserved (see failJSON's
+// contract comment):
+//
+// - status style (items, drops, mobs, objects, hazards): errors are HTTP
+// error statuses with a JSON body; a successful write echoes the entity
+// map (including _raw).
+// - body style (techs, modules): errors are a 200 JSON body read as
+// res.error; writes reply {"ok":true,"id":id}. These editors also upsert
+// (POST/PUT by id creates an absent file).
+type entityCRUD struct {
+ route string // URL segment and data subdirectory, e.g. "items"
+ noun string // singular for error text and undo descriptions
+ searchSubdirs bool // look the entity up in nested directories too
+ upsert bool // by-ID POST/PUT creates an absent file
+ bodyErr bool // write errors as a 200 JSON body (failJSON)
+ okResponse bool // reply {"ok":true,"id":id} and "Created/Updated"
+ refresh func(*AdminServer)
+}
+
+var (
+ itemSpec = entityCRUD{route: "items", noun: "item", searchSubdirs: true,
+ refresh: func(s *AdminServer) { s.invalidateEntityCaches("items") }}
+ dropSpec = entityCRUD{route: "drops", noun: "drop",
+ refresh: func(s *AdminServer) { s.invalidateEntityCaches("drops") }}
+ mobSpec = entityCRUD{route: "mobs", noun: "mob", searchSubdirs: true,
+ refresh: func(s *AdminServer) { s.invalidateEntityCaches("mobs") }}
+ objectSpec = entityCRUD{route: "objects", noun: "object", searchSubdirs: true}
+ hazardSpec = entityCRUD{route: "hazards", noun: "hazard",
+ refresh: func(s *AdminServer) { s.world.ClearHazardCache() }}
+ techSpec = entityCRUD{route: "techs", noun: "tech", searchSubdirs: true, upsert: true, bodyErr: true, okResponse: true}
+ moduleSpec = entityCRUD{route: "modules", noun: "module", searchSubdirs: true, upsert: true, bodyErr: true, okResponse: true}
+)
+
+func (c entityCRUD) find(s *AdminServer, id string) ([]byte, string, error) {
+ if c.searchSubdirs {
+ return findYAMLFileInSubdirs(s.dataDir, c.route, id)
+ }
+ return readYAMLFile(s.dataDir, c.route, id)
+}
+
+// fail writes an entity error in the family's wire style. status is ignored
+// by the body style, whose clients read the error out of a 200.
+func (c entityCRUD) fail(w http.ResponseWriter, status int, msg string) {
+ if c.bodyErr {
+ failJSON(w, msg)
+ return
+ }
+ writeJSONError(w, msg, status)
+}
+
+func (c entityCRUD) invalidate(s *AdminServer) {
+ if c.refresh != nil {
+ c.refresh(s)
+ }
+}
+
+func (c entityCRUD) invalidID() string {
+ if c.bodyErr {
+ return "invalid id"
+ }
+ return "invalid " + c.noun + " id"
+}
+
+func (c entityCRUD) missingID() string {
+ if c.bodyErr {
+ return "missing id"
+ }
+ return "missing " + c.noun + " id"
+}
+
+func (c entityCRUD) notFound(id string) string {
+ if c.bodyErr {
+ return "not found: " + id
+ }
+ return c.noun + " not found"
+}
+
+func (c entityCRUD) jsonErr(err error) string {
+ if c.bodyErr {
+ return "invalid JSON: " + err.Error()
+ }
+ return "invalid json body"
+}
+
+func (c entityCRUD) parseErr(err error) string {
+ if c.bodyErr {
+ return "parse error: " + err.Error()
+ }
+ return "failed to parse " + c.noun + " yaml"
+}
+
+func (c entityCRUD) writeErr(err error) string {
+ if c.bodyErr {
+ return "write error: " + err.Error()
+ }
+ return "failed to write " + c.noun
+}
+
+func (c entityCRUD) readErr(err error) string {
+ if c.bodyErr {
+ return "read existing file: " + err.Error()
+ }
+ return "failed to read existing " + c.noun
+}
+
+func (c entityCRUD) removeErr(err error) string {
+ if c.bodyErr {
+ return "delete error: " + err.Error()
+ }
+ return "failed to delete " + c.noun
+}
+
+func (c entityCRUD) listErr(err error) string {
+ if c.bodyErr {
+ return err.Error()
+ }
+ return "failed to list " + c.route
+}
+
+// desc names an undo entry. The body style uses past-tense verbs ("Created
+// tech x"); the status style uses imperative ones ("Create item x").
+func (c entityCRUD) desc(verb, okVerb, id string) string {
+ if c.okResponse {
+ return okVerb + " " + c.noun + " " + id
+ }
+ return verb + " " + c.noun + " " + id
+}
+
+// handleEntityList serves GET (tree) and POST (create) for one family.
+func (s *AdminServer) handleEntityList(c entityCRUD) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ tree, err := listYAMLTree(s.dataDir, c.route)
+ if err != nil {
+ c.fail(w, http.StatusInternalServerError, c.listErr(err))
+ return
+ }
+ if tree == nil {
+ tree = &YAMLTree{Root: []string{}, Dirs: map[string][]string{}}
+ }
+ writeJSON(w, tree)
+ case http.MethodPost:
+ s.entityCreate(w, r, c)
+ default:
+ methodNotAllowed(w)
+ }
+ }
+}
+
+// handleEntityByID serves GET/PUT/POST(upsert)/DELETE for one family.
+func (s *AdminServer) handleEntityByID(c entityCRUD) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := strings.TrimPrefix(r.URL.Path, "/api/"+c.route+"/")
+ if id == "" {
+ c.fail(w, http.StatusBadRequest, c.missingID())
+ return
+ }
+ if !validEntityID(id) {
+ c.fail(w, http.StatusBadRequest, c.invalidID())
+ return
+ }
+ switch r.Method {
+ case http.MethodGet:
+ s.entityGet(w, id, c)
+ case http.MethodPut:
+ s.entityUpdate(w, r, id, c)
+ case http.MethodPost:
+ if !c.upsert {
+ methodNotAllowed(w)
+ return
+ }
+ s.entityUpdate(w, r, id, c)
+ case http.MethodDelete:
+ s.entityDelete(w, id, c)
+ default:
+ methodNotAllowed(w)
+ }
+ }
+}
+
+func (s *AdminServer) entityGet(w http.ResponseWriter, id string, c entityCRUD) {
+ data, path, err := c.find(s, id)
+ if err != nil {
+ c.fail(w, http.StatusNotFound, c.notFound(id))
+ return
+ }
+ m, err := yamlToMap(data)
+ if err != nil {
+ c.fail(w, http.StatusInternalServerError, c.parseErr(err))
+ return
+ }
+ if c.okResponse {
+ // The body style strips the id before writing, so echo it from the
+ // filename; the status style already writes it into the file.
+ m["id"] = id
+ }
+ m["_path"] = path
+ m["_raw"] = string(data)
+ writeJSON(w, m)
+}
+
+func (s *AdminServer) entityCreate(w http.ResponseWriter, r *http.Request, c entityCRUD) {
+ var m map[string]any
+ if err := readJSON(r, &m); err != nil {
+ c.fail(w, http.StatusBadRequest, c.jsonErr(err))
+ return
+ }
+ id, ok := m["id"].(string)
+ if !ok || strings.TrimSpace(id) == "" {
+ c.fail(w, http.StatusBadRequest, c.missingID())
+ return
+ }
+ if !validEntityID(id) {
+ c.fail(w, http.StatusBadRequest, c.invalidID())
+ return
+ }
+ if _, _, err := c.find(s, id); err == nil {
+ c.fail(w, http.StatusConflict, c.noun+" already exists")
+ return
+ }
+ if c.okResponse {
+ delete(m, "id")
+ }
+ path := filepath.Join(s.dataDir, c.route, id+".yaml")
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ c.fail(w, http.StatusInternalServerError, c.writeErr(err))
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: c.desc("Create", "Created", id),
+ FilePath: path,
+ NewContent: newContent,
+ IsCreate: true,
+ })
+ c.invalidate(s)
+ if c.okResponse {
+ writeJSON(w, map[string]any{"ok": true, "id": id})
+ return
+ }
+ writeJSON(w, m)
+}
+
+func (s *AdminServer) entityUpdate(w http.ResponseWriter, r *http.Request, id string, c entityCRUD) {
+ var m map[string]any
+ if err := readJSON(r, &m); err != nil {
+ c.fail(w, http.StatusBadRequest, c.jsonErr(err))
+ return
+ }
+ _, path, findErr := c.find(s, id)
+ isCreate := findErr != nil
+ if isCreate && !c.upsert {
+ c.fail(w, http.StatusNotFound, c.notFound(id))
+ return
+ }
+ if isCreate {
+ path = filepath.Join(s.dataDir, c.route, id+".yaml")
+ }
+ var oldContent []byte
+ if !isCreate {
+ var err error
+ oldContent, err = snapshotFile(path)
+ if err != nil {
+ c.fail(w, http.StatusInternalServerError, c.readErr(err))
+ return
+ }
+ }
+ // _path/_raw are editor-only metadata and never belong in the file; the
+ // status style also stores the id, while the body style derives it from
+ // the filename.
+ delete(m, "_path")
+ delete(m, "_raw")
+ if c.okResponse {
+ delete(m, "id")
+ } else {
+ m["id"] = id
+ }
+ newContent, err := writeMapAsYAML(path, m)
+ if err != nil {
+ c.fail(w, http.StatusInternalServerError, c.writeErr(err))
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: c.desc("Update", "Updated", id),
+ FilePath: path,
+ OldContent: oldContent,
+ NewContent: newContent,
+ IsCreate: isCreate,
+ })
+ c.invalidate(s)
+ if c.okResponse {
+ writeJSON(w, map[string]any{"ok": true, "id": id})
+ return
+ }
+ m["_raw"] = string(newContent)
+ writeJSON(w, m)
+}
+
+func (s *AdminServer) entityDelete(w http.ResponseWriter, id string, c entityCRUD) {
+ _, path, err := c.find(s, id)
+ if err != nil {
+ c.fail(w, http.StatusNotFound, c.notFound(id))
+ return
+ }
+ oldContent := backupFile(path)
+ if err := os.Remove(path); err != nil {
+ c.fail(w, http.StatusInternalServerError, c.removeErr(err))
+ return
+ }
+ s.undoStack.Push(ChangeDesc{
+ Description: c.desc("Delete", "Deleted", id),
+ FilePath: path,
+ OldContent: oldContent,
+ IsDelete: true,
+ })
+ c.invalidate(s)
+ if c.okResponse {
+ writeJSON(w, map[string]any{"ok": true, "id": id})
+ return
+ }
+ writeJSON(w, map[string]any{"deleted": id})
+}
+
+// methodNotAllowed writes the shared 405 body. The admin JS throws on any
+// non-200, so the JSON form is safe for both wire styles.
+func methodNotAllowed(w http.ResponseWriter) {
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+}