From e7848ea79a9a57fdf391d30c48ce649f3d2f6180 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 21:44:49 -0400 Subject: refactor: dedup admin entity handlers and server construction --- internal/admin/api_entity.go | 343 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 internal/admin/api_entity.go (limited to 'internal/admin/api_entity.go') 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) +} -- cgit v1.2.3