- 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
392 lines
9.2 KiB
Go
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
|
|
}
|
|
}
|
|
}
|