Files
summercms/internal/tools/swagger2openapi/main.go
Jakub Zych e54fd257ee feat(12.2-02): add file removal, caption, reorder and protected downloads
- DELETE, PUT and POST reorder under .../{id}/files/{field}, each scoped by one parent query (404 for a foreign file)
- protected download and thumb routes: is_public=false only, nosniff, private no-store, sandbox CSP, inline only for jpeg/png/gif/webp
- the save applies deferred removals, replaces attachOne files and rechecks maxFiles and required
- blobs of deleted files are removed after commit
- swagger2openapi emits binary content for file responses
- admin OpenAPI, TS types, conformance, README and attachments docs
2026-10-02 18:11:56 +02:00

474 lines
12 KiB
Go

// 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"
"strings"
)
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
}
var form map[string]any
for _, item := range list {
p, ok := item.(map[string]any)
if !ok {
params = append(params, item)
continue
}
if in, _ := p["in"].(string); in == "formData" {
form = addFormField(form, p)
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))
}
if form != nil && body == nil {
body = formBody(form, consumes)
}
return params, body
}
// addFormField folds one Swagger 2.0 formData parameter into an object
// schema: a file becomes a binary string, any other type keeps its schema.
func addFormField(form map[string]any, p map[string]any) map[string]any {
if form == nil {
form = map[string]any{"type": "object", "properties": map[string]any{}}
}
name, _ := p["name"].(string)
prop := map[string]any{}
for k, v := range p {
if _, ok := paramSchemaKeys[k]; ok {
prop[k] = v
}
}
if prop["type"] == "file" {
prop["type"] = "string"
prop["format"] = "binary"
}
if desc, ok := p["description"]; ok {
prop["description"] = desc
}
form["properties"].(map[string]any)[name] = prop
if req, _ := p["required"].(bool); req {
required, _ := form["required"].([]any)
form["required"] = append(required, name)
}
return form
}
// formBody is the OpenAPI 3 requestBody of an operation's formData
// parameters, under multipart/form-data unless the operation declares only
// application/x-www-form-urlencoded.
func formBody(form map[string]any, consumes []string) map[string]any {
ct := "multipart/form-data"
for _, c := range consumes {
if c == "application/x-www-form-urlencoded" {
ct = c
}
if c == "multipart/form-data" {
ct = c
break
}
}
body := map[string]any{"content": map[string]any{ct: map[string]any{"schema": form}}}
if req, ok := form["required"].([]any); ok && len(req) > 0 {
body["required"] = true
}
return 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[<name>]=<value>), 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 {
// A Swagger 2.0 file response is binary under the operation's
// media types; any other schema (an error envelope next to a
// binary success) is JSON.
types := produces
if m, ok := schema.(map[string]any); ok && m["type"] == "file" {
schema = map[string]any{"type": "string", "format": "binary"}
} else {
types = jsonTypes(produces)
}
content := map[string]any{}
for _, ct := range types {
content[ct] = map[string]any{"schema": schema}
}
converted["content"] = content
}
out[code] = converted
}
return out
}
// jsonTypes keeps the JSON media types of produces, or application/json
// when there are none.
func jsonTypes(produces []string) []string {
var out []string
for _, ct := range produces {
if strings.Contains(ct, "json") {
out = append(out, ct)
}
}
if len(out) == 0 {
return []string{"application/json"}
}
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
}
}
}