- 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
474 lines
12 KiB
Go
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
|
|
}
|
|
}
|
|
}
|