mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-21 12:06:08 +00:00
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:
@@ -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.`
|
||||
@@ -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
|
||||
|
||||
@@ -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) == "-" {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user