feat(10-01): serve the embedded admin SPA at backend.uri with cookie login
- backend.uri prefix (default /backend) mounts the admin API at {prefix}/api/v1
and the embedded SPA shell at {prefix} with an api/ JSON 404 fallback
- cookie transport: an X-Requested-With login sets the HttpOnly summer_admin
cookie and returns no token; the backend guard reads the cookie after Bearer
- CSRF wrapper refuses cookie-only POST/PUT/DELETE without X-Requested-With
- boardwalk package embeds boardwalk/dist, rewrites index.html once per prefix
and sets cache and security headers
- framework admin OpenAPI pipeline (swag, swagger2openapi, openapi-typescript)
with prefix-relative paths and typed envelopes for the tracer routes
- admin/ Vite SPA: login, plugin rail, section panel and read-only list
through the openapi-fetch client typed by the generated schema
This commit is contained in:
380
internal/tools/swagger2openapi/main.go
Normal file
380
internal/tools/swagger2openapi/main.go
Normal file
@@ -0,0 +1,380 @@
|
||||
// Command swagger2openapi converts swag v1's Swagger 2.0 JSON to OpenAPI 3.0
|
||||
// so openapi-typescript 7.x can consume it. swag v1 has no OpenAPI 3 emitter
|
||||
// (v2 is RC and rejected by STACK.md). It is a copy of the app repository's
|
||||
// converter plus a rewrite of cabana's opaque JSON types into unions, used by
|
||||
// scripts/check-admin-openapi.sh to build admin/openapi/admin.json (D-15).
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
fmt.Fprintf(os.Stderr, "usage: swagger2openapi <swagger.json>\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
raw, err := os.ReadFile(os.Args[1])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
out := swagger2openapi(doc)
|
||||
enc, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
enc = append(enc, '\n')
|
||||
if _, err := os.Stdout.Write(enc); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func swagger2openapi(doc map[string]any) map[string]any {
|
||||
out := map[string]any{
|
||||
"openapi": "3.0.3",
|
||||
}
|
||||
if info, ok := doc["info"]; ok {
|
||||
out["info"] = info
|
||||
}
|
||||
if tags, ok := doc["tags"]; ok {
|
||||
out["tags"] = tags
|
||||
}
|
||||
if servers := convertServers(doc); len(servers) > 0 {
|
||||
out["servers"] = servers
|
||||
}
|
||||
if paths, ok := doc["paths"].(map[string]any); ok {
|
||||
out["paths"] = convertPaths(paths)
|
||||
}
|
||||
components := map[string]any{}
|
||||
if defs, ok := doc["definitions"].(map[string]any); ok {
|
||||
rewriteOpaque(defs)
|
||||
components["schemas"] = defs
|
||||
}
|
||||
if sec, ok := doc["securityDefinitions"].(map[string]any); ok {
|
||||
components["securitySchemes"] = convertSecurity(sec)
|
||||
}
|
||||
if len(components) > 0 {
|
||||
out["components"] = components
|
||||
}
|
||||
if sec, ok := doc["security"]; ok {
|
||||
out["security"] = sec
|
||||
}
|
||||
return rewriteRefs(out).(map[string]any)
|
||||
}
|
||||
|
||||
func convertServers(doc map[string]any) []any {
|
||||
host, _ := doc["host"].(string)
|
||||
base, _ := doc["basePath"].(string)
|
||||
schemes, _ := doc["schemes"].([]any)
|
||||
if host == "" && (base == "" || base == "/") && len(schemes) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(schemes) == 0 {
|
||||
schemes = []any{"https"}
|
||||
}
|
||||
if base == "" {
|
||||
base = "/"
|
||||
}
|
||||
out := make([]any, 0, len(schemes))
|
||||
for _, s := range schemes {
|
||||
scheme, _ := s.(string)
|
||||
url := scheme + "://" + host
|
||||
if host == "" {
|
||||
url = base
|
||||
} else if base != "/" {
|
||||
url += base
|
||||
}
|
||||
out = append(out, map[string]any{"url": url})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertPaths(paths map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(paths))
|
||||
for path, raw := range paths {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
out[path] = raw
|
||||
continue
|
||||
}
|
||||
converted := make(map[string]any, len(item))
|
||||
var produces []string
|
||||
if p, ok := item["produces"].([]any); ok {
|
||||
produces = stringList(p)
|
||||
}
|
||||
var consumes []string
|
||||
if c, ok := item["consumes"].([]any); ok {
|
||||
consumes = stringList(c)
|
||||
}
|
||||
for k, v := range item {
|
||||
switch k {
|
||||
case "produces", "consumes":
|
||||
continue
|
||||
case "get", "put", "post", "delete", "options", "head", "patch", "trace":
|
||||
op, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
converted[k] = v
|
||||
continue
|
||||
}
|
||||
converted[k] = convertOperation(op, produces, consumes)
|
||||
default:
|
||||
converted[k] = v
|
||||
}
|
||||
}
|
||||
out[path] = converted
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertOperation(op map[string]any, parentProduces, parentConsumes []string) map[string]any {
|
||||
out := make(map[string]any, len(op))
|
||||
produces := parentProduces
|
||||
if p, ok := op["produces"].([]any); ok {
|
||||
produces = stringList(p)
|
||||
}
|
||||
if len(produces) == 0 {
|
||||
produces = []string{"application/json"}
|
||||
}
|
||||
consumes := parentConsumes
|
||||
if c, ok := op["consumes"].([]any); ok {
|
||||
consumes = stringList(c)
|
||||
}
|
||||
if len(consumes) == 0 {
|
||||
consumes = []string{"application/json"}
|
||||
}
|
||||
for k, v := range op {
|
||||
switch k {
|
||||
case "produces", "consumes":
|
||||
continue
|
||||
case "parameters":
|
||||
params, body := splitParameters(v, consumes)
|
||||
if len(params) > 0 {
|
||||
out["parameters"] = params
|
||||
}
|
||||
if body != nil {
|
||||
out["requestBody"] = body
|
||||
}
|
||||
case "responses":
|
||||
res, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
out[k] = v
|
||||
continue
|
||||
}
|
||||
out[k] = convertResponses(res, produces)
|
||||
default:
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitParameters(v any, consumes []string) (params []any, body map[string]any) {
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
for _, item := range list {
|
||||
p, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
params = append(params, item)
|
||||
continue
|
||||
}
|
||||
if in, _ := p["in"].(string); in == "body" {
|
||||
content := map[string]any{}
|
||||
for _, ct := range consumes {
|
||||
entry := map[string]any{}
|
||||
if schema, ok := p["schema"]; ok {
|
||||
entry["schema"] = schema
|
||||
}
|
||||
content[ct] = entry
|
||||
}
|
||||
body = map[string]any{"content": content}
|
||||
if req, ok := p["required"]; ok {
|
||||
body["required"] = req
|
||||
}
|
||||
if desc, ok := p["description"]; ok {
|
||||
body["description"] = desc
|
||||
}
|
||||
continue
|
||||
}
|
||||
params = append(params, convertParameter(p))
|
||||
}
|
||||
return params, body
|
||||
}
|
||||
|
||||
var paramSchemaKeys = map[string]struct{}{
|
||||
"type": {}, "format": {}, "items": {}, "enum": {}, "default": {},
|
||||
"minimum": {}, "maximum": {}, "minLength": {}, "maxLength": {},
|
||||
"pattern": {}, "uniqueItems": {}, "multipleOf": {},
|
||||
"exclusiveMinimum": {}, "exclusiveMaximum": {},
|
||||
"additionalProperties": {}, "properties": {},
|
||||
}
|
||||
|
||||
func convertParameter(p map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(p))
|
||||
schema := map[string]any{}
|
||||
for k, v := range p {
|
||||
if _, ok := paramSchemaKeys[k]; ok {
|
||||
schema[k] = v
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
if len(schema) > 0 {
|
||||
out["schema"] = schema
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertResponses(res map[string]any, produces []string) map[string]any {
|
||||
out := make(map[string]any, len(res))
|
||||
for code, raw := range res {
|
||||
r, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
out[code] = raw
|
||||
continue
|
||||
}
|
||||
converted := make(map[string]any, len(r))
|
||||
var schema any
|
||||
for k, v := range r {
|
||||
if k == "schema" {
|
||||
schema = v
|
||||
continue
|
||||
}
|
||||
converted[k] = v
|
||||
}
|
||||
if schema != nil {
|
||||
content := map[string]any{}
|
||||
for _, ct := range produces {
|
||||
content[ct] = map[string]any{"schema": schema}
|
||||
}
|
||||
converted["content"] = content
|
||||
}
|
||||
out[code] = converted
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertSecurity(sec map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(sec))
|
||||
for name, raw := range sec {
|
||||
s, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
out[name] = raw
|
||||
continue
|
||||
}
|
||||
copied := make(map[string]any, len(s))
|
||||
for k, v := range s {
|
||||
copied[k] = v
|
||||
}
|
||||
if t, _ := copied["type"].(string); t == "oauth2" {
|
||||
flows := map[string]any{}
|
||||
flow, _ := copied["flow"].(string)
|
||||
delete(copied, "flow")
|
||||
flowObj := map[string]any{}
|
||||
if u, ok := copied["authorizationUrl"]; ok {
|
||||
flowObj["authorizationUrl"] = u
|
||||
delete(copied, "authorizationUrl")
|
||||
}
|
||||
if u, ok := copied["tokenUrl"]; ok {
|
||||
flowObj["tokenUrl"] = u
|
||||
delete(copied, "tokenUrl")
|
||||
}
|
||||
if sc, ok := copied["scopes"]; ok {
|
||||
flowObj["scopes"] = sc
|
||||
delete(copied, "scopes")
|
||||
}
|
||||
switch flow {
|
||||
case "implicit":
|
||||
flows["implicit"] = flowObj
|
||||
case "password":
|
||||
flows["password"] = flowObj
|
||||
case "application":
|
||||
flows["clientCredentials"] = flowObj
|
||||
case "accessCode":
|
||||
flows["authorizationCode"] = flowObj
|
||||
}
|
||||
copied["flows"] = flows
|
||||
}
|
||||
out[name] = copied
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rewriteRefs(v any) any {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(t))
|
||||
for k, val := range t {
|
||||
if k == "$ref" {
|
||||
if s, ok := val.(string); ok {
|
||||
const from = "#/definitions/"
|
||||
const to = "#/components/schemas/"
|
||||
if len(s) >= len(from) && s[:len(from)] == from {
|
||||
out[k] = to + s[len(from):]
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
out[k] = rewriteRefs(val)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(t))
|
||||
for i, val := range t {
|
||||
out[i] = rewriteRefs(val)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func stringList(in []any) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, v := range in {
|
||||
s, ok := v.(string)
|
||||
if ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// opaqueUnions replaces definitions that swag can only see as an empty object
|
||||
// because their Go type marshals itself. cabana.jsonScalar is a string,
|
||||
// number, boolean or null; cabana.fieldContext is a string or string list.
|
||||
var opaqueUnions = map[string]map[string]any{
|
||||
"cabana.jsonScalar": {
|
||||
"nullable": true,
|
||||
"oneOf": []any{
|
||||
map[string]any{"type": "string"},
|
||||
map[string]any{"type": "number"},
|
||||
map[string]any{"type": "boolean"},
|
||||
},
|
||||
},
|
||||
"cabana.fieldContext": {
|
||||
"oneOf": []any{
|
||||
map[string]any{"type": "string"},
|
||||
map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func rewriteOpaque(defs map[string]any) {
|
||||
for name, union := range opaqueUnions {
|
||||
if _, ok := defs[name]; ok {
|
||||
defs[name] = union
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user