From c4750dcb643f26597fc4168aba5bab612955fedd Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Fri, 24 Apr 2026 18:29:37 +0200 Subject: [PATCH] httpgw: JSON API with structured parsing and consumer-facing endpoint filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - All responses use application/json; errors use {"error": "..."} - File reads parse content by format: key=value → object, tab-separated → arrays, newline list → []string, single value → {name: value} - /s/idx parsed into typed IdxEntry structs (id, state, cwd, backend, model) - POST for all writes (actions/updates); PUT removed - /s/new is POST-only (session creation) - /s/{id}/cfg is POST (partial update) - /s/{id}/chat is read-only - Internal paths blocked: /, /a, /p, /m, /t, /sk, /help, /tr, /u, /x, /tmp - Per-file blocked: systemprompt, statewait - /s returns array of session IDs only - Embedded OpenAPI 3.0 spec at /openapi.json - OPTIONS handler with accurate method descriptions Co-Authored-By: Claude Sonnet 4.6 --- main.go | 230 ++++++++++++++++++++++++++++++++++++++++----------- openapi.json | 200 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 381 insertions(+), 49 deletions(-) create mode 100644 openapi.json diff --git a/main.go b/main.go index 7c15f14..03f47b1 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ package main import ( + _ "embed" "encoding/json" "flag" "fmt" @@ -24,6 +25,9 @@ import ( "9fans.net/go/plan9/client" ) +//go:embed openapi.json +var openapiSpec []byte + var ( listen = flag.String("listen", ":8080", "HTTP listen address") service = flag.String("service", "ollie", "9P service name to connect to") @@ -33,6 +37,10 @@ var ( func main() { flag.Parse() + http.HandleFunc("/openapi.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(openapiSpec) + }) http.HandleFunc("/", handler) log.Printf("ollie-httpgw listening on %s", *listen) log.Fatal(http.ListenAndServe(*listen, nil)) @@ -79,38 +87,88 @@ func setStatHeaders(w http.ResponseWriter, d *plan9.Dir) { } } +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + func handler(w http.ResponseWriter, r *http.Request) { fsys, err := dial() if err != nil { - http.Error(w, "9p connect: "+err.Error(), http.StatusBadGateway) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "9p connect: " + err.Error()}) return } defer fsys.Close() p := strings.TrimPrefix(r.URL.Path, "/") + // Internal paths not exposed via HTTP. + first, _, _ := strings.Cut(p, "/") + switch first { + case "", "a", "p", "m", "t", "sk", "help", "tr", "u", "x", "tmp": + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + base := path.Base(p) + switch base { + case "systemprompt", "statewait": + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + case "new": + if r.Method == http.MethodPost { + handleWrite(w, r, fsys, p) + return + } + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + switch r.Method { case http.MethodGet: handleRead(w, fsys, p) case http.MethodHead: handleStat(w, fsys, p) - case http.MethodPut: - handleWrite(w, r, fsys, p) case http.MethodPost: - handleCreate(w, r, fsys, p) + if base == "chat" { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "chat is read-only"}) + return + } + handleWrite(w, r, fsys, p) case http.MethodDelete: handleRemove(w, fsys, p) case http.MethodPatch: handleWstat(w, r, fsys, p) + case http.MethodOptions: + handleOptions(w, r, p) default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) } } +var allowedMethods = "GET, HEAD, POST, DELETE, PATCH, OPTIONS" + +var methodDescriptions = map[string]string{ + "GET": "read file content or list directory entries", + "HEAD": "stat file (metadata in response headers)", + "POST": "write to file or trigger action", + "DELETE": "remove file or session", + "PATCH": "rename via JSON {\"name\": \"newname\"}", + "OPTIONS": "describe available operations for this path", +} + +func handleOptions(w http.ResponseWriter, _ *http.Request, p string) { + w.Header().Set("Allow", allowedMethods) + writeJSON(w, http.StatusOK, map[string]any{ + "path": "/" + p, + "methods": methodDescriptions, + }) +} + func handleRead(w http.ResponseWriter, fsys *client.Fsys, p string) { d, err := fsys.Stat(p) if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) return } setStatHeaders(w, d) @@ -118,92 +176,166 @@ func handleRead(w http.ResponseWriter, fsys *client.Fsys, p string) { if d.Mode&plan9.DMDIR != 0 { fid, err := fsys.Open(p, plan9.OREAD) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } defer fid.Close() dirs, err := fid.Dirreadall() if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if p == "s" { + var ids []string + for _, de := range dirs { + if de.Mode&plan9.DMDIR != 0 { + ids = append(ids, de.Name) + } + } + writeJSON(w, http.StatusOK, ids) return } entries := make([]dirEntry, len(dirs)) for i, de := range dirs { entries[i] = dirToEntry(de) } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(entries) + writeJSON(w, http.StatusOK, entries) return } fid, err := fsys.Open(p, plan9.OREAD) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } defer fid.Close() - w.Header().Set("Content-Type", "application/octet-stream") - io.Copy(w, fid) + content, err := io.ReadAll(fid) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if p == "s/idx" { + writeJSON(w, http.StatusOK, parseIdx(content)) + return + } + writeJSON(w, http.StatusOK, parseContent(content, path.Base(p))) +} + +type idxEntry struct { + ID string `json:"id"` + State string `json:"state"` + CWD string `json:"cwd"` + Backend string `json:"backend"` + Model string `json:"model"` +} + +func parseIdx(data []byte) []idxEntry { + var entries []idxEntry + for line := range strings.SplitSeq(strings.TrimRight(string(data), "\n"), "\n") { + if line == "" { + continue + } + fields := strings.SplitN(line, "\t", 5) + if len(fields) != 5 { + continue + } + entries = append(entries, idxEntry{ + ID: fields[0], + State: fields[1], + CWD: fields[2], + Backend: fields[3], + Model: fields[4], + }) + } + return entries +} + +// parseContent detects the wire format and returns the appropriate Go value: +// - key=value lines → map[string]string +// - tab-separated records → [][]string +// - multiple newline-separated values → []string +// - single value → map[string]string{name: value} +func parseContent(data []byte, name string) any { + s := strings.TrimRight(string(data), "\n") + if s == "" { + return map[string]string{} + } + first, _, _ := strings.Cut(s, "\n") + if strings.Contains(first, "=") { + m := make(map[string]string) + for line := range strings.SplitSeq(s, "\n") { + if line == "" { + continue + } + k, v, _ := strings.Cut(line, "=") + m[k] = v + } + return m + } + if strings.Contains(first, "\t") { + var rows [][]string + for line := range strings.SplitSeq(s, "\n") { + if line != "" { + rows = append(rows, strings.Split(line, "\t")) + } + } + return rows + } + var lines []string + for line := range strings.SplitSeq(s, "\n") { + if line != "" { + lines = append(lines, line) + } + } + if len(lines) == 1 { + return map[string]string{name: lines[0]} + } + return lines +} + +// serializeKV serializes a map to newline-separated key=value lines. +func serializeKV(m map[string]any) []byte { + var sb strings.Builder + for k, v := range m { + fmt.Fprintf(&sb, "%s=%v\n", k, v) + } + return []byte(sb.String()) } func handleStat(w http.ResponseWriter, fsys *client.Fsys, p string) { d, err := fsys.Stat(p) if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) return } setStatHeaders(w, d) + w.Header().Set("Content-Type", "application/json") } func handleWrite(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) { - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + var m map[string]any + if err := json.NewDecoder(r.Body).Decode(&m); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } fid, err := fsys.Open(p, plan9.OWRITE) if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) return } - n, err := fid.Write(body) + n, err := fid.Write(serializeKV(m)) fid.Close() if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]int{"bytes_written": n}) + writeJSON(w, http.StatusOK, map[string]int{"bytes_written": n}) } -func handleCreate(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) { - dir := path.Dir(p) - name := path.Base(p) - if dir == "." { - dir = "" - } - - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - fid, err := fsys.Create(path.Join(dir, name), plan9.OWRITE, 0644) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if len(body) > 0 { - fid.Write(body) - } - fid.Close() - w.WriteHeader(http.StatusCreated) -} func handleRemove(w http.ResponseWriter, fsys *client.Fsys, p string) { if err := fsys.Remove(p); err != nil { - http.Error(w, err.Error(), http.StatusNotFound) + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) @@ -214,12 +346,12 @@ func handleWstat(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p st Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } d := &plan9.Dir{Name: req.Name} if err := fsys.Wstat(p, d); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..4232876 --- /dev/null +++ b/openapi.json @@ -0,0 +1,200 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "ollie-httpgw", + "description": "HTTP gateway for the ollie 9P server.", + "version": "1.0.0" + }, + "paths": { + "/{path}": { + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "description": "9P file or directory path (may contain slashes for nested paths)", + "schema": { "type": "string" }, + "example": "s/idx" + } + ], + "get": { + "summary": "Read file or list directory", + "description": "Returns parsed file content as a JSON object or array, or a directory listing.", + "responses": { + "200": { + "description": "File content or directory listing", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { "title": "Key-value file", "type": "object", "additionalProperties": { "type": "string" } }, + { "title": "List file", "type": "array", "items": { "type": "string" } }, + { "title": "Session index", "type": "array", "items": { "$ref": "#/components/schemas/IdxEntry" } }, + { "title": "Directory", "type": "array", "items": { "$ref": "#/components/schemas/DirEntry" } } + ] + } + } + } + }, + "404": { "$ref": "#/components/responses/Error" }, + "502": { "$ref": "#/components/responses/Error" } + } + }, + "head": { + "summary": "Stat file or directory", + "description": "Returns 9P metadata in response headers. No body.", + "responses": { + "200": { + "description": "Stat headers set", + "headers": { "$ref": "#/components/headers/StatHeaders" } + }, + "404": { "$ref": "#/components/responses/Error" } + } + }, + "post": { + "summary": "Write to file or trigger action", + "description": "Writes a JSON object to a file, serialized as key=value lines. Used for session creation (POST /s/new), config updates (POST /s/{id}/cfg), and control actions (POST /s/{id}/ctl, /s/{id}/prompt, /s/{id}/fifo.in).", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "description": "Bytes written", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bytes_written": { "type": "integer" } + }, + "required": ["bytes_written"] + } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" }, + "405": { "$ref": "#/components/responses/Error" }, + "500": { "$ref": "#/components/responses/Error" } + } + }, + "delete": { + "summary": "Remove file or session", + "description": "Removes the file or kills the session at the given path.", + "responses": { + "204": { "description": "Removed" }, + "404": { "$ref": "#/components/responses/Error" } + } + }, + "patch": { + "summary": "Rename file or session", + "description": "Renames the file or session via 9P wstat.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "New name" } + }, + "required": ["name"] + } + } + } + }, + "responses": { + "204": { "description": "Renamed" }, + "400": { "$ref": "#/components/responses/Error" }, + "500": { "$ref": "#/components/responses/Error" } + } + }, + "options": { + "summary": "Describe available operations", + "description": "Returns the allowed HTTP methods and a description of each operation for this path.", + "responses": { + "200": { + "description": "Available methods", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/OptionsResponse" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "IdxEntry": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "state": { "type": "string" }, + "cwd": { "type": "string" }, + "backend": { "type": "string" }, + "model": { "type": "string" } + }, + "required": ["id", "state", "cwd", "backend", "model"] + }, + "DirEntry": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "size": { "type": "integer", "format": "uint64" }, + "mode": { "type": "integer", "format": "uint32" }, + "mtime": { "type": "integer", "format": "uint32" }, + "is_dir": { "type": "boolean" } + }, + "required": ["name", "size", "mode", "mtime", "is_dir"] + }, + "Error": { + "type": "object", + "properties": { + "error": { "type": "string" } + }, + "required": ["error"] + }, + "OptionsResponse": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "methods": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "required": ["path", "methods"] + } + }, + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + }, + "headers": { + "StatHeaders": { + "description": "9P stat fields returned as response headers", + "X-9p-Name": { "schema": { "type": "string" } }, + "X-9p-Size": { "schema": { "type": "integer" } }, + "X-9p-Mode": { "schema": { "type": "string" } }, + "X-9p-Mtime": { "schema": { "type": "integer" } }, + "X-9p-Type": { "schema": { "type": "string", "enum": ["file", "dir"] } } + } + } + } +}