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.
This commit is contained in:
MHSanaei
2026-06-06 16:22:21 +02:00
parent e56f6c63f6
commit a014c01725
5 changed files with 2026 additions and 4 deletions
+190
View File
@@ -0,0 +1,190 @@
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"
}
}
}
}
+7
View File
@@ -106,6 +106,10 @@ func run(root, outDir string) error {
if err := emitExamples(examplesBuf, schemas, aliases); err != nil {
return err
}
schemasBuf := &bytes.Buffer{}
if err := emitJSONSchema(schemasBuf, schemas, aliases); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(target, "zod.ts"), zodBuf.Bytes(), 0o644); err != nil {
return err
@@ -116,6 +120,9 @@ func run(root, outDir string) error {
if err := os.WriteFile(filepath.Join(target, "examples.ts"), examplesBuf.Bytes(), 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(target, "schemas.ts"), schemasBuf.Bytes(), 0o644); err != nil {
return err
}
fmt.Printf("openapigen: wrote %d schemas to %s\n", len(schemas), target)
return nil