feat(api-docs): generate response examples from Go structs; fix SS2022 PSK regen (#4996)

Stop hand-writing OpenAPI response examples, which kept drifting from the real payloads (clients/traffic missing fields, inbounds/list exposing userId which is json:"-", the fictional inbound-443 tag instead of the real in-<port>-<transport> form).

tools/openapigen now emits frontend/src/generated/examples.ts: a per-struct example instance built from type defaults, validate oneof/min bounds, and example: struct tags, with nested-ref expansion and a cycle guard. build-openapi.mjs composes the {success,obj} envelope from it for any endpoint annotated with responseSchema (+ responseSchemaArray for lists); the hand-written response is dropped for those. Service DTOs InboundOption/ApiTokenView/ProbeResultUI are added to the walker.

#4996: client password regeneration now produces a valid Shadowsocks 2022 PSK (correct base64 length per cipher) when an SS2022 inbound is attached, in both the single and bulk client forms; backend surfaces ssMethod on /inbounds/options so the UI can pick the right length.

Also: Swagger UI persists the Authorization token across reloads (persistAuthorization).
This commit is contained in:
MHSanaei
2026-06-06 14:58:15 +02:00
parent 483952cfa0
commit 83799d71b0
22 changed files with 924 additions and 143 deletions
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
)
func emitExamples(w io.Writer, schemas []Schema, aliases []Alias) error {
byName := make(map[string]Schema, len(schemas))
for _, s := range schemas {
byName[s.Name] = s
}
aliasByName := make(map[string]Alias, len(aliases))
for _, a := range aliases {
aliasByName[a.Name] = a
}
gen := &exampleGen{byName: byName, aliasByName: aliasByName}
out := make(map[string]any, len(schemas))
for _, s := range schemas {
out[s.Name] = gen.forSchema(s, map[string]bool{})
}
payload, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
if _, err := fmt.Fprintln(w, examplesHeader); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "export const EXAMPLES: Record<string, unknown> = %s;\n", payload); err != nil {
return err
}
return nil
}
type exampleGen struct {
byName map[string]Schema
aliasByName map[string]Alias
}
func (g *exampleGen) forSchema(s Schema, visited map[string]bool) map[string]any {
obj := make(map[string]any, len(s.Fields))
for _, f := range s.Fields {
obj[f.JSONName] = g.forField(f, visited)
}
return obj
}
func (g *exampleGen) forField(f Field, visited map[string]bool) any {
if f.Example != "" {
return coerceExample(f.Example, baseKind(f.Type))
}
if v, ok := firstOneOf(f.Validate); ok {
return v
}
bk := baseKind(f.Type)
if bk.Kind == KindInt || bk.Kind == KindNumber {
if v, ok := numericFloor(bk.Kind, f.Validate); ok {
return v
}
}
return g.forType(f.Type, visited)
}
func (g *exampleGen) forType(t TypeRef, visited map[string]bool) any {
switch t.Kind {
case KindString:
if t.Name == "datetime" {
return "2025-01-01T00:00:00Z"
}
return ""
case KindInt, KindNumber:
return 0
case KindBool:
return false
case KindArray:
if isVisitedRef(*t.Element, visited) {
return []any{}
}
return []any{g.forType(*t.Element, visited)}
case KindMap:
return map[string]any{}
case KindRef:
if t.Name == "nullable" {
return nil
}
if alias, ok := g.aliasByName[t.Name]; ok {
return g.forType(alias.Underlying, visited)
}
schema, ok := g.byName[t.Name]
if !ok || visited[t.Name] {
return map[string]any{}
}
next := cloneVisited(visited)
next[t.Name] = true
return g.forSchema(schema, next)
}
return nil
}
func baseKind(t TypeRef) TypeRef {
if t.Kind == KindRef && t.Name == "nullable" && t.Inner != nil {
return *t.Inner
}
return t
}
func isVisitedRef(t TypeRef, visited map[string]bool) bool {
return t.Kind == KindRef && t.Name != "nullable" && visited[t.Name]
}
func cloneVisited(in map[string]bool) map[string]bool {
out := make(map[string]bool, len(in)+1)
for k, v := range in {
out[k] = v
}
return out
}
func numericFloor(kind TypeKind, rules []ValidateRule) (any, bool) {
for _, r := range rules {
if (r.Name == "gte" || r.Name == "min") && r.Param != "" {
return coerceExample(r.Param, TypeRef{Kind: kind}), true
}
}
return nil, false
}
func firstOneOf(rules []ValidateRule) (string, bool) {
for _, r := range rules {
if r.Name == "oneof" {
fields := strings.Fields(r.Param)
if len(fields) > 0 {
return fields[0], true
}
}
}
return "", false
}
func coerceExample(ex string, t TypeRef) any {
switch t.Kind {
case KindInt:
if n, err := strconv.ParseInt(ex, 10, 64); err == nil {
return n
}
return 0
case KindNumber:
if n, err := strconv.ParseFloat(ex, 64); err == nil {
return n
}
return 0
case KindBool:
return ex == "true"
case KindString:
return ex
default:
var parsed any
if err := json.Unmarshal([]byte(ex), &parsed); err == nil {
return parsed
}
return ex
}
}
const examplesHeader = `// Code generated by tools/openapigen. DO NOT EDIT.`
+15
View File
@@ -69,6 +69,14 @@ func run(root, outDir string) error {
"ClientTraffic",
),
},
{
Path: resolveRel(root, "web/service"),
StructAllow: setOf(
"InboundOption",
"ApiTokenView",
"ProbeResultUI",
),
},
}
schemas, aliases, err := walkPackages(requests)
@@ -94,6 +102,10 @@ func run(root, outDir string) error {
if err := emitTypes(typesBuf, schemas, aliases); err != nil {
return err
}
examplesBuf := &bytes.Buffer{}
if err := emitExamples(examplesBuf, schemas, aliases); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(target, "zod.ts"), zodBuf.Bytes(), 0o644); err != nil {
return err
@@ -101,6 +113,9 @@ func run(root, outDir string) error {
if err := os.WriteFile(filepath.Join(target, "types.ts"), typesBuf.Bytes(), 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(target, "examples.ts"), examplesBuf.Bytes(), 0o644); err != nil {
return err
}
fmt.Printf("openapigen: wrote %d schemas to %s\n", len(schemas), target)
return nil
+3 -1
View File
@@ -27,6 +27,7 @@ type Field struct {
Skip bool
Validate []ValidateRule
Doc string
Example string
}
type TypeRef struct {
@@ -59,10 +60,11 @@ type ValidateRule struct {
Param string
}
func parseStructTag(raw string) (json string, validate string, gormHasDash bool) {
func parseStructTag(raw string) (json string, validate string, example string, gormHasDash bool) {
tag := reflect.StructTag(strings.Trim(raw, "`"))
json = tag.Get("json")
validate = tag.Get("validate")
example = tag.Get("example")
if g := tag.Get("gorm"); g != "" {
for part := range strings.SplitSeq(g, ";") {
if strings.TrimSpace(part) == "-" {
+3 -1
View File
@@ -102,7 +102,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
if fld.Tag != nil {
tag = fld.Tag.Value
}
jsonTag, validateTag, gormDash := parseStructTag(tag)
jsonTag, validateTag, exampleTag, gormDash := parseStructTag(tag)
if gormDash && jsonTag == "" {
return nil
}
@@ -132,6 +132,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
Optional: omitempty || isPointer(fld.Type),
Validate: validate,
Doc: doc,
Example: exampleTag,
})
}
@@ -154,6 +155,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
Optional: omitempty || isPointer(fld.Type),
Validate: validate,
Doc: doc,
Example: exampleTag,
})
}