refactor(frontend): port api-docs/endpoints to TypeScript

endpoints.js was the only remaining JS file under src/. It's a pure data
file describing every panel API surface for the in-panel Swagger docs;
scripts/build-openapi.mjs reads it at build time to emit
public/openapi.json.

Convert it to endpoints.ts with explicit interfaces:
  HttpMethod, ParamLocation, ParamType,
  EndpointParam, Endpoint, SubscriptionHeader, Section

Type-checking surfaced shapes the .js had silently accepted:
  - 'in' values beyond plain 'body' — 'body (form)', 'body (json)',
    'body (multipart)' for non-JSON request bodies
  - 'type' arrays — 'integer[]', 'object[]'
  - Subscription section's subHeader documenting response headers
All four are now part of the union types so the existing data type-checks.

Dead exports removed:
  - safeInlineHtml — unused since the docs page switched to Swagger UI
  - methodColors — unused

Build pipeline:
  - scripts/build-openapi.mjs imports endpoints.ts directly
  - gen:api runs via Node 22's native --experimental-strip-types; no
    tsx/ts-node dependency added
  - --disable-warning=ExperimentalWarning silences just the strip-types
    notice while keeping deprecation warnings intact
This commit is contained in:
MHSanaei
2026-05-25 15:20:12 +02:00
parent dc37f9b731
commit 20edaee8ed
4 changed files with 62 additions and 41 deletions

View File

@@ -14,7 +14,7 @@
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"gen:api": "node scripts/build-openapi.mjs" "gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.2.3", "@ant-design/icons": "^6.2.3",

View File

@@ -3,7 +3,7 @@ import { writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { sections } from '../src/pages/api-docs/endpoints.js'; import { sections } from '../src/pages/api-docs/endpoints.ts';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const outPath = join(__dirname, '..', 'public', 'openapi.json'); const outPath = join(__dirname, '..', 'public', 'openapi.json');

View File

@@ -1,29 +1,59 @@
export function safeInlineHtml(input) { export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'WS';
if (!input) return ''; export type ParamLocation =
const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); | 'path'
const open = '<code>'; | 'query'
const close = '</code>'; | 'header'
let out = ''; | 'body'
let i = 0; | 'body (form)'
while (i < input.length) { | 'body (json)'
const oIdx = input.indexOf(open, i); | 'body (multipart)';
if (oIdx === -1) { export type ParamType =
out += escape(input.slice(i)); | 'string'
break; | 'integer'
} | 'integer[]'
out += escape(input.slice(i, oIdx)); | 'number'
const cIdx = input.indexOf(close, oIdx + open.length); | 'boolean'
if (cIdx === -1) { | 'object'
out += escape(input.slice(oIdx)); | 'object[]'
break; | 'array'
} | 'file';
out += '<code>' + escape(input.slice(oIdx + open.length, cIdx)) + '</code>';
i = cIdx + close.length; export interface EndpointParam {
} name: string;
return out; in: ParamLocation;
type: ParamType;
desc?: string;
optional?: boolean;
defaultValue?: string | number | boolean;
} }
export const sections = [ export interface Endpoint {
method: HttpMethod;
path: string;
summary: string;
description?: string;
deprecated?: boolean;
params?: EndpointParam[];
body?: string;
response?: string;
errorResponse?: string;
errorStatus?: number;
}
export interface SubscriptionHeader {
name: string;
desc: string;
}
export interface Section {
id: string;
title: string;
description?: string;
subHeader?: SubscriptionHeader[];
endpoints: Endpoint[];
}
export const sections: readonly Section[] = [
{ {
id: 'authentication', id: 'authentication',
title: 'Authentication', title: 'Authentication',
@@ -975,12 +1005,3 @@ export const sections = [
], ],
}, },
]; ];
export const methodColors = {
GET: 'blue',
POST: 'green',
PUT: 'orange',
PATCH: 'orange',
DELETE: 'red',
WS: 'purple',
};

View File

@@ -16,10 +16,10 @@ type routeDef struct {
// routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler) // routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler)
var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`) var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`)
// docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.js. // docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.ts.
var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`) var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`)
// buildDocSet parses frontend/src/pages/api-docs/endpoints.js and returns the // buildDocSet parses frontend/src/pages/api-docs/endpoints.ts and returns the
// set of documented "METHOD PATH" keys. WS pseudo-routes and subscription // set of documented "METHOD PATH" keys. WS pseudo-routes and subscription
// placeholders (paths starting with /{...}) are skipped because they aren't // placeholders (paths starting with /{...}) are skipped because they aren't
// registered on the main Gin engine. // registered on the main Gin engine.
@@ -29,10 +29,10 @@ func buildDocSet(t *testing.T) map[string]bool {
if err != nil { if err != nil {
t.Fatalf("failed to get current dir: %v", err) t.Fatalf("failed to get current dir: %v", err)
} }
endpointsPath := filepath.Join(controllerDir, "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.js") endpointsPath := filepath.Join(controllerDir, "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts")
data, err := os.ReadFile(endpointsPath) data, err := os.ReadFile(endpointsPath)
if err != nil { if err != nil {
t.Fatalf("failed to read endpoints.js at %s: %v", endpointsPath, err) t.Fatalf("failed to read endpoints.ts at %s: %v", endpointsPath, err)
} }
docSet := make(map[string]bool) docSet := make(map[string]bool)
for _, m := range docRoutePattern.FindAllStringSubmatch(string(data), -1) { for _, m := range docRoutePattern.FindAllStringSubmatch(string(data), -1) {
@@ -150,7 +150,7 @@ func TestAPIRoutesDocumented(t *testing.T) {
foundInDoc++ foundInDoc++
} else { } else {
missingFromDocs++ missingFromDocs++
t.Errorf("Route not documented in endpoints.js: %s %s", r.Method, r.Path) t.Errorf("Route not documented in endpoints.ts: %s %s", r.Method, r.Path)
} }
} }
@@ -158,6 +158,6 @@ func TestAPIRoutesDocumented(t *testing.T) {
len(sourceSet), len(docSet), foundInDoc, missingFromDocs) len(sourceSet), len(docSet), foundInDoc, missingFromDocs)
if missingFromDocs > 0 { if missingFromDocs > 0 {
t.Errorf("Found %d undocumented route(s). Update endpoints.js to match.", missingFromDocs) t.Errorf("Found %d undocumented route(s). Update endpoints.ts to match.", missingFromDocs)
} }
} }