Files
3x-ui/tools/openapigen/emit_jsonschema.go
MHSanaei a014c01725 feat(api-docs): generate OpenAPI components/schemas from Go structs
A new emit_jsonschema.go walks the same allow-listed structs as the zod/types/examples emitters and writes generated/schemas.ts (SCHEMAS). build-openapi mounts it under components.schemas and points each typed response obj at a $ref instead of an untyped {} blob, so Swagger renders real models and openapi-generator can emit clients.

Also add a vitest guard that safeParses every EXAMPLES entry against its generated zod schema, reviving the previously unused generated/zod.ts and catching drift between the example and schema emitters.
2026-06-06 16:22:21 +02:00

191 lines
4.4 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"sort"
"strconv"
"strings"
)
func emitJSONSchema(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 := &schemaGen{byName: byName, aliasByName: aliasByName}
out := make(map[string]any, len(schemas))
for _, s := range schemas {
out[s.Name] = gen.objectSchema(s)
}
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 SCHEMAS: Record<string, unknown> = %s;\n", payload); err != nil {
return err
}
return nil
}
type schemaGen struct {
byName map[string]Schema
aliasByName map[string]Alias
}
func (g *schemaGen) objectSchema(s Schema) map[string]any {
props := make(map[string]any, len(s.Fields))
var required []string
for _, f := range s.Fields {
props[f.JSONName] = g.fieldSchema(f)
if !f.Optional {
required = append(required, f.JSONName)
}
}
obj := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
sort.Strings(required)
obj["required"] = required
}
if s.Doc != "" {
obj["description"] = s.Doc
}
return obj
}
func (g *schemaGen) fieldSchema(f Field) map[string]any {
sch := g.typeSchema(f.Type)
if ref, ok := sch["$ref"]; ok {
if f.Doc == "" && f.Example == "" {
return sch
}
wrap := map[string]any{"allOf": []any{map[string]any{"$ref": ref}}}
if f.Doc != "" {
wrap["description"] = f.Doc
}
if f.Example != "" {
wrap["example"] = coerceExample(f.Example, baseKind(f.Type))
}
return wrap
}
applyConstraints(sch, f.Type, f.Validate)
if f.Doc != "" {
sch["description"] = f.Doc
}
if f.Example != "" {
sch["example"] = coerceExample(f.Example, baseKind(f.Type))
}
return sch
}
func (g *schemaGen) typeSchema(t TypeRef) map[string]any {
switch t.Kind {
case KindString:
if t.Name == "datetime" {
return map[string]any{"type": "string", "format": "date-time"}
}
return map[string]any{"type": "string"}
case KindInt:
return map[string]any{"type": "integer"}
case KindNumber:
return map[string]any{"type": "number"}
case KindBool:
return map[string]any{"type": "boolean"}
case KindArray:
return map[string]any{"type": "array", "items": g.typeSchema(*t.Element)}
case KindMap:
return map[string]any{"type": "object", "additionalProperties": g.typeSchema(*t.Value)}
case KindAny, KindUnknown, KindRaw:
return map[string]any{}
case KindRef:
if t.Name == "nullable" {
inner := g.typeSchema(*t.Inner)
if ref, ok := inner["$ref"]; ok {
return map[string]any{"nullable": true, "allOf": []any{map[string]any{"$ref": ref}}}
}
inner["nullable"] = true
return inner
}
if alias, ok := g.aliasByName[t.Name]; ok {
return g.typeSchema(alias.Underlying)
}
if _, ok := g.byName[t.Name]; ok {
return map[string]any{"$ref": "#/components/schemas/" + t.Name}
}
return map[string]any{}
}
return map[string]any{}
}
func applyConstraints(sch map[string]any, t TypeRef, rules []ValidateRule) {
base := baseKind(t)
numeric := base.Kind == KindInt || base.Kind == KindNumber
str := base.Kind == KindString
for _, r := range rules {
switch r.Name {
case "gte":
if numeric {
sch["minimum"] = coerceExample(r.Param, base)
}
case "lte":
if numeric {
sch["maximum"] = coerceExample(r.Param, base)
}
case "gt":
if numeric {
sch["minimum"] = coerceExample(r.Param, base)
sch["exclusiveMinimum"] = true
}
case "lt":
if numeric {
sch["maximum"] = coerceExample(r.Param, base)
sch["exclusiveMaximum"] = true
}
case "min":
if numeric {
sch["minimum"] = coerceExample(r.Param, base)
} else if str {
if n, err := strconv.Atoi(r.Param); err == nil {
sch["minLength"] = n
}
}
case "max":
if numeric {
sch["maximum"] = coerceExample(r.Param, base)
} else if str {
if n, err := strconv.Atoi(r.Param); err == nil {
sch["maxLength"] = n
}
}
case "oneof":
vals := strings.Fields(r.Param)
if len(vals) > 0 {
enum := make([]any, len(vals))
for i, v := range vals {
enum[i] = v
}
sch["enum"] = enum
}
case "email":
if str {
sch["format"] = "email"
}
case "url":
if str {
sch["format"] = "uri"
}
}
}
}