mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix(dashboard): improve API try it functionality (#4296)
* fix(dashboard): improve api try it functionality and allow manual key entry * test(api): cover generateExampleFromSchema for the Try It panel (#4296) Export generateExampleFromSchema from the /api/openapi/spec route and add a unit test covering type handling, property-name heuristics, $ref/oneOf/anyOf/ allOf resolution, the 'required + first 3 optional' object policy, and the depth-3 recursion guard — the example bodies the dashboard Try It panel pre-fills. Rule #18 regression guard for the new helper. Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> --------- Co-authored-by: ci <ci@local> Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
This commit is contained in:
@@ -17,6 +17,7 @@ interface Endpoint {
|
||||
security: boolean;
|
||||
parameters: any[];
|
||||
requestBody: boolean;
|
||||
exampleBody?: any;
|
||||
responses: string[];
|
||||
loopbackOnly?: boolean;
|
||||
alwaysProtected?: boolean;
|
||||
@@ -100,6 +101,13 @@ export default function ApiEndpointsTab() {
|
||||
const [tryBody, setTryBody] = useState("");
|
||||
const [tryResult, setTryResult] = useState<TryItResult | null>(null);
|
||||
const [trying, setTrying] = useState(false);
|
||||
const [availableApiKeys, setAvailableApiKeys] = useState<Array<{ id: string; key: string }>>([]);
|
||||
const [selectedApiKeyId, setSelectedApiKeyId] = useState<string>("");
|
||||
const [revealedApiKeys, setRevealedApiKeys] = useState<Record<string, string>>({});
|
||||
const [apiKeyLoadError, setApiKeyLoadError] = useState<string | null>(null);
|
||||
const [manualApiKey, setManualApiKey] = useState("");
|
||||
const [useManualKey, setUseManualKey] = useState(false);
|
||||
const selectedApiKey = availableApiKeys.find((apiKey) => apiKey.id === selectedApiKeyId) || null;
|
||||
|
||||
const loadCatalog = async () => {
|
||||
try {
|
||||
@@ -134,6 +142,38 @@ export default function ApiEndpointsTab() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load API keys for Try It functionality. The list endpoint returns masked
|
||||
// keys; the selected key is revealed only when sending a Try It request.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/api/keys?limit=100", { credentials: "same-origin" })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`Failed to load API keys (${res.status})`);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
const rows = Array.isArray(data?.keys) ? data.keys : Array.isArray(data) ? data : [];
|
||||
const keys = rows
|
||||
.filter((k) => k?.id && k.isActive !== false && k.isBanned !== true)
|
||||
.map((k) => ({ id: String(k.id), key: String(k.key || k.id) }));
|
||||
setAvailableApiKeys(keys);
|
||||
setApiKeyLoadError(null);
|
||||
if (keys.length > 0) {
|
||||
setSelectedApiKeyId((current) => current || keys[0].id);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
setAvailableApiKeys([]);
|
||||
setApiKeyLoadError(error instanceof Error ? error.message : "Failed to load API keys");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Filter endpoints
|
||||
const filteredEndpoints = useMemo(() => {
|
||||
if (!catalog) return [];
|
||||
@@ -176,6 +216,13 @@ export default function ApiEndpointsTab() {
|
||||
}, [catalog]);
|
||||
|
||||
// Try It handler
|
||||
// Generate example body from OpenAPI schema
|
||||
const generateExampleBody = (ep: Endpoint): string => {
|
||||
if (ep.method === "GET") return "";
|
||||
if (ep.exampleBody) return JSON.stringify(ep.exampleBody, null, 2);
|
||||
return "{\n \n}";
|
||||
};
|
||||
|
||||
const handleTryIt = async (ep: Endpoint) => {
|
||||
const key = `${ep.method}:${ep.path}`;
|
||||
if (tryingEndpoint === key) {
|
||||
@@ -185,22 +232,67 @@ export default function ApiEndpointsTab() {
|
||||
}
|
||||
setTryingEndpoint(key);
|
||||
setTryResult(null);
|
||||
setTryBody(ep.method === "GET" ? "" : "{\n \n}");
|
||||
setTryBody(generateExampleBody(ep));
|
||||
};
|
||||
|
||||
const revealSelectedApiKey = async () => {
|
||||
if (!selectedApiKey) return "";
|
||||
if (revealedApiKeys[selectedApiKey.id]) return revealedApiKeys[selectedApiKey.id];
|
||||
|
||||
const res = await fetch(`/api/keys/${encodeURIComponent(selectedApiKey.id)}/reveal`, {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
res.status === 403
|
||||
? "API key reveal is disabled (ALLOW_API_KEY_REVEAL). Change it in the feature flag page or paste an API key manually."
|
||||
: `Failed to reveal API key (${res.status})`
|
||||
);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data?.key || typeof data.key !== "string") {
|
||||
throw new Error("API key reveal returned an invalid response");
|
||||
}
|
||||
setRevealedApiKeys((current) => ({ ...current, [selectedApiKey.id]: data.key }));
|
||||
return data.key;
|
||||
};
|
||||
|
||||
const executeTryIt = async (ep: Endpoint) => {
|
||||
setTrying(true);
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Add Authorization header if endpoint requires auth
|
||||
if (ep.security) {
|
||||
let apiKeyForRequest = "";
|
||||
if (useManualKey) {
|
||||
apiKeyForRequest = manualApiKey;
|
||||
} else if (selectedApiKey) {
|
||||
apiKeyForRequest = await revealSelectedApiKey();
|
||||
}
|
||||
|
||||
if (apiKeyForRequest) {
|
||||
headers["Authorization"] = `Bearer ${apiKeyForRequest}`;
|
||||
} else {
|
||||
throw new Error("API key is required for this endpoint.");
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch("/api/openapi/try", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
method: ep.method,
|
||||
path: ep.path.replace("/api/", "/"),
|
||||
path: ep.path,
|
||||
headers,
|
||||
body: tryBody ? JSON.parse(tryBody) : undefined,
|
||||
}),
|
||||
});
|
||||
if (res.ok) setTryResult(await res.json());
|
||||
else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || `Request failed (${res.status})`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setTryResult({
|
||||
status: 0,
|
||||
@@ -504,7 +596,7 @@ export default function ApiEndpointsTab() {
|
||||
</p>
|
||||
<code className="text-[11px] font-mono text-text-main break-all">
|
||||
curl -X {ep.method} {baseUrl}
|
||||
{ep.path.replace("/api/", "/")}
|
||||
{ep.path}
|
||||
{ep.security ? ' -H "Authorization: Bearer YOUR_KEY"' : ""}
|
||||
{ep.requestBody
|
||||
? " -H \"Content-Type: application/json\" -d '{...}'"
|
||||
@@ -515,6 +607,53 @@ export default function ApiEndpointsTab() {
|
||||
{/* Try It panel */}
|
||||
{isTrying && (
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/[0.02] p-3 space-y-3">
|
||||
{ep.security && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-[9px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
API Key
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUseManualKey(!useManualKey)}
|
||||
className="text-[9px] text-primary hover:underline"
|
||||
>
|
||||
{useManualKey ? "Switch to Selection" : "Enter Manually"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{useManualKey ? (
|
||||
<input
|
||||
type="password"
|
||||
value={manualApiKey}
|
||||
onChange={(e) => setManualApiKey(e.target.value)}
|
||||
placeholder="Paste your API key here"
|
||||
className="w-full px-3 py-2 text-xs font-mono rounded-lg border border-black/10
|
||||
dark:border-white/10 bg-white dark:bg-black/30 focus:outline-none
|
||||
focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
) : availableApiKeys.length > 0 ? (
|
||||
<select
|
||||
value={selectedApiKeyId}
|
||||
onChange={(e) => setSelectedApiKeyId(e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs font-mono rounded-lg border border-black/10
|
||||
dark:border-white/10 bg-white dark:bg-black/30 focus:outline-none
|
||||
focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{availableApiKeys.map((apiKey) => (
|
||||
<option key={apiKey.id} value={apiKey.id}>
|
||||
{apiKey.key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-[11px] text-amber-500">
|
||||
{apiKeyLoadError ||
|
||||
"No active API keys found. Toggle manual entry to paste one."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{ep.method !== "GET" && (
|
||||
<div>
|
||||
<label className="text-[9px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
@@ -523,7 +662,7 @@ export default function ApiEndpointsTab() {
|
||||
<textarea
|
||||
value={tryBody}
|
||||
onChange={(e) => setTryBody(e.target.value)}
|
||||
rows={4}
|
||||
rows={8}
|
||||
className="w-full mt-1 px-3 py-2 text-xs font-mono rounded-lg border border-black/10
|
||||
dark:border-white/10 bg-white dark:bg-black/30 focus:outline-none
|
||||
focus:ring-1 focus:ring-primary resize-none"
|
||||
|
||||
@@ -18,6 +18,99 @@ const OPENAPI_SPEC_CANDIDATES = [
|
||||
path.join(/* turbopackIgnore: true */ process.cwd(), "app", "docs", "openapi.yaml"),
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate example value from OpenAPI schema.
|
||||
*
|
||||
* Exported for unit testing: the dashboard "Try It" panel pre-fills request
|
||||
* bodies from this. Bounded to depth 3 to prevent infinite recursion on
|
||||
* self-referential `$ref` schemas.
|
||||
*/
|
||||
export function generateExampleFromSchema(
|
||||
schema: any,
|
||||
components: any,
|
||||
depth = 0,
|
||||
propertyName = ""
|
||||
): any {
|
||||
if (!schema || depth > 3) return null; // Prevent infinite recursion
|
||||
|
||||
// Use example if provided
|
||||
if (schema.example !== undefined) return schema.example;
|
||||
if (schema.default !== undefined) return schema.default;
|
||||
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
|
||||
return generateExampleFromSchema(schema.oneOf[0], components, depth + 1, propertyName);
|
||||
}
|
||||
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
||||
return generateExampleFromSchema(schema.anyOf[0], components, depth + 1, propertyName);
|
||||
}
|
||||
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
|
||||
return schema.allOf.reduce((acc: any, item: any) => {
|
||||
const value = generateExampleFromSchema(item, components, depth + 1, propertyName);
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? { ...acc, ...value }
|
||||
: acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// Handle $ref
|
||||
if (schema.$ref) {
|
||||
const refPath = schema.$ref.replace("#/components/schemas/", "");
|
||||
return generateExampleFromSchema(components[refPath], components, depth + 1, propertyName);
|
||||
}
|
||||
|
||||
// Handle type
|
||||
switch (schema.type) {
|
||||
case "string":
|
||||
if (schema.enum) return schema.enum[0];
|
||||
if (schema.format === "date-time") return "2024-01-01T00:00:00Z";
|
||||
if (schema.format === "email") return "user@example.com";
|
||||
if (schema.format === "uri") return "https://example.com";
|
||||
if (/model/i.test(propertyName)) return "openai/gpt-4o";
|
||||
if (/prompt/i.test(propertyName)) return "Write a function to sort an array";
|
||||
if (/system/i.test(propertyName)) return "You are a concise, helpful assistant.";
|
||||
if (/query/i.test(propertyName)) return "What is the capital of France?";
|
||||
if (/input|text|content/i.test(propertyName)) return "Sample text";
|
||||
if (/provider/i.test(propertyName)) return "openai";
|
||||
if (/url/i.test(propertyName)) return "https://example.com";
|
||||
return "string";
|
||||
|
||||
case "number":
|
||||
case "integer":
|
||||
return schema.default !== undefined ? schema.default : schema.minimum || 0;
|
||||
|
||||
case "boolean":
|
||||
return schema.default !== undefined ? schema.default : false;
|
||||
|
||||
case "array":
|
||||
if (schema.items) {
|
||||
const item = generateExampleFromSchema(schema.items, components, depth + 1, propertyName);
|
||||
return item ? [item] : [];
|
||||
}
|
||||
return [];
|
||||
|
||||
case "object":
|
||||
const obj: any = {};
|
||||
if (schema.properties) {
|
||||
const required = schema.required || [];
|
||||
// Include required fields + first 3 optional fields
|
||||
const propsToInclude = [
|
||||
...required,
|
||||
...Object.keys(schema.properties)
|
||||
.filter((k) => !required.includes(k))
|
||||
.slice(0, 3),
|
||||
];
|
||||
|
||||
for (const key of propsToInclude) {
|
||||
const propSchema = schema.properties[key];
|
||||
obj[key] = generateExampleFromSchema(propSchema, components, depth + 1, key);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
let specPath = "";
|
||||
@@ -58,6 +151,18 @@ export async function GET() {
|
||||
if (!methods || typeof methods !== "object") continue;
|
||||
for (const [method, spec] of Object.entries(methods as Record<string, any>)) {
|
||||
if (["get", "post", "put", "patch", "delete"].includes(method) && spec) {
|
||||
// Extract example from request body schema if available
|
||||
let exampleBody: any = null;
|
||||
const jsonBody = spec.requestBody?.content?.["application/json"];
|
||||
if (jsonBody?.example !== undefined) {
|
||||
exampleBody = jsonBody.example;
|
||||
} else if (jsonBody?.examples && typeof jsonBody.examples === "object") {
|
||||
const firstExample = Object.values(jsonBody.examples)[0] as any;
|
||||
exampleBody = firstExample?.value ?? firstExample;
|
||||
} else if (jsonBody?.schema) {
|
||||
exampleBody = generateExampleFromSchema(jsonBody.schema, raw.components?.schemas || {});
|
||||
}
|
||||
|
||||
catalog.endpoints.push({
|
||||
method: method.toUpperCase(),
|
||||
path: pathStr,
|
||||
@@ -67,6 +172,7 @@ export async function GET() {
|
||||
security: spec.security ? true : false,
|
||||
parameters: spec.parameters || [],
|
||||
requestBody: spec.requestBody ? true : false,
|
||||
exampleBody,
|
||||
responses: Object.keys(spec.responses || {}),
|
||||
loopbackOnly: spec["x-loopback-only"] === true,
|
||||
alwaysProtected: spec["x-always-protected"] === true,
|
||||
|
||||
105
tests/unit/openapi-spec-example-body.test.ts
Normal file
105
tests/unit/openapi-spec-example-body.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #4296: the dashboard "Try It" panel pre-fills request bodies generated from
|
||||
// the OpenAPI request-body schema by generateExampleFromSchema (in the
|
||||
// /api/openapi/spec route). These tests pin that generator's behaviour: type
|
||||
// handling, property-name heuristics, $ref/oneOf/anyOf/allOf resolution, the
|
||||
// "required + first 3 optional" object policy, and the depth-3 recursion guard.
|
||||
const { generateExampleFromSchema } = await import("@/app/api/openapi/spec/route");
|
||||
|
||||
const NO_COMPONENTS = {};
|
||||
|
||||
test("uses an explicit example/default before anything else", () => {
|
||||
assert.equal(generateExampleFromSchema({ type: "string", example: "hi" }, NO_COMPONENTS), "hi");
|
||||
assert.equal(generateExampleFromSchema({ type: "number", default: 7 }, NO_COMPONENTS), 7);
|
||||
});
|
||||
|
||||
test("string types: enum, formats, and a plain default", () => {
|
||||
assert.equal(generateExampleFromSchema({ type: "string", enum: ["a", "b"] }, NO_COMPONENTS), "a");
|
||||
assert.equal(
|
||||
generateExampleFromSchema({ type: "string", format: "date-time" }, NO_COMPONENTS),
|
||||
"2024-01-01T00:00:00Z"
|
||||
);
|
||||
assert.equal(
|
||||
generateExampleFromSchema({ type: "string", format: "email" }, NO_COMPONENTS),
|
||||
"user@example.com"
|
||||
);
|
||||
assert.equal(generateExampleFromSchema({ type: "string" }, NO_COMPONENTS), "string");
|
||||
});
|
||||
|
||||
test("string property-name heuristics produce realistic values", () => {
|
||||
const s = (name: string) =>
|
||||
generateExampleFromSchema({ type: "string" }, NO_COMPONENTS, 0, name);
|
||||
assert.equal(s("model"), "openai/gpt-4o");
|
||||
assert.equal(s("prompt"), "Write a function to sort an array");
|
||||
assert.equal(s("system"), "You are a concise, helpful assistant.");
|
||||
assert.equal(s("query"), "What is the capital of France?");
|
||||
assert.equal(s("provider"), "openai");
|
||||
});
|
||||
|
||||
test("number / integer / boolean defaults", () => {
|
||||
assert.equal(generateExampleFromSchema({ type: "integer", minimum: 3 }, NO_COMPONENTS), 3);
|
||||
assert.equal(generateExampleFromSchema({ type: "number" }, NO_COMPONENTS), 0);
|
||||
assert.equal(generateExampleFromSchema({ type: "boolean" }, NO_COMPONENTS), false);
|
||||
assert.equal(generateExampleFromSchema({ type: "boolean", default: true }, NO_COMPONENTS), true);
|
||||
});
|
||||
|
||||
test("arrays wrap a single generated item", () => {
|
||||
assert.deepEqual(
|
||||
generateExampleFromSchema({ type: "array", items: { type: "string" } }, NO_COMPONENTS),
|
||||
["string"]
|
||||
);
|
||||
});
|
||||
|
||||
test("objects include all required + only the first 3 optional properties", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
required: ["model"],
|
||||
properties: {
|
||||
model: { type: "string" },
|
||||
a: { type: "string" },
|
||||
b: { type: "string" },
|
||||
c: { type: "string" },
|
||||
d: { type: "string" }, // 4th optional — must be dropped
|
||||
},
|
||||
};
|
||||
const out = generateExampleFromSchema(schema, NO_COMPONENTS) as Record<string, unknown>;
|
||||
assert.equal(out.model, "openai/gpt-4o");
|
||||
assert.deepEqual(Object.keys(out).sort(), ["a", "b", "c", "model"]);
|
||||
assert.ok(!("d" in out), "the 4th optional property must be omitted");
|
||||
});
|
||||
|
||||
test("resolves $ref against components and merges allOf", () => {
|
||||
const components = { Msg: { type: "object", properties: { role: { type: "string" } } } };
|
||||
const refOut = generateExampleFromSchema({ $ref: "#/components/schemas/Msg" }, components) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(refOut.role, "string");
|
||||
|
||||
const allOfOut = generateExampleFromSchema(
|
||||
{ allOf: [{ type: "object", properties: { x: { type: "boolean" } } }, { $ref: "#/components/schemas/Msg" }] },
|
||||
components
|
||||
) as Record<string, unknown>;
|
||||
assert.equal(allOfOut.x, false);
|
||||
assert.equal(allOfOut.role, "string");
|
||||
});
|
||||
|
||||
test("oneOf / anyOf pick the first branch", () => {
|
||||
assert.equal(
|
||||
generateExampleFromSchema({ oneOf: [{ type: "string" }, { type: "number" }] }, NO_COMPONENTS),
|
||||
"string"
|
||||
);
|
||||
assert.equal(
|
||||
generateExampleFromSchema({ anyOf: [{ type: "integer", minimum: 5 }] }, NO_COMPONENTS),
|
||||
5
|
||||
);
|
||||
});
|
||||
|
||||
test("depth guard stops infinite recursion on self-referential $ref", () => {
|
||||
const components = { Node: { $ref: "#/components/schemas/Node" } };
|
||||
// Must not throw / stack-overflow; returns null once depth > 3.
|
||||
const out = generateExampleFromSchema({ $ref: "#/components/schemas/Node" }, components);
|
||||
assert.equal(out, null);
|
||||
});
|
||||
Reference in New Issue
Block a user