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

View File

@@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import { sections } from '../src/pages/api-docs/endpoints.ts';
import { EXAMPLES } from '../src/generated/examples.ts';
import { SCHEMAS } from '../src/generated/schemas.ts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const outPath = join(__dirname, '..', 'public', 'openapi.json');
@@ -130,12 +131,20 @@ function buildOperation(ep, tag) {
const responses = {};
let successExample = tryParseJson(ep.response);
if (successExample === undefined && ep.responseSchema) {
let objSchema = {};
if (ep.responseSchema) {
const obj = EXAMPLES[ep.responseSchema];
if (obj === undefined) {
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`);
}
successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
if (SCHEMAS[ep.responseSchema] === undefined) {
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`);
}
const ref = { $ref: `#/components/schemas/${ep.responseSchema}` };
objSchema = ep.responseSchemaArray ? { type: 'array', items: ref } : ref;
if (successExample === undefined) {
successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
}
}
responses['200'] = {
description: 'Successful response',
@@ -146,7 +155,7 @@ function buildOperation(ep, tag) {
properties: {
success: { type: 'boolean' },
msg: { type: 'string' },
obj: {},
obj: objSchema,
},
},
...(successExample !== undefined ? { example: successExample } : {}),
@@ -200,13 +209,14 @@ function buildSpec() {
title: '3X-UI Panel API',
version: PANEL_VERSION,
description:
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes.',
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.',
},
servers: [
{ url: '/', description: 'Current panel (basePath aware)' },
],
components: {
securitySchemes: SECURITY_SCHEMES,
schemas: SCHEMAS,
},
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
tags,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import type { ZodType } from 'zod';
import { EXAMPLES } from '@/generated/examples';
import * as zodSchemas from '@/generated/zod';
const registry = zodSchemas as unknown as Record<string, ZodType>;
const names = Object.keys(EXAMPLES);
describe('generated response examples', () => {
it('has at least one example to validate', () => {
expect(names.length).toBeGreaterThan(0);
});
it('pairs every example with a generated zod schema', () => {
const missing = names.filter((name) => typeof registry[`${name}Schema`]?.safeParse !== 'function');
expect(missing).toEqual([]);
});
it.each(names)('EXAMPLES.%s satisfies its generated zod schema', (name) => {
const schema = registry[`${name}Schema`];
const result = schema.safeParse(EXAMPLES[name]);
if (!result.success) {
throw new Error(
`EXAMPLES.${name} does not match ${name}Schema:\n${JSON.stringify(result.error.issues, null, 2)}`,
);
}
expect(result.success).toBe(true);
});
});

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"
}
}
}
}

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