httpgw: JSON API with structured parsing and consumer-facing endpoint filtering
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
ac79ebf733
commit
c4750dcb64
230
main.go
230
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)
|
||||
|
|
|
|||
|
|
@ -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"] } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue