From e56f6c63f601950421322d1374e3e058efcbd38c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 6 Jun 2026 16:22:08 +0200 Subject: [PATCH] fix(api-docs): target the panel base path in OpenAPI servers ServeOpenAPISpec shipped servers:[{url:"/"}], so Swagger UI "Try it out" and external generators hit the origin root and ignored a non-root webBasePath. Inject the runtime base path into the single servers entry at serve time, touching only that field via json.RawMessage so the rest of the spec is preserved verbatim. --- web/controller/dist.go | 33 +++++++++++++++++++++++++++++ web/controller/dist_test.go | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 web/controller/dist_test.go diff --git a/web/controller/dist.go b/web/controller/dist.go index e8779151..e3b845f3 100644 --- a/web/controller/dist.go +++ b/web/controller/dist.go @@ -3,6 +3,7 @@ package controller import ( "bytes" "embed" + "encoding/json" htmlpkg "html" "net/http" "strings" @@ -34,10 +35,42 @@ func ServeOpenAPISpec(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"}) return } + + // The embedded spec ships with `servers: [{url: "/"}]`. When the panel runs + // under a non-root web base path, Swagger UI "Try it out" and external + // generators must target that prefix, so rewrite the single server entry to + // the runtime base path before serving. + if basePath := c.GetString("base_path"); basePath != "" && basePath != "/" { + if rebuilt, err := withServerBasePath(body, basePath); err != nil { + logger.Warning("openapi.json: could not inject base path:", err) + } else { + body = rebuilt + } + } + c.Header("Cache-Control", "public, max-age=300") c.Data(http.StatusOK, "application/json; charset=utf-8", body) } +// withServerBasePath rewrites the spec's `servers` entry so requests target the +// panel's configured web base path. Only the top-level `servers` field is +// replaced; every other field is preserved verbatim via json.RawMessage. +func withServerBasePath(spec []byte, basePath string) ([]byte, error) { + var doc map[string]json.RawMessage + if err := json.Unmarshal(spec, &doc); err != nil { + return nil, err + } + servers, err := json.Marshal([]map[string]string{{ + "url": strings.TrimSuffix(basePath, "/"), + "description": "Current panel", + }}) + if err != nil { + return nil, err + } + doc["servers"] = servers + return json.Marshal(doc) +} + func serveDistPage(c *gin.Context, name string) { body, err := distFS.ReadFile("dist/" + name) if err != nil { diff --git a/web/controller/dist_test.go b/web/controller/dist_test.go new file mode 100644 index 00000000..ad7bc73b --- /dev/null +++ b/web/controller/dist_test.go @@ -0,0 +1,42 @@ +package controller + +import ( + "encoding/json" + "testing" +) + +func TestWithServerBasePath(t *testing.T) { + spec := []byte(`{"openapi":"3.0.3","info":{"title":"x"},"servers":[{"url":"/","description":"old"}],"paths":{"/p":{"get":{"summary":"s"}}}}`) + + out, err := withServerBasePath(spec, "/test/") + if err != nil { + t.Fatalf("withServerBasePath: %v", err) + } + + var doc map[string]any + if err := json.Unmarshal(out, &doc); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + servers, ok := doc["servers"].([]any) + if !ok || len(servers) != 1 { + t.Fatalf("servers = %v, want one entry", doc["servers"]) + } + srv, _ := servers[0].(map[string]any) + if srv["url"] != "/test" { + t.Errorf("server url = %v, want /test (trailing slash trimmed)", srv["url"]) + } + + if doc["openapi"] != "3.0.3" { + t.Errorf("openapi field not preserved: %v", doc["openapi"]) + } + if _, ok := doc["paths"].(map[string]any)["/p"]; !ok { + t.Errorf("paths content not preserved verbatim") + } +} + +func TestWithServerBasePathInvalidJSON(t *testing.T) { + if _, err := withServerBasePath([]byte("not json"), "/test/"); err == nil { + t.Errorf("expected error on invalid spec, got nil") + } +}