mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-07 21:04:32 +00:00
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:
@@ -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,
|
||||
|
||||
1785
frontend/src/generated/schemas.ts
Normal file
1785
frontend/src/generated/schemas.ts
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/src/test/generated-examples.test.ts
Normal file
30
frontend/src/test/generated-examples.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
190
tools/openapigen/emit_jsonschema.go
Normal file
190
tools/openapigen/emit_jsonschema.go
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user