Files
summercms/internal/tools/swagger2openapi/main.go
Jakub Zych 126ca5b8ed feat(10-03): open, edit and save a record with toast and 422 feedback
- The SPA loads the backend::lang bundle before /auth/me, sets the
  document language from meta.locale and renders plural messages with
  Intl.PluralRules; interpolate mirrors phrasebook for :name/:Name/:NAME
- Create and record routes; mapWinterUrl maps recordUrl and redirects
  onto the controller's list, create and record routes only
- List rows open their record; FormView loads the form schema and the
  record, shows context-allowed fields in the span grid, saves values
  keyed by field name and toasts the resolved saved message
- A 422 puts each message under its field (aria-invalid,
  aria-describedby), shows the plural banner, focuses the first invalid
  field in schema order and clears a field's error on change
- The D-05 registry maps text, textarea, number and dropdown; any other
  type renders the unsupported-field box with the type in DM Mono
- The admin OpenAPI document declares the write request bodies
  (AdminRecord, AdminIDsRequest) and the list filter query as a
  deepObject, so the typed client can send them
- New backend::lang form.load_failed key; boardwalk/dist rebuilt
2026-09-27 16:43:47 +02:00

392 lines
9.2 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"
)
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
}
// 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 {
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
}
}
}