@kirrosh/zond 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/cli/commands/catalog.ts +62 -0
- package/src/cli/commands/generate.ts +18 -1
- package/src/cli/commands/sync.ts +28 -0
- package/src/cli/index.ts +18 -0
- package/src/core/generator/catalog-builder.ts +179 -0
- package/src/core/generator/index.ts +2 -0
- package/src/web/routes/api.ts +80 -0
- package/src/web/routes/dashboard.ts +15 -0
- package/src/web/static/style.css +290 -0
- package/src/web/views/explorer-tab.ts +402 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kirrosh/zond",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "API testing platform — define tests in YAML, run from CLI or WebUI, generate from OpenAPI specs",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"module": "index.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"scripts": {
|
|
27
27
|
"zond": "bun run src/cli/index.ts",
|
|
28
28
|
"test": "bun run test:unit && bun run test:mocked",
|
|
29
|
-
"test:unit": "bun test tests/db/ tests/parser/ tests/runner/ tests/generator/ tests/core/ tests/diagnostics/ tests/cli/args.test.ts tests/cli/ci-init.test.ts tests/cli/commands.test.ts tests/cli/safe-run.test.ts tests/cli/json-envelope.test.ts tests/cli/describe.test.ts tests/cli/db.test.ts tests/cli/request.test.ts tests/cli/init.test.ts tests/cli/guide.test.ts tests/integration/ tests/web/ tests/reporter/ tests/version-sync.test.ts",
|
|
29
|
+
"test:unit": "bun test tests/db/ tests/parser/ tests/runner/ tests/generator/ tests/core/ tests/diagnostics/ tests/cli/args.test.ts tests/cli/ci-init.test.ts tests/cli/commands.test.ts tests/cli/safe-run.test.ts tests/cli/json-envelope.test.ts tests/cli/describe.test.ts tests/cli/catalog.test.ts tests/cli/db.test.ts tests/cli/request.test.ts tests/cli/init.test.ts tests/cli/guide.test.ts tests/integration/ tests/web/ tests/reporter/ tests/version-sync.test.ts",
|
|
30
30
|
"test:mocked": "bun run scripts/run-mocked-tests.ts",
|
|
31
31
|
"check": "tsc --noEmit --project tsconfig.json",
|
|
32
32
|
"build": "bun build --compile src/cli/index.ts --outfile zond",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { mkdir } from "fs/promises";
|
|
3
|
+
import { readOpenApiSpec, extractEndpoints, extractSecuritySchemes } from "../../core/generator/index.ts";
|
|
4
|
+
import { buildCatalog, serializeCatalog } from "../../core/generator/catalog-builder.ts";
|
|
5
|
+
import { decycleSchema } from "../../core/generator/schema-utils.ts";
|
|
6
|
+
import { hashSpec } from "../../core/meta/meta-store.ts";
|
|
7
|
+
import { printError, printSuccess } from "../output.ts";
|
|
8
|
+
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
9
|
+
|
|
10
|
+
export interface CatalogOptions {
|
|
11
|
+
specPath: string;
|
|
12
|
+
output?: string;
|
|
13
|
+
json?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function catalogCommand(options: CatalogOptions): Promise<number> {
|
|
17
|
+
try {
|
|
18
|
+
const doc = await readOpenApiSpec(options.specPath);
|
|
19
|
+
const endpoints = extractEndpoints(doc);
|
|
20
|
+
const securitySchemes = extractSecuritySchemes(doc);
|
|
21
|
+
const baseUrl = ((doc as any).servers?.[0]?.url) as string | undefined;
|
|
22
|
+
const apiName = (doc as any).info?.title as string | undefined;
|
|
23
|
+
const apiVersion = (doc as any).info?.version as string | undefined;
|
|
24
|
+
const specContent = JSON.stringify(decycleSchema(doc));
|
|
25
|
+
|
|
26
|
+
const catalog = buildCatalog({
|
|
27
|
+
endpoints,
|
|
28
|
+
securitySchemes,
|
|
29
|
+
specSource: options.specPath,
|
|
30
|
+
specHash: hashSpec(specContent),
|
|
31
|
+
apiName,
|
|
32
|
+
apiVersion,
|
|
33
|
+
baseUrl,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const outputDir = options.output ?? ".";
|
|
37
|
+
await mkdir(outputDir, { recursive: true });
|
|
38
|
+
|
|
39
|
+
const catalogPath = join(outputDir, ".api-catalog.yaml");
|
|
40
|
+
await Bun.write(catalogPath, serializeCatalog(catalog));
|
|
41
|
+
|
|
42
|
+
if (options.json) {
|
|
43
|
+
printJson(jsonOk("catalog", {
|
|
44
|
+
path: catalogPath,
|
|
45
|
+
endpointCount: catalog.endpointCount,
|
|
46
|
+
apiName: catalog.apiName,
|
|
47
|
+
}));
|
|
48
|
+
} else {
|
|
49
|
+
printSuccess(`Generated API catalog: ${catalogPath} (${catalog.endpointCount} endpoints)`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return 0;
|
|
53
|
+
} catch (err) {
|
|
54
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
55
|
+
if (options.json) {
|
|
56
|
+
printJson(jsonError("catalog", [message]));
|
|
57
|
+
} else {
|
|
58
|
+
printError(message);
|
|
59
|
+
}
|
|
60
|
+
return 2;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
scanCoveredEndpoints,
|
|
8
8
|
filterUncoveredEndpoints,
|
|
9
9
|
serializeSuite,
|
|
10
|
+
buildCatalog,
|
|
11
|
+
serializeCatalog,
|
|
10
12
|
} from "../../core/generator/index.ts";
|
|
11
13
|
import { generateSuites, findUnresolvedVars } from "../../core/generator/suite-generator.ts";
|
|
12
14
|
import { filterByTag } from "../../core/generator/chunker.ts";
|
|
@@ -30,9 +32,12 @@ export interface GenerateOptions {
|
|
|
30
32
|
export async function generateCommand(options: GenerateOptions): Promise<number> {
|
|
31
33
|
try {
|
|
32
34
|
const doc = await readOpenApiSpec(options.specPath);
|
|
33
|
-
|
|
35
|
+
const allEndpoints = extractEndpoints(doc);
|
|
36
|
+
let endpoints = allEndpoints;
|
|
34
37
|
const securitySchemes = extractSecuritySchemes(doc);
|
|
35
38
|
const baseUrl = ((doc as any).servers?.[0]?.url) as string | undefined;
|
|
39
|
+
const apiName = (doc as any).info?.title as string | undefined;
|
|
40
|
+
const apiVersion = (doc as any).info?.version as string | undefined;
|
|
36
41
|
const warnings: string[] = [];
|
|
37
42
|
|
|
38
43
|
// Filter to uncovered only
|
|
@@ -92,6 +97,18 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
|
|
|
92
97
|
files: { ...(existingMeta?.files ?? {}), ...metaFiles },
|
|
93
98
|
});
|
|
94
99
|
|
|
100
|
+
// Generate .api-catalog.yaml (always uses full unfiltered endpoint list)
|
|
101
|
+
const catalog = buildCatalog({
|
|
102
|
+
endpoints: allEndpoints,
|
|
103
|
+
securitySchemes,
|
|
104
|
+
specSource: options.specPath,
|
|
105
|
+
specHash: hashSpec(specContent),
|
|
106
|
+
apiName,
|
|
107
|
+
apiVersion,
|
|
108
|
+
baseUrl,
|
|
109
|
+
});
|
|
110
|
+
await Bun.write(join(options.output, ".api-catalog.yaml"), serializeCatalog(catalog));
|
|
111
|
+
|
|
95
112
|
// Sync DB collection spec reference if one is registered for this output directory
|
|
96
113
|
try {
|
|
97
114
|
getDb();
|
package/src/cli/commands/sync.ts
CHANGED
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
extractEndpoints,
|
|
6
6
|
extractSecuritySchemes,
|
|
7
7
|
serializeSuite,
|
|
8
|
+
buildCatalog,
|
|
9
|
+
serializeCatalog,
|
|
8
10
|
} from "../../core/generator/index.ts";
|
|
9
11
|
import { generateSuites } from "../../core/generator/suite-generator.ts";
|
|
10
12
|
import { filterByTag } from "../../core/generator/chunker.ts";
|
|
@@ -78,6 +80,19 @@ export async function syncCommand(options: SyncOptions): Promise<number> {
|
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
if (newEndpoints.length === 0) {
|
|
83
|
+
// Update catalog even when no new endpoints — spec schema may have changed
|
|
84
|
+
const allEndpoints = extractEndpoints(doc);
|
|
85
|
+
const catalog = buildCatalog({
|
|
86
|
+
endpoints: allEndpoints,
|
|
87
|
+
securitySchemes,
|
|
88
|
+
specSource: options.specPath,
|
|
89
|
+
specHash: currentHash,
|
|
90
|
+
apiName: (doc as any).info?.title,
|
|
91
|
+
apiVersion: (doc as any).info?.version,
|
|
92
|
+
baseUrl: (doc as any).servers?.[0]?.url,
|
|
93
|
+
});
|
|
94
|
+
await Bun.write(join(options.testsDir, ".api-catalog.yaml"), serializeCatalog(catalog));
|
|
95
|
+
|
|
81
96
|
const msg = "Spec changed (hash differs) but no new endpoints detected. Existing tests may need manual review.";
|
|
82
97
|
warnings.push(msg);
|
|
83
98
|
if (options.json) {
|
|
@@ -168,6 +183,19 @@ export async function syncCommand(options: SyncOptions): Promise<number> {
|
|
|
168
183
|
files: { ...meta.files, ...updatedMetaFiles },
|
|
169
184
|
});
|
|
170
185
|
|
|
186
|
+
// Update .api-catalog.yaml with current spec state
|
|
187
|
+
const allEndpoints = extractEndpoints(doc);
|
|
188
|
+
const catalog = buildCatalog({
|
|
189
|
+
endpoints: allEndpoints,
|
|
190
|
+
securitySchemes,
|
|
191
|
+
specSource: options.specPath,
|
|
192
|
+
specHash: currentHash,
|
|
193
|
+
apiName: (doc as any).info?.title,
|
|
194
|
+
apiVersion: (doc as any).info?.version,
|
|
195
|
+
baseUrl: (doc as any).servers?.[0]?.url,
|
|
196
|
+
});
|
|
197
|
+
await Bun.write(join(options.testsDir, ".api-catalog.yaml"), serializeCatalog(catalog));
|
|
198
|
+
|
|
171
199
|
// Sync DB collection if one is registered for this tests directory
|
|
172
200
|
try {
|
|
173
201
|
getDb();
|
package/src/cli/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { generateCommand } from "./commands/generate.ts";
|
|
|
14
14
|
import { exportCommand } from "./commands/export.ts";
|
|
15
15
|
import { syncCommand } from "./commands/sync.ts";
|
|
16
16
|
import { updateCommand } from "./commands/update.ts";
|
|
17
|
+
import { catalogCommand } from "./commands/catalog.ts";
|
|
17
18
|
import { printError } from "./output.ts";
|
|
18
19
|
import { getRuntimeInfo } from "./runtime.ts";
|
|
19
20
|
import { getDb } from "../db/schema.ts";
|
|
@@ -107,6 +108,7 @@ Usage:
|
|
|
107
108
|
zond ui Alias for 'serve --open' (start dashboard & open browser)
|
|
108
109
|
zond ci init Generate CI/CD workflow (GitHub Actions, GitLab CI)
|
|
109
110
|
zond export postman <path> Export YAML tests as Postman Collection v2.1
|
|
111
|
+
zond catalog <spec> Generate API catalog (compact endpoint reference)
|
|
110
112
|
zond sync <spec> Detect new/removed endpoints and generate tests for new ones
|
|
111
113
|
zond update Check for updates and self-update the binary
|
|
112
114
|
|
|
@@ -186,6 +188,9 @@ Options for 'export postman':
|
|
|
186
188
|
--env <file> Also export .env.yaml as Postman environment
|
|
187
189
|
--collection-name <name> Collection name (default: derived from path)
|
|
188
190
|
|
|
191
|
+
Options for 'catalog':
|
|
192
|
+
--output <dir> Output directory (default: current directory)
|
|
193
|
+
|
|
189
194
|
Options for 'sync':
|
|
190
195
|
--tests <dir> Path to test files directory (required)
|
|
191
196
|
--dry-run Show what would be generated without writing files
|
|
@@ -521,6 +526,19 @@ async function main(): Promise<number> {
|
|
|
521
526
|
});
|
|
522
527
|
}
|
|
523
528
|
|
|
529
|
+
case "catalog": {
|
|
530
|
+
const specPath = positional[0];
|
|
531
|
+
if (!specPath) {
|
|
532
|
+
printError("Missing spec path. Usage: zond catalog <spec> [--output <dir>]");
|
|
533
|
+
return 2;
|
|
534
|
+
}
|
|
535
|
+
return catalogCommand({
|
|
536
|
+
specPath,
|
|
537
|
+
output: typeof flags["output"] === "string" ? flags["output"] : undefined,
|
|
538
|
+
json: jsonFlag,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
524
542
|
case "guide": {
|
|
525
543
|
const specPath = positional[0];
|
|
526
544
|
if (!specPath) {
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "./types.ts";
|
|
2
|
+
import { compressSchema, formatParam, isAnySchema } from "./schema-utils.ts";
|
|
3
|
+
|
|
4
|
+
export interface CatalogEndpoint {
|
|
5
|
+
method: string;
|
|
6
|
+
path: string;
|
|
7
|
+
summary?: string;
|
|
8
|
+
tags: string[];
|
|
9
|
+
deprecated?: boolean;
|
|
10
|
+
parameters?: string[];
|
|
11
|
+
requestBody?: string;
|
|
12
|
+
responses: Array<{
|
|
13
|
+
status: number;
|
|
14
|
+
schema?: string;
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ApiCatalog {
|
|
19
|
+
generatedAt: string;
|
|
20
|
+
specSource: string;
|
|
21
|
+
specHash: string;
|
|
22
|
+
apiName?: string;
|
|
23
|
+
apiVersion?: string;
|
|
24
|
+
baseUrl?: string;
|
|
25
|
+
auth: string[];
|
|
26
|
+
endpointCount: number;
|
|
27
|
+
endpoints: CatalogEndpoint[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BuildCatalogParams {
|
|
31
|
+
endpoints: EndpointInfo[];
|
|
32
|
+
securitySchemes: SecuritySchemeInfo[];
|
|
33
|
+
specSource: string;
|
|
34
|
+
specHash: string;
|
|
35
|
+
apiName?: string;
|
|
36
|
+
apiVersion?: string;
|
|
37
|
+
baseUrl?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatSecurityScheme(s: SecuritySchemeInfo): string {
|
|
41
|
+
let desc = `${s.name}: ${s.type}`;
|
|
42
|
+
if (s.scheme) desc += `/${s.scheme}`;
|
|
43
|
+
if (s.bearerFormat) desc += ` (${s.bearerFormat})`;
|
|
44
|
+
if (s.in && s.apiKeyName) desc += ` (${s.apiKeyName} in ${s.in})`;
|
|
45
|
+
return desc;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatParamWithLocation(p: import("openapi-types").OpenAPIV3.ParameterObject): string {
|
|
49
|
+
const base = formatParam(p);
|
|
50
|
+
// Insert location before (req) or at the end
|
|
51
|
+
const reqSuffix = " (req)";
|
|
52
|
+
if (base.endsWith(reqSuffix)) {
|
|
53
|
+
return `${base.slice(0, -reqSuffix.length)} (${p.in}, req)`;
|
|
54
|
+
}
|
|
55
|
+
return `${base} (${p.in})`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildCatalogEndpoint(ep: EndpointInfo): CatalogEndpoint {
|
|
59
|
+
const result: CatalogEndpoint = {
|
|
60
|
+
method: ep.method.toUpperCase(),
|
|
61
|
+
path: ep.path,
|
|
62
|
+
tags: ep.tags,
|
|
63
|
+
responses: [],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (ep.summary) result.summary = ep.summary;
|
|
67
|
+
if (ep.deprecated) result.deprecated = true;
|
|
68
|
+
|
|
69
|
+
if (ep.parameters.length > 0) {
|
|
70
|
+
result.parameters = ep.parameters.map(formatParamWithLocation);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (ep.requestBodySchema) {
|
|
74
|
+
result.requestBody = isAnySchema(ep.requestBodySchema)
|
|
75
|
+
? "any"
|
|
76
|
+
: compressSchema(ep.requestBodySchema);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const resp of ep.responses) {
|
|
80
|
+
const entry: { status: number; schema?: string } = { status: resp.statusCode };
|
|
81
|
+
if (resp.schema && !isAnySchema(resp.schema)) {
|
|
82
|
+
entry.schema = compressSchema(resp.schema);
|
|
83
|
+
}
|
|
84
|
+
result.responses.push(entry);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function buildCatalog(params: BuildCatalogParams): ApiCatalog {
|
|
91
|
+
return {
|
|
92
|
+
generatedAt: new Date().toISOString(),
|
|
93
|
+
specSource: params.specSource,
|
|
94
|
+
specHash: params.specHash,
|
|
95
|
+
...(params.apiName ? { apiName: params.apiName } : {}),
|
|
96
|
+
...(params.apiVersion ? { apiVersion: params.apiVersion } : {}),
|
|
97
|
+
...(params.baseUrl ? { baseUrl: params.baseUrl } : {}),
|
|
98
|
+
auth: params.securitySchemes.map(formatSecurityScheme),
|
|
99
|
+
endpointCount: params.endpoints.length,
|
|
100
|
+
endpoints: params.endpoints.map(buildCatalogEndpoint),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- YAML serialization ---
|
|
105
|
+
|
|
106
|
+
function yamlEscape(value: string): string {
|
|
107
|
+
if (/[:{}\[\],&*#?|<>=!%@`"']/.test(value) || value.includes("\n")) {
|
|
108
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function yamlScalar(value: unknown): string {
|
|
114
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
115
|
+
return yamlEscape(String(value));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function serializeCatalog(catalog: ApiCatalog): string {
|
|
119
|
+
const lines: string[] = [];
|
|
120
|
+
lines.push("# Auto-generated by zond. Regenerate with: zond catalog <spec>");
|
|
121
|
+
lines.push(`generatedAt: ${yamlScalar(catalog.generatedAt)}`);
|
|
122
|
+
lines.push(`specSource: ${yamlScalar(catalog.specSource)}`);
|
|
123
|
+
lines.push(`specHash: ${yamlScalar(catalog.specHash)}`);
|
|
124
|
+
if (catalog.apiName) lines.push(`apiName: ${yamlScalar(catalog.apiName)}`);
|
|
125
|
+
if (catalog.apiVersion) lines.push(`apiVersion: ${yamlScalar(catalog.apiVersion)}`);
|
|
126
|
+
if (catalog.baseUrl) lines.push(`baseUrl: ${yamlScalar(catalog.baseUrl)}`);
|
|
127
|
+
lines.push(`endpointCount: ${catalog.endpointCount}`);
|
|
128
|
+
|
|
129
|
+
// Auth
|
|
130
|
+
if (catalog.auth.length > 0) {
|
|
131
|
+
lines.push("auth:");
|
|
132
|
+
for (const a of catalog.auth) {
|
|
133
|
+
lines.push(` - ${yamlScalar(a)}`);
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
lines.push("auth: []");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Endpoints
|
|
140
|
+
if (catalog.endpoints.length === 0) {
|
|
141
|
+
lines.push("endpoints: []");
|
|
142
|
+
return lines.join("\n") + "\n";
|
|
143
|
+
}
|
|
144
|
+
lines.push("endpoints:");
|
|
145
|
+
for (const ep of catalog.endpoints) {
|
|
146
|
+
lines.push(` - method: ${ep.method}`);
|
|
147
|
+
lines.push(` path: ${yamlScalar(ep.path)}`);
|
|
148
|
+
if (ep.summary) lines.push(` summary: ${yamlScalar(ep.summary)}`);
|
|
149
|
+
if (ep.tags.length > 0) {
|
|
150
|
+
lines.push(` tags: [${ep.tags.map(yamlScalar).join(", ")}]`);
|
|
151
|
+
}
|
|
152
|
+
if (ep.deprecated) lines.push(` deprecated: true`);
|
|
153
|
+
|
|
154
|
+
if (ep.parameters && ep.parameters.length > 0) {
|
|
155
|
+
lines.push(` parameters:`);
|
|
156
|
+
for (const p of ep.parameters) {
|
|
157
|
+
lines.push(` - ${yamlScalar(p)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (ep.requestBody) {
|
|
162
|
+
lines.push(` requestBody: ${yamlScalar(ep.requestBody)}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (ep.responses.length > 0) {
|
|
166
|
+
lines.push(` responses:`);
|
|
167
|
+
for (const r of ep.responses) {
|
|
168
|
+
if (r.schema) {
|
|
169
|
+
lines.push(` - status: ${r.status}`);
|
|
170
|
+
lines.push(` schema: ${yamlScalar(r.schema)}`);
|
|
171
|
+
} else {
|
|
172
|
+
lines.push(` - status: ${r.status}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return lines.join("\n") + "\n";
|
|
179
|
+
}
|
|
@@ -10,3 +10,5 @@ export type { GuideOptions } from "./guide-builder.ts";
|
|
|
10
10
|
export type { EndpointWarning, WarningCode } from "./endpoint-warnings.ts";
|
|
11
11
|
export type { EndpointInfo, ResponseInfo, GenerateOptions, SecuritySchemeInfo, CrudGroup } from "./types.ts";
|
|
12
12
|
export { generateSuites, generateStep, detectCrudGroups, generateCrudSuite, generateSanitySuite, findUnresolvedVars } from "./suite-generator.ts";
|
|
13
|
+
export { buildCatalog, serializeCatalog } from "./catalog-builder.ts";
|
|
14
|
+
export type { ApiCatalog, CatalogEndpoint } from "./catalog-builder.ts";
|
package/src/web/routes/api.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
RunDetailSchema,
|
|
13
13
|
RunIdParam,
|
|
14
14
|
} from "../schemas.ts";
|
|
15
|
+
import { renderProxyResponse, renderProxyError } from "../views/explorer-tab.ts";
|
|
15
16
|
|
|
16
17
|
const api = new OpenAPIHono();
|
|
17
18
|
|
|
@@ -114,6 +115,85 @@ api.openapi(runRoute, async (c) => {
|
|
|
114
115
|
}
|
|
115
116
|
});
|
|
116
117
|
|
|
118
|
+
// ──────────────────────────────────────────────
|
|
119
|
+
// POST /api/proxy — Explorer proxy for HTTP requests
|
|
120
|
+
// ──────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
api.post("/api/proxy", async (c) => {
|
|
123
|
+
const form = await c.req.parseBody();
|
|
124
|
+
const baseUrl = (form["base_url"] as string) ?? "";
|
|
125
|
+
const method = ((form["method"] as string) ?? "GET").toUpperCase();
|
|
126
|
+
let path = (form["path"] as string) ?? "/";
|
|
127
|
+
const body = (form["body"] as string) || undefined;
|
|
128
|
+
const contentType = (form["content_type"] as string) || undefined;
|
|
129
|
+
|
|
130
|
+
if (!baseUrl) {
|
|
131
|
+
return c.html(renderProxyError("Base URL is required", 0));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Substitute path parameters
|
|
135
|
+
for (const [key, value] of Object.entries(form)) {
|
|
136
|
+
if (typeof key === "string" && key.startsWith("param_path_") && value) {
|
|
137
|
+
const paramName = key.slice("param_path_".length);
|
|
138
|
+
path = path.replace(`{${paramName}}`, encodeURIComponent(value as string));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Build query string
|
|
143
|
+
const queryParams = new URLSearchParams();
|
|
144
|
+
for (const [key, value] of Object.entries(form)) {
|
|
145
|
+
if (typeof key === "string" && key.startsWith("param_query_") && value) {
|
|
146
|
+
queryParams.set(key.slice("param_query_".length), value as string);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Build headers
|
|
151
|
+
const headers: Record<string, string> = {};
|
|
152
|
+
for (const [key, value] of Object.entries(form)) {
|
|
153
|
+
if (typeof key === "string" && key.startsWith("param_header_") && value) {
|
|
154
|
+
headers[key.slice("param_header_".length)] = value as string;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Custom headers
|
|
158
|
+
for (let i = 0; i < 50; i++) {
|
|
159
|
+
const k = form[`custom_header_key_${i}`] as string | undefined;
|
|
160
|
+
const v = form[`custom_header_value_${i}`] as string | undefined;
|
|
161
|
+
if (!k && !v) break;
|
|
162
|
+
if (k && v) headers[k] = v;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (contentType && body) {
|
|
166
|
+
headers["Content-Type"] = contentType;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Build URL
|
|
170
|
+
let url: URL;
|
|
171
|
+
try {
|
|
172
|
+
url = new URL(path, baseUrl.endsWith("/") ? baseUrl : baseUrl + "/");
|
|
173
|
+
queryParams.forEach((v, k) => url.searchParams.set(k, v));
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return c.html(renderProxyError(`Invalid URL: ${baseUrl}${path} — ${(err as Error).message}`, 0));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const startTime = performance.now();
|
|
179
|
+
try {
|
|
180
|
+
const resp = await fetch(url.toString(), {
|
|
181
|
+
method,
|
|
182
|
+
headers,
|
|
183
|
+
body: ["GET", "HEAD"].includes(method) ? undefined : (body || undefined),
|
|
184
|
+
});
|
|
185
|
+
const elapsed = Math.round(performance.now() - startTime);
|
|
186
|
+
const respBody = await resp.text();
|
|
187
|
+
const respHeaders: Record<string, string> = {};
|
|
188
|
+
resp.headers.forEach((v, k) => { respHeaders[k] = v; });
|
|
189
|
+
|
|
190
|
+
return c.html(renderProxyResponse(resp.status, respHeaders, respBody, elapsed));
|
|
191
|
+
} catch (err) {
|
|
192
|
+
const elapsed = Math.round(performance.now() - startTime);
|
|
193
|
+
return c.html(renderProxyError((err as Error).message, elapsed));
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
117
197
|
// ──────────────────────────────────────────────
|
|
118
198
|
// Export helpers
|
|
119
199
|
// ──────────────────────────────────────────────
|
|
@@ -5,6 +5,7 @@ import { renderHealthStrip } from "../views/health-strip.ts";
|
|
|
5
5
|
import { renderEndpointsTab } from "../views/endpoints-tab.ts";
|
|
6
6
|
import { renderSuitesTab } from "../views/suites-tab.ts";
|
|
7
7
|
import { renderRunsTab, renderRunDetail } from "../views/runs-tab.ts";
|
|
8
|
+
import { renderExplorerTab } from "../views/explorer-tab.ts";
|
|
8
9
|
import { buildCollectionState, invalidateCollectionCache } from "../data/collection-state.ts";
|
|
9
10
|
import {
|
|
10
11
|
listCollections,
|
|
@@ -81,6 +82,16 @@ dashboard.get("/panels/endpoints", async (c) => {
|
|
|
81
82
|
return c.html(renderEndpointsTab(state, filters));
|
|
82
83
|
});
|
|
83
84
|
|
|
85
|
+
dashboard.get("/panels/explorer", async (c) => {
|
|
86
|
+
const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
|
|
87
|
+
if (isNaN(collectionId)) return c.html("");
|
|
88
|
+
|
|
89
|
+
const collection = getCollectionById(collectionId);
|
|
90
|
+
if (!collection) return c.html("");
|
|
91
|
+
|
|
92
|
+
return c.html(await renderExplorerTab(collection));
|
|
93
|
+
});
|
|
94
|
+
|
|
84
95
|
dashboard.get("/panels/suites", async (c) => {
|
|
85
96
|
const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
|
|
86
97
|
if (isNaN(collectionId)) return c.html("");
|
|
@@ -243,6 +254,10 @@ async function renderCollectionContent(collection: CollectionRecord): Promise<st
|
|
|
243
254
|
hx-get="/panels/runs-tab?collection_id=${collection.id}"
|
|
244
255
|
hx-target="#tab-content" hx-swap="innerHTML"
|
|
245
256
|
onclick="activateTab(this)">Runs <span class="tab-count">${runCount}</span></button>
|
|
257
|
+
<button class="tab-btn" data-tab="explorer"
|
|
258
|
+
hx-get="/panels/explorer?collection_id=${collection.id}"
|
|
259
|
+
hx-target="#tab-content" hx-swap="innerHTML"
|
|
260
|
+
onclick="activateTab(this)">Explorer</button>
|
|
246
261
|
</div>`;
|
|
247
262
|
|
|
248
263
|
// Default tab content (endpoints)
|
package/src/web/static/style.css
CHANGED
|
@@ -847,6 +847,294 @@ h3 { font-size: 1.05rem; margin: 1rem 0 0.25rem; }
|
|
|
847
847
|
100% { background: transparent; }
|
|
848
848
|
}
|
|
849
849
|
|
|
850
|
+
/* ═══════════════════════════════════════════════════
|
|
851
|
+
Explorer Tab
|
|
852
|
+
═══════════════════════════════════════════════════ */
|
|
853
|
+
|
|
854
|
+
.explorer-base-url {
|
|
855
|
+
display: flex;
|
|
856
|
+
align-items: center;
|
|
857
|
+
gap: 0.5rem;
|
|
858
|
+
padding: 0.75rem 1rem;
|
|
859
|
+
background: var(--bg-secondary);
|
|
860
|
+
border: 1px solid var(--border);
|
|
861
|
+
border-radius: var(--radius);
|
|
862
|
+
margin-bottom: 1rem;
|
|
863
|
+
}
|
|
864
|
+
.explorer-base-url .explorer-label {
|
|
865
|
+
font-size: 0.8rem;
|
|
866
|
+
font-weight: 600;
|
|
867
|
+
color: var(--text-dim);
|
|
868
|
+
white-space: nowrap;
|
|
869
|
+
}
|
|
870
|
+
.explorer-input {
|
|
871
|
+
background: var(--bg-inset);
|
|
872
|
+
border: 1px solid var(--border);
|
|
873
|
+
border-radius: var(--radius-sm);
|
|
874
|
+
color: var(--text);
|
|
875
|
+
font-family: var(--font-mono);
|
|
876
|
+
font-size: 0.85rem;
|
|
877
|
+
padding: 0.4rem 0.6rem;
|
|
878
|
+
flex: 1;
|
|
879
|
+
min-width: 0;
|
|
880
|
+
}
|
|
881
|
+
.explorer-input:focus {
|
|
882
|
+
outline: none;
|
|
883
|
+
border-color: var(--accent);
|
|
884
|
+
}
|
|
885
|
+
.explorer-input-sm { flex: unset; width: auto; }
|
|
886
|
+
|
|
887
|
+
/* Groups */
|
|
888
|
+
.explorer-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
889
|
+
.explorer-group {
|
|
890
|
+
border: 1px solid var(--border);
|
|
891
|
+
border-radius: var(--radius);
|
|
892
|
+
overflow: hidden;
|
|
893
|
+
}
|
|
894
|
+
.explorer-group-title {
|
|
895
|
+
font-size: 0.85rem;
|
|
896
|
+
font-weight: 600;
|
|
897
|
+
padding: 0.6rem 1rem;
|
|
898
|
+
background: var(--bg-secondary);
|
|
899
|
+
cursor: pointer;
|
|
900
|
+
display: flex;
|
|
901
|
+
align-items: center;
|
|
902
|
+
gap: 0.5rem;
|
|
903
|
+
}
|
|
904
|
+
.explorer-group-title .tab-count {
|
|
905
|
+
font-size: 0.75rem;
|
|
906
|
+
background: var(--bg-hover);
|
|
907
|
+
padding: 0.1rem 0.5rem;
|
|
908
|
+
border-radius: var(--radius-pill);
|
|
909
|
+
color: var(--text-dim);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/* Endpoint row */
|
|
913
|
+
.explorer-endpoint {
|
|
914
|
+
display: flex;
|
|
915
|
+
align-items: center;
|
|
916
|
+
gap: 0.5rem;
|
|
917
|
+
padding: 0.5rem 1rem;
|
|
918
|
+
cursor: pointer;
|
|
919
|
+
border-top: 1px solid var(--border-subtle);
|
|
920
|
+
transition: background 0.15s;
|
|
921
|
+
}
|
|
922
|
+
.explorer-endpoint:hover { background: var(--bg-hover); }
|
|
923
|
+
.explorer-endpoint-path {
|
|
924
|
+
font-family: var(--font-mono);
|
|
925
|
+
font-size: 0.85rem;
|
|
926
|
+
font-weight: 500;
|
|
927
|
+
}
|
|
928
|
+
.explorer-endpoint-summary {
|
|
929
|
+
color: var(--text-dim);
|
|
930
|
+
font-size: 0.8rem;
|
|
931
|
+
margin-left: auto;
|
|
932
|
+
white-space: nowrap;
|
|
933
|
+
overflow: hidden;
|
|
934
|
+
text-overflow: ellipsis;
|
|
935
|
+
}
|
|
936
|
+
.explorer-auth-hint {
|
|
937
|
+
font-size: 0.65rem;
|
|
938
|
+
font-weight: 600;
|
|
939
|
+
padding: 0.1rem 0.35rem;
|
|
940
|
+
border-radius: var(--radius-sm);
|
|
941
|
+
background: var(--warn-dim);
|
|
942
|
+
color: var(--warn);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/* Detail / form */
|
|
946
|
+
.explorer-detail {
|
|
947
|
+
padding: 1rem;
|
|
948
|
+
background: var(--bg-raised);
|
|
949
|
+
border-top: 1px solid var(--border);
|
|
950
|
+
}
|
|
951
|
+
.explorer-section {
|
|
952
|
+
margin-bottom: 1rem;
|
|
953
|
+
}
|
|
954
|
+
.explorer-section-title {
|
|
955
|
+
font-size: 0.8rem;
|
|
956
|
+
font-weight: 600;
|
|
957
|
+
color: var(--text-dim);
|
|
958
|
+
margin-bottom: 0.5rem;
|
|
959
|
+
display: flex;
|
|
960
|
+
align-items: center;
|
|
961
|
+
gap: 0.5rem;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/* Parameters */
|
|
965
|
+
.explorer-param-row {
|
|
966
|
+
display: grid;
|
|
967
|
+
grid-template-columns: 140px 50px 90px 1fr;
|
|
968
|
+
gap: 0.5rem;
|
|
969
|
+
align-items: center;
|
|
970
|
+
margin-bottom: 0.35rem;
|
|
971
|
+
font-size: 0.85rem;
|
|
972
|
+
}
|
|
973
|
+
.explorer-param-name {
|
|
974
|
+
font-family: var(--font-mono);
|
|
975
|
+
font-weight: 500;
|
|
976
|
+
font-size: 0.8rem;
|
|
977
|
+
}
|
|
978
|
+
.explorer-param-location {
|
|
979
|
+
font-size: 0.7rem;
|
|
980
|
+
color: var(--text-muted);
|
|
981
|
+
background: var(--bg-inset);
|
|
982
|
+
padding: 0.1rem 0.3rem;
|
|
983
|
+
border-radius: var(--radius-sm);
|
|
984
|
+
text-align: center;
|
|
985
|
+
}
|
|
986
|
+
.explorer-param-type {
|
|
987
|
+
font-size: 0.75rem;
|
|
988
|
+
color: var(--text-dim);
|
|
989
|
+
font-family: var(--font-mono);
|
|
990
|
+
}
|
|
991
|
+
.explorer-required {
|
|
992
|
+
color: var(--fail);
|
|
993
|
+
font-weight: 700;
|
|
994
|
+
margin-left: 0.15rem;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/* Body editor */
|
|
998
|
+
.explorer-body-editor {
|
|
999
|
+
width: 100%;
|
|
1000
|
+
min-height: 120px;
|
|
1001
|
+
background: var(--bg-inset);
|
|
1002
|
+
border: 1px solid var(--border);
|
|
1003
|
+
border-radius: var(--radius-sm);
|
|
1004
|
+
color: var(--text);
|
|
1005
|
+
font-family: var(--font-mono);
|
|
1006
|
+
font-size: 0.85rem;
|
|
1007
|
+
padding: 0.5rem;
|
|
1008
|
+
resize: vertical;
|
|
1009
|
+
tab-size: 2;
|
|
1010
|
+
}
|
|
1011
|
+
.explorer-body-editor:focus { outline: none; border-color: var(--accent); }
|
|
1012
|
+
.explorer-content-type {
|
|
1013
|
+
font-size: 0.75rem;
|
|
1014
|
+
padding: 0.15rem 0.35rem;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/* Custom headers */
|
|
1018
|
+
.explorer-headers-list {
|
|
1019
|
+
display: flex;
|
|
1020
|
+
flex-direction: column;
|
|
1021
|
+
gap: 0.35rem;
|
|
1022
|
+
}
|
|
1023
|
+
.explorer-header-pair {
|
|
1024
|
+
display: flex;
|
|
1025
|
+
gap: 0.35rem;
|
|
1026
|
+
align-items: center;
|
|
1027
|
+
}
|
|
1028
|
+
.explorer-header-pair input { flex: 1; }
|
|
1029
|
+
.explorer-remove-btn {
|
|
1030
|
+
background: none;
|
|
1031
|
+
border: 1px solid var(--border);
|
|
1032
|
+
border-radius: var(--radius-sm);
|
|
1033
|
+
color: var(--text-dim);
|
|
1034
|
+
cursor: pointer;
|
|
1035
|
+
font-size: 0.75rem;
|
|
1036
|
+
padding: 0.25rem 0.5rem;
|
|
1037
|
+
line-height: 1;
|
|
1038
|
+
}
|
|
1039
|
+
.explorer-remove-btn:hover { color: var(--fail); border-color: var(--fail); }
|
|
1040
|
+
.explorer-add-header-btn {
|
|
1041
|
+
background: none;
|
|
1042
|
+
border: none;
|
|
1043
|
+
color: var(--accent);
|
|
1044
|
+
cursor: pointer;
|
|
1045
|
+
font-size: 0.8rem;
|
|
1046
|
+
padding: 0.25rem 0;
|
|
1047
|
+
margin-top: 0.25rem;
|
|
1048
|
+
}
|
|
1049
|
+
.explorer-add-header-btn:hover { text-decoration: underline; }
|
|
1050
|
+
|
|
1051
|
+
/* Actions */
|
|
1052
|
+
.explorer-actions {
|
|
1053
|
+
display: flex;
|
|
1054
|
+
align-items: center;
|
|
1055
|
+
gap: 0.75rem;
|
|
1056
|
+
margin-top: 0.5rem;
|
|
1057
|
+
}
|
|
1058
|
+
.explorer-send-btn {
|
|
1059
|
+
background: var(--accent);
|
|
1060
|
+
color: #fff;
|
|
1061
|
+
border: none;
|
|
1062
|
+
border-radius: var(--radius);
|
|
1063
|
+
padding: 0.5rem 1.5rem;
|
|
1064
|
+
font-weight: 600;
|
|
1065
|
+
font-size: 0.85rem;
|
|
1066
|
+
cursor: pointer;
|
|
1067
|
+
transition: background 0.15s;
|
|
1068
|
+
}
|
|
1069
|
+
.explorer-send-btn:hover { background: var(--accent-hover); }
|
|
1070
|
+
.explorer-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
1071
|
+
.explorer-spinner { color: var(--text-dim); font-size: 0.85rem; }
|
|
1072
|
+
|
|
1073
|
+
/* Response */
|
|
1074
|
+
.explorer-response-container { margin-top: 1rem; }
|
|
1075
|
+
.explorer-response {
|
|
1076
|
+
border: 1px solid var(--border);
|
|
1077
|
+
border-radius: var(--radius);
|
|
1078
|
+
overflow: hidden;
|
|
1079
|
+
}
|
|
1080
|
+
.explorer-response-error { border-color: var(--fail); }
|
|
1081
|
+
.response-meta {
|
|
1082
|
+
display: flex;
|
|
1083
|
+
align-items: center;
|
|
1084
|
+
gap: 1rem;
|
|
1085
|
+
padding: 0.5rem 0.75rem;
|
|
1086
|
+
background: var(--bg-secondary);
|
|
1087
|
+
border-bottom: 1px solid var(--border);
|
|
1088
|
+
font-size: 0.85rem;
|
|
1089
|
+
}
|
|
1090
|
+
.response-status {
|
|
1091
|
+
font-weight: 700;
|
|
1092
|
+
font-family: var(--font-mono);
|
|
1093
|
+
}
|
|
1094
|
+
.response-status.status-2xx { color: var(--pass); }
|
|
1095
|
+
.response-status.status-3xx { color: var(--info); }
|
|
1096
|
+
.response-status.status-4xx { color: var(--warn); }
|
|
1097
|
+
.response-status.status-5xx { color: var(--fail); }
|
|
1098
|
+
.response-time { color: var(--text-dim); font-size: 0.8rem; }
|
|
1099
|
+
.response-size { color: var(--text-muted); font-size: 0.8rem; }
|
|
1100
|
+
.response-headers { padding: 0.5rem 0.75rem; }
|
|
1101
|
+
.response-headers summary {
|
|
1102
|
+
font-size: 0.8rem;
|
|
1103
|
+
color: var(--text-dim);
|
|
1104
|
+
cursor: pointer;
|
|
1105
|
+
}
|
|
1106
|
+
.response-headers-pre {
|
|
1107
|
+
font-size: 0.8rem;
|
|
1108
|
+
margin-top: 0.25rem;
|
|
1109
|
+
white-space: pre-wrap;
|
|
1110
|
+
word-break: break-all;
|
|
1111
|
+
}
|
|
1112
|
+
.response-body {
|
|
1113
|
+
padding: 0.75rem;
|
|
1114
|
+
background: var(--bg-inset);
|
|
1115
|
+
}
|
|
1116
|
+
.response-body pre {
|
|
1117
|
+
font-family: var(--font-mono);
|
|
1118
|
+
font-size: 0.8rem;
|
|
1119
|
+
white-space: pre-wrap;
|
|
1120
|
+
word-break: break-all;
|
|
1121
|
+
max-height: 500px;
|
|
1122
|
+
overflow-y: auto;
|
|
1123
|
+
margin: 0;
|
|
1124
|
+
}
|
|
1125
|
+
.response-error-msg {
|
|
1126
|
+
padding: 0.75rem;
|
|
1127
|
+
color: var(--fail);
|
|
1128
|
+
font-size: 0.85rem;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/* JSON syntax highlighting */
|
|
1132
|
+
.json-key { color: var(--accent); }
|
|
1133
|
+
.json-string { color: var(--pass); }
|
|
1134
|
+
.json-number { color: var(--warn); }
|
|
1135
|
+
.json-boolean { color: var(--method-patch); }
|
|
1136
|
+
.json-null { color: var(--text-dim); font-style: italic; }
|
|
1137
|
+
|
|
850
1138
|
/* ── Responsive ── */
|
|
851
1139
|
@media (max-width: 768px) {
|
|
852
1140
|
.health-strip { grid-template-columns: 1fr; gap: 1rem; }
|
|
@@ -855,4 +1143,6 @@ h3 { font-size: 1.05rem; margin: 1rem 0 0.25rem; }
|
|
|
855
1143
|
.run-row { grid-template-columns: 48px 1fr 60px; }
|
|
856
1144
|
.run-time, .run-duration { display: none; }
|
|
857
1145
|
.runs-header { display: none; }
|
|
1146
|
+
.explorer-param-row { grid-template-columns: 1fr; }
|
|
1147
|
+
.explorer-endpoint-summary { display: none; }
|
|
858
1148
|
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explorer tab: Swagger-like interactive API explorer.
|
|
3
|
+
* Renders endpoint forms, executes requests via server proxy, displays responses.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
7
|
+
import type { CollectionRecord } from "../../db/queries.ts";
|
|
8
|
+
import type { EndpointInfo } from "../../core/generator/types.ts";
|
|
9
|
+
import { escapeHtml } from "./layout.ts";
|
|
10
|
+
import { methodBadge } from "./results.ts";
|
|
11
|
+
|
|
12
|
+
// ── Public API ──
|
|
13
|
+
|
|
14
|
+
export async function renderExplorerTab(collection: CollectionRecord): Promise<string> {
|
|
15
|
+
if (!collection.openapi_spec) {
|
|
16
|
+
return `<div class="tab-empty">No OpenAPI spec configured. Register a spec with <code>setup_api</code> to see the explorer.</div>`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let doc: OpenAPIV3.Document;
|
|
20
|
+
let endpoints: EndpointInfo[];
|
|
21
|
+
try {
|
|
22
|
+
const { readOpenApiSpec, extractEndpoints } = await import("../../core/generator/openapi-reader.ts");
|
|
23
|
+
doc = await readOpenApiSpec(collection.openapi_spec);
|
|
24
|
+
endpoints = extractEndpoints(doc);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return `<div class="tab-empty">Failed to load OpenAPI spec: ${escapeHtml((err as Error).message)}</div>`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (endpoints.length === 0) {
|
|
30
|
+
return `<div class="tab-empty">No endpoints found in the OpenAPI spec.</div>`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Resolve base URLs from spec servers + env
|
|
34
|
+
const baseUrls: string[] = [];
|
|
35
|
+
if (doc.servers && doc.servers.length > 0) {
|
|
36
|
+
for (const s of doc.servers) {
|
|
37
|
+
if (s.url) baseUrls.push(s.url);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let envBaseUrl: string | undefined;
|
|
42
|
+
try {
|
|
43
|
+
const { loadEnvironment } = await import("../../core/parser/variables.ts");
|
|
44
|
+
const env = await loadEnvironment(undefined, collection.base_dir ?? collection.test_path);
|
|
45
|
+
if (env.base_url) {
|
|
46
|
+
envBaseUrl = env.base_url;
|
|
47
|
+
if (!baseUrls.includes(envBaseUrl)) baseUrls.unshift(envBaseUrl);
|
|
48
|
+
}
|
|
49
|
+
} catch { /* no env file */ }
|
|
50
|
+
|
|
51
|
+
// Base URL bar
|
|
52
|
+
const baseUrlBar = renderBaseUrlBar(baseUrls, envBaseUrl);
|
|
53
|
+
|
|
54
|
+
// Group endpoints by first tag
|
|
55
|
+
const groups = new Map<string, EndpointInfo[]>();
|
|
56
|
+
for (const ep of endpoints) {
|
|
57
|
+
const tag = ep.tags.length > 0 ? ep.tags[0]! : "Other";
|
|
58
|
+
const list = groups.get(tag) ?? [];
|
|
59
|
+
list.push(ep);
|
|
60
|
+
groups.set(tag, list);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let idx = 0;
|
|
64
|
+
const groupsHtml = [...groups.entries()].map(([tag, eps]) => {
|
|
65
|
+
const rows = eps.map(ep => {
|
|
66
|
+
const html = renderEndpointEntry(ep, idx, collection.id);
|
|
67
|
+
idx++;
|
|
68
|
+
return html;
|
|
69
|
+
}).join("");
|
|
70
|
+
return `<details class="explorer-group" open>
|
|
71
|
+
<summary class="explorer-group-title">${escapeHtml(tag)} <span class="tab-count">${eps.length}</span></summary>
|
|
72
|
+
${rows}
|
|
73
|
+
</details>`;
|
|
74
|
+
}).join("");
|
|
75
|
+
|
|
76
|
+
const script = `<script>
|
|
77
|
+
function explorerToggle(id) {
|
|
78
|
+
var el = document.getElementById(id);
|
|
79
|
+
if (!el) return;
|
|
80
|
+
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
|
81
|
+
}
|
|
82
|
+
function explorerAddHeader(btn) {
|
|
83
|
+
var container = btn.previousElementSibling;
|
|
84
|
+
var count = container.querySelectorAll('.explorer-header-pair').length;
|
|
85
|
+
var row = document.createElement('div');
|
|
86
|
+
row.className = 'explorer-header-pair';
|
|
87
|
+
row.innerHTML = '<input type="text" name="custom_header_key_' + count + '" placeholder="Header name" class="explorer-input explorer-input-sm">' +
|
|
88
|
+
'<input type="text" name="custom_header_value_' + count + '" placeholder="Value" class="explorer-input explorer-input-sm">' +
|
|
89
|
+
'<button type="button" class="explorer-remove-btn" onclick="this.parentElement.remove()">x</button>';
|
|
90
|
+
container.appendChild(row);
|
|
91
|
+
}
|
|
92
|
+
function explorerGetBaseUrl() {
|
|
93
|
+
var sel = document.getElementById('explorer-base-url-select');
|
|
94
|
+
var custom = document.getElementById('explorer-base-url-custom');
|
|
95
|
+
if (sel && sel.value === '__custom__') return custom ? custom.value : '';
|
|
96
|
+
return sel ? sel.value : (custom ? custom.value : '');
|
|
97
|
+
}
|
|
98
|
+
function explorerBeforeRequest(formId) {
|
|
99
|
+
var form = document.getElementById(formId);
|
|
100
|
+
if (!form) return true;
|
|
101
|
+
var input = form.querySelector('input[name="base_url"]');
|
|
102
|
+
if (input) input.value = explorerGetBaseUrl();
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
document.addEventListener('change', function(e) {
|
|
106
|
+
if (e.target && e.target.id === 'explorer-base-url-select') {
|
|
107
|
+
var custom = document.getElementById('explorer-base-url-custom');
|
|
108
|
+
if (custom) custom.style.display = e.target.value === '__custom__' ? 'block' : 'none';
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
</script>`;
|
|
112
|
+
|
|
113
|
+
return `${baseUrlBar}<div class="explorer-list">${groupsHtml}</div>${script}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function renderProxyResponse(status: number, headers: Record<string, string>, body: string, elapsedMs: number): string {
|
|
117
|
+
const statusClass = status < 300 ? "status-2xx" : status < 400 ? "status-3xx" : status < 500 ? "status-4xx" : "status-5xx";
|
|
118
|
+
const statusText = httpStatusText(status);
|
|
119
|
+
const size = body.length < 1024 ? `${body.length} B` : `${(body.length / 1024).toFixed(1)} KB`;
|
|
120
|
+
|
|
121
|
+
// Try to format JSON
|
|
122
|
+
let formattedBody: string;
|
|
123
|
+
const contentType = headers["content-type"] ?? "";
|
|
124
|
+
if (contentType.includes("json") || body.trimStart().startsWith("{") || body.trimStart().startsWith("[")) {
|
|
125
|
+
try {
|
|
126
|
+
const pretty = JSON.stringify(JSON.parse(body), null, 2);
|
|
127
|
+
formattedBody = highlightJson(pretty);
|
|
128
|
+
} catch {
|
|
129
|
+
formattedBody = escapeHtml(body);
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
formattedBody = escapeHtml(body);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const headerEntries = Object.entries(headers);
|
|
136
|
+
const headersHtml = headerEntries.length > 0
|
|
137
|
+
? `<details class="response-headers">
|
|
138
|
+
<summary>Headers (${headerEntries.length})</summary>
|
|
139
|
+
<pre class="response-headers-pre">${headerEntries.map(([k, v]) => `<span class="json-key">${escapeHtml(k)}</span>: ${escapeHtml(v)}`).join("\n")}</pre>
|
|
140
|
+
</details>`
|
|
141
|
+
: "";
|
|
142
|
+
|
|
143
|
+
return `<div class="explorer-response">
|
|
144
|
+
<div class="response-meta">
|
|
145
|
+
<span class="response-status ${statusClass}">${status} ${escapeHtml(statusText)}</span>
|
|
146
|
+
<span class="response-time">${elapsedMs}ms</span>
|
|
147
|
+
<span class="response-size">${size}</span>
|
|
148
|
+
</div>
|
|
149
|
+
${headersHtml}
|
|
150
|
+
<div class="response-body"><pre><code>${formattedBody}</code></pre></div>
|
|
151
|
+
</div>`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function renderProxyError(message: string, elapsedMs: number): string {
|
|
155
|
+
return `<div class="explorer-response explorer-response-error">
|
|
156
|
+
<div class="response-meta">
|
|
157
|
+
<span class="response-status status-5xx">Error</span>
|
|
158
|
+
<span class="response-time">${elapsedMs}ms</span>
|
|
159
|
+
</div>
|
|
160
|
+
<div class="response-error-msg">${escapeHtml(message)}</div>
|
|
161
|
+
</div>`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Private helpers ──
|
|
165
|
+
|
|
166
|
+
function renderBaseUrlBar(baseUrls: string[], envBaseUrl?: string): string {
|
|
167
|
+
if (baseUrls.length === 0) {
|
|
168
|
+
return `<div class="explorer-base-url">
|
|
169
|
+
<label class="explorer-label">Base URL</label>
|
|
170
|
+
<input type="text" id="explorer-base-url-custom" class="explorer-input" placeholder="https://api.example.com" value="">
|
|
171
|
+
</div>`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const options = baseUrls.map(url => {
|
|
175
|
+
const label = url === envBaseUrl ? `${url} (env)` : url;
|
|
176
|
+
return `<option value="${escapeHtml(url)}">${escapeHtml(label)}</option>`;
|
|
177
|
+
}).join("");
|
|
178
|
+
|
|
179
|
+
return `<div class="explorer-base-url">
|
|
180
|
+
<label class="explorer-label">Base URL</label>
|
|
181
|
+
<select id="explorer-base-url-select" class="explorer-input">
|
|
182
|
+
${options}
|
|
183
|
+
<option value="__custom__">Custom...</option>
|
|
184
|
+
</select>
|
|
185
|
+
<input type="text" id="explorer-base-url-custom" class="explorer-input" placeholder="https://api.example.com" style="display:none;">
|
|
186
|
+
</div>`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function renderEndpointEntry(ep: EndpointInfo, index: number, collectionId: number): string {
|
|
190
|
+
const formId = `explorer-form-${index}`;
|
|
191
|
+
const detailId = `explorer-detail-${index}`;
|
|
192
|
+
const responseId = `explorer-response-${index}`;
|
|
193
|
+
const spinnerId = `explorer-spinner-${index}`;
|
|
194
|
+
const deprecated = ep.deprecated ? ' <span class="warning-badge warning-deprecated">DEPRECATED</span>' : "";
|
|
195
|
+
const securityHint = ep.security.length > 0
|
|
196
|
+
? ` <span class="explorer-auth-hint" title="Requires: ${escapeHtml(ep.security.join(", "))}">Auth</span>`
|
|
197
|
+
: "";
|
|
198
|
+
|
|
199
|
+
// Separate parameters by location
|
|
200
|
+
const pathParams = ep.parameters.filter(p => p.in === "path");
|
|
201
|
+
const queryParams = ep.parameters.filter(p => p.in === "query");
|
|
202
|
+
const headerParams = ep.parameters.filter(p => p.in === "header");
|
|
203
|
+
|
|
204
|
+
// Request body
|
|
205
|
+
const hasBody = ["POST", "PUT", "PATCH"].includes(ep.method);
|
|
206
|
+
const exampleBody = hasBody && ep.requestBodySchema
|
|
207
|
+
? JSON.stringify(generateExample(ep.requestBodySchema), null, 2)
|
|
208
|
+
: "";
|
|
209
|
+
const bodyContentType = ep.requestBodyContentType ?? "application/json";
|
|
210
|
+
|
|
211
|
+
const paramsHtml = renderParamsSection(pathParams, queryParams, headerParams);
|
|
212
|
+
const bodyHtml = hasBody ? renderBodySection(exampleBody, bodyContentType) : "";
|
|
213
|
+
const headersHtml = renderCustomHeadersSection();
|
|
214
|
+
|
|
215
|
+
return `
|
|
216
|
+
<div class="explorer-endpoint" onclick="explorerToggle('${detailId}')">
|
|
217
|
+
${methodBadge(ep.method)}
|
|
218
|
+
<span class="explorer-endpoint-path">${escapeHtml(ep.path)}</span>
|
|
219
|
+
${deprecated}${securityHint}
|
|
220
|
+
${ep.summary ? `<span class="explorer-endpoint-summary">${escapeHtml(ep.summary)}</span>` : ""}
|
|
221
|
+
</div>
|
|
222
|
+
<div class="explorer-detail" id="${detailId}" style="display:none" onclick="event.stopPropagation()">
|
|
223
|
+
<form id="${formId}" hx-post="/api/proxy" hx-target="#${responseId}" hx-swap="innerHTML"
|
|
224
|
+
hx-indicator="#${spinnerId}"
|
|
225
|
+
hx-vals='js:{"base_url": explorerGetBaseUrl()}'
|
|
226
|
+
hx-disabled-elt="find .explorer-send-btn">
|
|
227
|
+
<input type="hidden" name="method" value="${ep.method}">
|
|
228
|
+
<input type="hidden" name="path" value="${escapeHtml(ep.path)}">
|
|
229
|
+
<input type="hidden" name="collection_id" value="${collectionId}">
|
|
230
|
+
${paramsHtml}
|
|
231
|
+
${bodyHtml}
|
|
232
|
+
${headersHtml}
|
|
233
|
+
<div class="explorer-actions">
|
|
234
|
+
<button type="submit" class="btn explorer-send-btn">Send</button>
|
|
235
|
+
<span id="${spinnerId}" class="htmx-indicator explorer-spinner">Sending...</span>
|
|
236
|
+
</div>
|
|
237
|
+
</form>
|
|
238
|
+
<div id="${responseId}" class="explorer-response-container"></div>
|
|
239
|
+
</div>`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function renderParamsSection(
|
|
243
|
+
pathParams: OpenAPIV3.ParameterObject[],
|
|
244
|
+
queryParams: OpenAPIV3.ParameterObject[],
|
|
245
|
+
headerParams: OpenAPIV3.ParameterObject[],
|
|
246
|
+
): string {
|
|
247
|
+
const all = [
|
|
248
|
+
...pathParams.map(p => ({ ...p, prefix: "param_path_" })),
|
|
249
|
+
...queryParams.map(p => ({ ...p, prefix: "param_query_" })),
|
|
250
|
+
...headerParams.map(p => ({ ...p, prefix: "param_header_" })),
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
if (all.length === 0) return "";
|
|
254
|
+
|
|
255
|
+
const rows = all.map(p => {
|
|
256
|
+
const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
|
|
257
|
+
const type = schema?.type ?? "string";
|
|
258
|
+
const required = p.required ? '<span class="explorer-required">*</span>' : "";
|
|
259
|
+
const locationLabel = p.in === "path" ? "path" : p.in === "query" ? "query" : "header";
|
|
260
|
+
const placeholder = schema?.example != null ? String(schema.example) : (schema?.enum ? schema.enum[0] : "");
|
|
261
|
+
const defaultVal = schema?.default != null ? String(schema.default) : "";
|
|
262
|
+
const description = p.description ? ` title="${escapeHtml(p.description)}"` : "";
|
|
263
|
+
|
|
264
|
+
return `<div class="explorer-param-row"${description}>
|
|
265
|
+
<span class="explorer-param-name">${escapeHtml(p.name)}${required}</span>
|
|
266
|
+
<span class="explorer-param-location">${locationLabel}</span>
|
|
267
|
+
<span class="explorer-param-type">${escapeHtml(type)}${schema?.format ? ` (${escapeHtml(schema.format)})` : ""}</span>
|
|
268
|
+
<input type="text" name="${p.prefix}${escapeHtml(p.name)}" class="explorer-input"
|
|
269
|
+
placeholder="${escapeHtml(placeholder)}" value="${escapeHtml(defaultVal)}"
|
|
270
|
+
${p.required ? "required" : ""}>
|
|
271
|
+
</div>`;
|
|
272
|
+
}).join("");
|
|
273
|
+
|
|
274
|
+
return `<div class="explorer-section">
|
|
275
|
+
<div class="explorer-section-title">Parameters</div>
|
|
276
|
+
${rows}
|
|
277
|
+
</div>`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function renderBodySection(exampleBody: string, contentType: string): string {
|
|
281
|
+
return `<div class="explorer-section">
|
|
282
|
+
<div class="explorer-section-title">Request Body
|
|
283
|
+
<select name="content_type" class="explorer-input explorer-input-sm explorer-content-type">
|
|
284
|
+
<option value="application/json"${contentType === "application/json" ? " selected" : ""}>application/json</option>
|
|
285
|
+
<option value="application/x-www-form-urlencoded"${contentType === "application/x-www-form-urlencoded" ? " selected" : ""}>form-urlencoded</option>
|
|
286
|
+
</select>
|
|
287
|
+
</div>
|
|
288
|
+
<textarea name="body" class="explorer-body-editor" rows="8" spellcheck="false">${escapeHtml(exampleBody)}</textarea>
|
|
289
|
+
</div>`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function renderCustomHeadersSection(): string {
|
|
293
|
+
return `<div class="explorer-section">
|
|
294
|
+
<div class="explorer-section-title">Headers</div>
|
|
295
|
+
<div class="explorer-headers-list">
|
|
296
|
+
<div class="explorer-header-pair">
|
|
297
|
+
<input type="text" name="custom_header_key_0" placeholder="Header name" class="explorer-input explorer-input-sm">
|
|
298
|
+
<input type="text" name="custom_header_value_0" placeholder="Value" class="explorer-input explorer-input-sm">
|
|
299
|
+
<button type="button" class="explorer-remove-btn" onclick="this.parentElement.remove()">x</button>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
<button type="button" class="explorer-add-header-btn" onclick="explorerAddHeader(this)">+ Add header</button>
|
|
303
|
+
</div>`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function generateExample(schema: OpenAPIV3.SchemaObject, depth = 0): unknown {
|
|
307
|
+
if (depth > 5) return {};
|
|
308
|
+
|
|
309
|
+
if (schema.example !== undefined) return schema.example;
|
|
310
|
+
|
|
311
|
+
if (schema.enum && schema.enum.length > 0) return schema.enum[0];
|
|
312
|
+
|
|
313
|
+
if (schema.allOf) {
|
|
314
|
+
const merged: Record<string, unknown> = {};
|
|
315
|
+
for (const sub of schema.allOf) {
|
|
316
|
+
const s = sub as OpenAPIV3.SchemaObject;
|
|
317
|
+
const val = generateExample(s, depth + 1);
|
|
318
|
+
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
|
|
319
|
+
Object.assign(merged, val);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return merged;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (schema.oneOf && schema.oneOf.length > 0) {
|
|
326
|
+
return generateExample(schema.oneOf[0] as OpenAPIV3.SchemaObject, depth + 1);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (schema.anyOf && schema.anyOf.length > 0) {
|
|
330
|
+
return generateExample(schema.anyOf[0] as OpenAPIV3.SchemaObject, depth + 1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
switch (schema.type) {
|
|
334
|
+
case "string":
|
|
335
|
+
if (schema.format === "email") return "user@example.com";
|
|
336
|
+
if (schema.format === "date") return "2026-01-01";
|
|
337
|
+
if (schema.format === "date-time") return "2026-01-01T00:00:00Z";
|
|
338
|
+
if (schema.format === "uri" || schema.format === "url") return "https://example.com";
|
|
339
|
+
if (schema.format === "uuid") return "550e8400-e29b-41d4-a716-446655440000";
|
|
340
|
+
return "string";
|
|
341
|
+
case "integer":
|
|
342
|
+
return schema.minimum != null ? schema.minimum : 0;
|
|
343
|
+
case "number":
|
|
344
|
+
return schema.minimum != null ? schema.minimum : 0.0;
|
|
345
|
+
case "boolean":
|
|
346
|
+
return true;
|
|
347
|
+
case "array": {
|
|
348
|
+
const items = schema.items as OpenAPIV3.SchemaObject | undefined;
|
|
349
|
+
if (items) return [generateExample(items, depth + 1)];
|
|
350
|
+
return [];
|
|
351
|
+
}
|
|
352
|
+
case "object":
|
|
353
|
+
default:
|
|
354
|
+
if (schema.properties) {
|
|
355
|
+
const result: Record<string, unknown> = {};
|
|
356
|
+
for (const [key, propObj] of Object.entries(schema.properties)) {
|
|
357
|
+
result[key] = generateExample(propObj as OpenAPIV3.SchemaObject, depth + 1);
|
|
358
|
+
}
|
|
359
|
+
return result;
|
|
360
|
+
}
|
|
361
|
+
return {};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function highlightJson(json: string): string {
|
|
366
|
+
// Split into tokens and non-token text, escape everything properly
|
|
367
|
+
const tokenRe = /("(?:\\.|[^"\\])*")\s*:|("(?:\\.|[^"\\])*")|([-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(true|false)\b|\b(null)\b/g;
|
|
368
|
+
let result = "";
|
|
369
|
+
let lastIndex = 0;
|
|
370
|
+
let m: RegExpExecArray | null;
|
|
371
|
+
|
|
372
|
+
while ((m = tokenRe.exec(json)) !== null) {
|
|
373
|
+
// Escape text between tokens (brackets, commas, whitespace, colons)
|
|
374
|
+
if (m.index > lastIndex) {
|
|
375
|
+
result += escapeHtml(json.slice(lastIndex, m.index));
|
|
376
|
+
}
|
|
377
|
+
const [, key, str, num, bool, nil] = m;
|
|
378
|
+
if (key) result += `<span class="json-key">${escapeHtml(key)}</span>:`;
|
|
379
|
+
else if (str) result += `<span class="json-string">${escapeHtml(str)}</span>`;
|
|
380
|
+
else if (num) result += `<span class="json-number">${num}</span>`;
|
|
381
|
+
else if (bool) result += `<span class="json-boolean">${bool}</span>`;
|
|
382
|
+
else if (nil) result += `<span class="json-null">null</span>`;
|
|
383
|
+
lastIndex = tokenRe.lastIndex;
|
|
384
|
+
}
|
|
385
|
+
// Remaining text after last token
|
|
386
|
+
if (lastIndex < json.length) {
|
|
387
|
+
result += escapeHtml(json.slice(lastIndex));
|
|
388
|
+
}
|
|
389
|
+
return result;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function httpStatusText(code: number): string {
|
|
393
|
+
const map: Record<number, string> = {
|
|
394
|
+
200: "OK", 201: "Created", 204: "No Content",
|
|
395
|
+
301: "Moved Permanently", 302: "Found", 304: "Not Modified",
|
|
396
|
+
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found",
|
|
397
|
+
405: "Method Not Allowed", 409: "Conflict", 422: "Unprocessable Entity",
|
|
398
|
+
429: "Too Many Requests",
|
|
399
|
+
500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable",
|
|
400
|
+
};
|
|
401
|
+
return map[code] ?? "";
|
|
402
|
+
}
|