// 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 \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 } // Swagger 2.0 has no object query parameters; swag emits `type: object` // for one. cabana reads such a parameter as bracketed keys // (filter[]=), which is OpenAPI 3's deepObject style with // string values. if in, _ := out["in"].(string); in == "query" && schema["type"] == "object" { if _, ok := schema["additionalProperties"]; !ok { schema["additionalProperties"] = map[string]any{"type": "string"} } out["style"] = "deepObject" out["explode"] = true } 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 } } }