- Introduced a new optional `profile` parameter in the OpenAPI spec generation route, allowing for "full" and "inventory" profiles. - Updated the `ToolExplorer` and `ToolsView` components to remove inventory state handling, simplifying the UI. - Adjusted caching mechanisms to accommodate the new profile parameter. - Enhanced tests to verify behavior with the new profile options.
Unified · Split
| @@ -8,6 +8,7 @@ const querySchema = z.object({ · 7 unmodified lines | ||
| 8 | 8 | specUrl: z.string().trim().min(1), |
| 9 | 9 | sourceName: z.string().trim().min(1).optional(), |
| 10 | 10 | includeDts: z.enum(["1", "true", "0", "false"]).optional(), |
| 11 | + profile: z.enum(["full", "inventory"]).optional(), | |
| 11 | 12 | headers: z.string().optional(), |
| 12 | 13 | }); |
| 13 | 14 | |
| @@ -109,6 +110,7 @@ async function prepareWithForwardedHeaders( · 95 unmodified lines | ||
| 109 | 110 | specUrl: string, |
| 110 | 111 | sourceName: string, |
| 111 | 112 | includeDts: boolean, |
| 113 | + profile: "full" | "inventory", | |
| 112 | 114 | headers: Record<string, string>, |
| 113 | 115 | ): Promise<Awaited<ReturnType<typeof prepareOpenApiSpec>>> { |
| 114 | 116 | const controller = new AbortController(); |
| @@ -133,7 +135,7 @@ async function prepareWithForwardedHeaders( · 18 unmodified lines | ||
| 133 | 135 | const parsedSpec = parseSpecPayload(raw); |
| 134 | 136 | return await prepareOpenApiSpec(parsedSpec, sourceName, { |
| 135 | 137 | includeDts, |
| 136 | − profile: "inventory", | |
| 138 | + profile, | |
| 137 | 139 | }); |
| 138 | 140 | } finally { |
| 139 | 141 | clearTimeout(timeoutId); |
| @@ -146,6 +148,7 @@ export async function GET(request: Request): Promise<Response> { · 6 unmodified lines | ||
| 146 | 148 | specUrl: url.searchParams.get("specUrl") ?? "", |
| 147 | 149 | sourceName: url.searchParams.get("sourceName") ?? undefined, |
| 148 | 150 | includeDts: url.searchParams.get("includeDts") ?? undefined, |
| 151 | + profile: url.searchParams.get("profile") ?? undefined, | |
| 149 | 152 | headers: url.searchParams.get("headers") ?? undefined, |
| 150 | 153 | }); |
| 151 | 154 | |
| @@ -156,17 +159,19 @@ export async function GET(request: Request): Promise<Response> { · 4 unmodified lines | ||
| 156 | 159 | try { |
| 157 | 160 | const includeDts = parsed.data.includeDts === "1" || parsed.data.includeDts === "true"; |
| 158 | 161 | const sourceName = parsed.data.sourceName ?? "openapi"; |
| 162 | + const profile = parsed.data.profile ?? "full"; | |
| 159 | 163 | const forwardedHeaders = sanitizeForwardHeaders(parseHeadersQueryValue(parsed.data.headers)); |
| 160 | 164 | |
| 161 | 165 | const prepared = Object.keys(forwardedHeaders).length === 0 |
| 162 | 166 | ? await prepareOpenApiSpec(parsed.data.specUrl, sourceName, { |
| 163 | 167 | includeDts, |
| 164 | − profile: "inventory", | |
| 168 | + profile, | |
| 165 | 169 | }) |
| 166 | 170 | : await prepareWithForwardedHeaders( |
| 167 | 171 | parsed.data.specUrl, |
| 168 | 172 | sourceName, |
| 169 | 173 | includeDts, |
| 174 | + profile, | |
| 170 | 175 | forwardedHeaders, |
| 171 | 176 | ); |
| 172 | 177 | |
| @@ -74,8 +74,6 @@ interface ToolExplorerProps { · 73 unmodified lines | ||
| 74 | 74 | onSourceDeleted?: (sourceName: string) => void; |
| 75 | 75 | onRegenerate?: () => void; |
| 76 | 76 | isRebuilding?: boolean; |
| 77 | − inventoryState?: "initializing" | "ready" | "rebuilding" | "failed"; | |
| 78 | − inventoryError?: string; | |
| 79 | 77 | } |
| 80 | 78 | |
| 81 | 79 | export function ToolExplorer({ |
| @@ -107,8 +105,6 @@ export function ToolExplorer({ · 25 unmodified lines | ||
| 107 | 105 | onSourceDeleted, |
| 108 | 106 | onRegenerate, |
| 109 | 107 | isRebuilding = false, |
| 110 | − inventoryState, | |
| 111 | − inventoryError, | |
| 112 | 108 | }: ToolExplorerProps) { |
| 113 | 109 | const [toolDetailsByPath, setToolDetailsByPath] = useState<Record<string, Pick<ToolDescriptor, "path" | "description" | "display" | "typing">>>({}); |
| 114 | 110 | const [loadingDetailPaths, setLoadingDetailPaths] = useState<Set<string>>(new Set()); |
| @@ -426,50 +422,6 @@ export function ToolExplorer({ · 311 unmodified lines | ||
| 426 | 422 | }); |
| 427 | 423 | }, []); |
| 428 | 424 | |
| 429 | − const inventoryStatus = useMemo(() => { | |
| 430 | − const loadingSourceCount = loadingSourceSet.size; | |
| 431 | − const sourceStateEntries = Object.entries(sourceStates); | |
| 432 | − const totalSourceCount = sourceStateEntries.length; | |
| 433 | − const readySourceCount = sourceStateEntries.filter(([, state]) => state.state === "ready").length; | |
| 434 | − const failedSourceCount = sourceStateEntries.filter(([, state]) => state.state === "failed").length; | |
| 435 | − | |
| 436 | − if (!inventoryState) { | |
| 437 | − return { label: "Checking...", tone: "muted" as const }; | |
| 438 | − } | |
| 439 | − if (inventoryState === "initializing") { | |
| 440 | − const progress = totalSourceCount > 0 | |
| 441 | − ? `${readySourceCount}/${totalSourceCount}` | |
| 442 | − : `${loadingSourceCount}`; | |
| 443 | − return { | |
| 444 | − label: `Building ${progress}`, | |
| 445 | − tone: "loading" as const, | |
| 446 | − }; | |
| 447 | − } | |
| 448 | − if (inventoryState === "rebuilding") { | |
| 449 | − const progress = totalSourceCount > 0 | |
| 450 | − ? `${readySourceCount}/${totalSourceCount}` | |
| 451 | − : `${loadingSourceCount}`; | |
| 452 | − return { | |
| 453 | − label: `Refreshing ${progress}`, | |
| 454 | − tone: "loading" as const, | |
| 455 | − }; | |
| 456 | − } | |
| 457 | − if (inventoryState === "failed") { | |
| 458 | − return { | |
| 459 | − label: inventoryError | |
| 460 | − ? `Failed: ${inventoryError}` | |
| 461 | − : failedSourceCount > 0 | |
| 462 | − ? `Failed (${failedSourceCount} sources)` | |
| 463 | − : "Failed", | |
| 464 | − tone: "error" as const, | |
| 465 | − }; | |
| 466 | − } | |
| 467 | − return { | |
| 468 | − label: totalSourceCount > 0 ? `Up to date (${readySourceCount}/${totalSourceCount})` : "Up to date", | |
| 469 | − tone: "muted" as const, | |
| 470 | − }; | |
| 471 | − }, [inventoryError, inventoryState, loadingSourceSet.size, sourceStates]); | |
| 472 | − | |
| 473 | 425 | const regenerationInProgress = isRebuilding; |
| 474 | 426 | // ── Render ────────────────────────────────────────────────────────────── |
| 475 | 427 | |
| @@ -484,24 +436,6 @@ export function ToolExplorer({ · 8 unmodified lines | ||
| 484 | 436 | <p className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground/50"> |
| 485 | 437 | Tools |
| 486 | 438 | </p> |
| 487 | − <span | |
| 488 | − className={cn( | |
| 489 | − "text-[9px] font-mono flex items-center gap-0.5", | |
| 490 | − inventoryStatus.tone === "loading" | |
| 491 | − ? "text-terminal-amber/80" | |
| 492 | − : inventoryStatus.tone === "error" | |
| 493 | − ? "text-terminal-red/80" | |
| 494 | − : "text-muted-foreground/40", | |
| 495 | − )} | |
| 496 | − title={inventoryStatus.label} | |
| 497 | − > | |
| 498 | − {inventoryStatus.tone === "loading" ? ( | |
| 499 | − <Loader2 className="h-2.5 w-2.5 animate-spin" /> | |
| 500 | − ) : inventoryStatus.tone === "error" ? ( | |
| 501 | − <AlertTriangle className="h-2.5 w-2.5" /> | |
| 502 | − ) : null} | |
| 503 | − {inventoryStatus.label} | |
| 504 | − </span> | |
| 505 | 439 | </div> |
| 506 | 440 | <div className="flex items-center gap-0.5"> |
| 507 | 441 | {onRegenerate ? ( |
| @@ -489,8 +489,6 @@ export function ToolsView() { · 488 unmodified lines | ||
| 489 | 489 | onFocusedToolPathChange={setCatalogActiveToolPath} |
| 490 | 490 | onFocusedSourceNameChange={setCatalogFocusedSourceName} |
| 491 | 491 | onRegenerate={handleRegenerateInventory} |
| 492 | − inventoryState={inventoryStatus?.state} | |
| 493 | − inventoryError={inventoryStatus?.error} | |
| 494 | 492 | isRebuilding={ |
| 495 | 493 | regenerationInFlight |
| 496 | 494 | || inventoryStatus?.state === "rebuilding" |
| @@ -218,6 +218,7 @@ export async function fetchAndInspectOpenApiSpec(input: { · 217 unmodified lines | ||
| 218 | 218 | specUrl: input.specUrl, |
| 219 | 219 | sourceName: "openapi-inspect", |
| 220 | 220 | includeDts: "0", |
| 221 | + profile: "inventory", | |
| 221 | 222 | }); |
| 222 | 223 | |
| 223 | 224 | if (hasHeaders) { |
| @@ -0,0 +1,49 @@ | ||
| 1 | +import { describe, expect, test } from "bun:test"; | |
| 2 | +import { traverseSchema } from "./schema-traverse"; | |
| 3 | + | |
| 4 | +describe("traverseSchema type labels", () => { | |
| 5 | + test("infers object when schema has properties but no explicit type", () => { | |
| 6 | + const schema = { | |
| 7 | + type: "object", | |
| 8 | + properties: { | |
| 9 | + body: { | |
| 10 | + properties: { | |
| 11 | + name: { type: "string" }, | |
| 12 | + }, | |
| 13 | + required: ["name"], | |
| 14 | + }, | |
| 15 | + }, | |
| 16 | + } satisfies Record<string, unknown>; | |
| 17 | + | |
| 18 | + const { entries } = traverseSchema(schema); | |
| 19 | + const body = entries.find((entry) => entry.path === "body"); | |
| 20 | + | |
| 21 | + expect(body).toBeDefined(); | |
| 22 | + expect(body?.type).toBe("object"); | |
| 23 | + expect(body?.typeLabel).toBe("object"); | |
| 24 | + }); | |
| 25 | + | |
| 26 | + test("dedupes oneOf object variants in array item labels", () => { | |
| 27 | + const schema = { | |
| 28 | + type: "object", | |
| 29 | + properties: { | |
| 30 | + include: { | |
| 31 | + type: "array", | |
| 32 | + items: { | |
| 33 | + oneOf: [ | |
| 34 | + { type: "object", properties: { email: { type: "string" } } }, | |
| 35 | + { type: "object", properties: { ip: { type: "string" } } }, | |
| 36 | + ], | |
| 37 | + }, | |
| 38 | + }, | |
| 39 | + }, | |
| 40 | + } satisfies Record<string, unknown>; | |
| 41 | + | |
| 42 | + const { entries } = traverseSchema(schema); | |
| 43 | + const include = entries.find((entry) => entry.path === "include"); | |
| 44 | + | |
| 45 | + expect(include).toBeDefined(); | |
| 46 | + expect(include?.type).toBe("array"); | |
| 47 | + expect(include?.typeLabel).toBe("object[]"); | |
| 48 | + }); | |
| 49 | +}); | |
| @@ -113,21 +113,30 @@ function resolveTypeLabel(schema: Record<string, unknown>): { · 112 unmodified lines | ||
| 113 | 113 | // oneOf / anyOf union |
| 114 | 114 | const union = (schema.oneOf ?? schema.anyOf) as unknown[] | undefined; |
| 115 | 115 | if (Array.isArray(union) && union.length > 0) { |
| 116 | − const parts = union.map((s) => { | |
| 117 | − const sub = asRecord(s); | |
| 118 | − return resolveTypeLabel(sub).label; | |
| 119 | − }); | |
| 120 | − return { type: "union", label: parts.join(" | ") }; | |
| 116 | + const parts = union | |
| 117 | + .map((s) => resolveTypeLabel(asRecord(s))) | |
| 118 | + .filter((part) => part.label.trim().length > 0); | |
| 119 | + const uniqueLabels = [...new Set(parts.map((part) => part.label))]; | |
| 120 | + if (uniqueLabels.length === 1) { | |
| 121 | + const single = parts[0]; | |
| 122 | + if (single) { | |
| 123 | + return { type: single.type, label: single.label, itemType: single.itemType }; | |
| 124 | + } | |
| 125 | + } | |
| 126 | + return { type: "union", label: uniqueLabels.join(" | ") }; | |
| 121 | 127 | } |
| 122 | 128 | |
| 123 | 129 | // allOf intersection |
| 124 | 130 | const allOf = schema.allOf as unknown[] | undefined; |
| 125 | 131 | if (Array.isArray(allOf) && allOf.length > 0) { |
| 126 | − const parts = allOf.map((s) => { | |
| 127 | − const sub = asRecord(s); | |
| 128 | − return resolveTypeLabel(sub).label; | |
| 129 | − }); | |
| 130 | − return { type: "object", label: parts.join(" & ") }; | |
| 132 | + const parts = allOf | |
| 133 | + .map((s) => resolveTypeLabel(asRecord(s)).label) | |
| 134 | + .filter((label) => label.trim().length > 0); | |
| 135 | + const uniqueLabels = [...new Set(parts)]; | |
| 136 | + if (uniqueLabels.length === 1) { | |
| 137 | + return { type: "object", label: uniqueLabels[0]! }; | |
| 138 | + } | |
| 139 | + return { type: "object", label: uniqueLabels.join(" & ") }; | |
| 131 | 140 | } |
| 132 | 141 | |
| 133 | 142 | // Type array (e.g. ["string", "null"]) |
| @@ -157,6 +166,11 @@ function resolveTypeLabel(schema: Record<string, unknown>): { · 23 unmodified lines | ||
| 157 | 166 | return { type: "object", label: "object" }; |
| 158 | 167 | } |
| 159 | 168 | |
| 169 | + const hasProperties = Object.keys(asRecord(schema.properties)).length > 0; | |
| 170 | + if (hasProperties || schema.additionalProperties !== undefined || Array.isArray(schema.required)) { | |
| 171 | + return { type: "object", label: "object" }; | |
| 172 | + } | |
| 173 | + | |
| 160 | 174 | // String with format |
| 161 | 175 | if (rawType === "string" && typeof schema.format === "string") { |
| 162 | 176 | return { type: "string", label: `string<${schema.format}>` }; |
| @@ -761,6 +761,49 @@ describe("real-world OpenAPI specs", () => { · 760 unmodified lines | ||
| 761 | 761 | 300_000, |
| 762 | 762 | ); |
| 763 | 763 | |
| 764 | + test( | |
| 765 | + "cloudflare: access group create keeps concrete body schema in full profile", | |
| 766 | + async () => { | |
| 767 | + const cloudflareUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml"; | |
| 768 | + | |
| 769 | + const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare", { | |
| 770 | + includeDts: false, | |
| 771 | + profile: "full", | |
| 772 | + }); | |
| 773 | + const tools = buildOpenApiToolsFromPrepared( | |
| 774 | + { | |
| 775 | + type: "openapi", | |
| 776 | + name: "cloudflare", | |
| 777 | + spec: cloudflareUrl, | |
| 778 | + baseUrl: prepared.servers[0] || "https://api.cloudflare.com/client/v4", | |
| 779 | + }, | |
| 780 | + prepared, | |
| 781 | + ); | |
| 782 | + | |
| 783 | + const tool = tools.find((t) => t.path === "cloudflare.access_groups.create_an_access_group"); | |
| 784 | + | |
| 785 | + expect(tool).toBeDefined(); | |
| 786 | + const inputSchema = toRecord(tool!.typing?.inputSchema); | |
| 787 | + const inputProperties = toRecord(inputSchema.properties); | |
| 788 | + const bodySchema = toRecord(inputProperties.body); | |
| 789 | + expect(Object.keys(bodySchema).length).toBeGreaterThan(0); | |
| 790 | + | |
| 791 | + const bodyProperties = toRecord(bodySchema.properties); | |
| 792 | + const includeSchema = toRecord(bodyProperties.include); | |
| 793 | + const excludeSchema = toRecord(bodyProperties.exclude); | |
| 794 | + const requireSchema = toRecord(bodyProperties.require); | |
| 795 | + | |
| 796 | + expect(includeSchema.type).toBe("array"); | |
| 797 | + expect(excludeSchema.type).toBe("array"); | |
| 798 | + expect(requireSchema.type).toBe("array"); | |
| 799 | + | |
| 800 | + expect(Object.keys(toRecord(includeSchema.items)).length).toBeGreaterThan(0); | |
| 801 | + expect(Object.keys(toRecord(excludeSchema.items)).length).toBeGreaterThan(0); | |
| 802 | + expect(Object.keys(toRecord(requireSchema.items)).length).toBeGreaterThan(0); | |
| 803 | + }, | |
| 804 | + 300_000, | |
| 805 | + ); | |
| 806 | + | |
| 764 | 807 | test( |
| 765 | 808 | "github + stripe: root generated array schemas include concrete item definitions", |
| 766 | 809 | async () => { |
| @@ -1742,7 +1742,12 @@ export declare const internal: { · 1741 unmodified lines | ||
| 1742 | 1742 | prepareOpenApiSpec: FunctionReference< |
| 1743 | 1743 | "action", |
| 1744 | 1744 | "internal", |
| 1745 | − { includeDts?: boolean; sourceName: string; specUrl: string }, | |
| 1745 | + { | |
| 1746 | + includeDts?: boolean; | |
| 1747 | + profile?: "full" | "inventory"; | |
| 1748 | + sourceName: string; | |
| 1749 | + specUrl: string; | |
| 1750 | + }, | |
| 1746 | 1751 | any |
| 1747 | 1752 | >; |
| 1748 | 1753 | }; |
| @@ -39,12 +39,13 @@ export const prepareOpenApiSpec = internalAction({ · 38 unmodified lines | ||
| 39 | 39 | specUrl: v.string(), |
| 40 | 40 | sourceName: v.string(), |
| 41 | 41 | includeDts: v.optional(v.boolean()), |
| 42 | + profile: v.optional(v.union(v.literal("full"), v.literal("inventory"))), | |
| 42 | 43 | }, |
| 43 | 44 | handler: async (ctx, args): Promise<string> => { |
| 44 | 45 | const prepared = await prepareOpenApiSpecInNode(args.specUrl, args.sourceName, { |
| 45 | 46 | includeDts: args.includeDts, |
| 46 | − profile: "full", | |
| 47 | − resolveSchemaRefs: true, | |
| 47 | + profile: args.profile ?? "full", | |
| 48 | + resolveSchemaRefs: args.profile === "inventory" ? false : true, | |
| 48 | 49 | }); |
| 49 | 50 | |
| 50 | 51 | const json = JSON.stringify(prepared); |
| @@ -32,9 +32,12 @@ const OPENAPI_PREPARE_MAX_ATTEMPTS = 3; · 31 unmodified lines | ||
| 32 | 32 | const OPENAPI_PREPARE_RETRY_BASE_DELAY_MS = 1_500; |
| 33 | 33 | const OPENAPI_EXTERNAL_GENERATE_TIMEOUT_MS = 180_000; |
| 34 | 34 | |
| 35 | −const OPENAPI_PREPARED_CACHE_VERSION = "openapi_v11"; | |
| 36 | −const OPENAPI_ARTIFACT_CACHE_VERSION = "openapi_artifact_v1"; | |
| 35 | +const OPENAPI_PREPARED_CACHE_VERSION = "openapi_v12"; | |
| 36 | +const OPENAPI_ARTIFACT_CACHE_VERSION = "openapi_artifact_v2"; | |
| 37 | 37 | const OPENAPI_ARTIFACT_CACHE_TTL_MS = 24 * 60 * 60_000; |
| 38 | +const OPENAPI_PROFILE_FALLBACK_WARNING_PREFIX = "OpenAPI profile fallback:"; | |
| 39 | + | |
| 40 | +type OpenApiPrepareProfile = "full" | "inventory"; | |
| 38 | 41 | |
| 39 | 42 | const openApiAuthModeSchema = z.enum(["account", "workspace", "organization"]); |
| 40 | 43 | |
| @@ -105,6 +108,7 @@ async function loadPreparedOpenApiSpecFromExternalGenerate( · 64 unmodified lines | ||
| 105 | 108 | specUrl: string, |
| 106 | 109 | sourceName: string, |
| 107 | 110 | includeDts: boolean, |
| 111 | + profile: "full" | "inventory", | |
| 108 | 112 | headers?: Record<string, string>, |
| 109 | 113 | ): Promise<PreparedOpenApiSpec | null> { |
| 110 | 114 | const endpoint = resolveExternalGenerateEndpoint(); |
| @@ -120,6 +124,7 @@ async function loadPreparedOpenApiSpecFromExternalGenerate( · 9 unmodified lines | ||
| 120 | 124 | url.searchParams.set("specUrl", specUrl); |
| 121 | 125 | url.searchParams.set("sourceName", sourceName); |
| 122 | 126 | url.searchParams.set("includeDts", includeDts ? "1" : "0"); |
| 127 | + url.searchParams.set("profile", profile); | |
| 123 | 128 | if (headers && Object.keys(headers).length > 0) { |
| 124 | 129 | url.searchParams.set("headers", JSON.stringify(headers)); |
| 125 | 130 | } |
| @@ -149,6 +154,7 @@ async function loadPreparedOpenApiSpecFromExternalGenerate( · 23 unmodified lines | ||
| 149 | 154 | async function cachePreparedOpenApiSpec( |
| 150 | 155 | ctx: ActionCtx, |
| 151 | 156 | specUrl: string, |
| 157 | + version: string, | |
| 152 | 158 | sourceName: string, |
| 153 | 159 | prepared: PreparedOpenApiSpec, |
| 154 | 160 | preparedStorageId?: Id<"_storage">, |
| @@ -159,7 +165,7 @@ async function cachePreparedOpenApiSpec( · 4 unmodified lines | ||
| 159 | 165 | const storageId = preparedStorageId ?? await ctx.storage.store(new Blob([json], { type: "application/json" })); |
| 160 | 166 | await ctx.runMutation(internal.openApiSpecCache.putEntry, { |
| 161 | 167 | specUrl, |
| 162 | − version: OPENAPI_PREPARED_CACHE_VERSION, | |
| 168 | + version, | |
| 163 | 169 | storageId, |
| 164 | 170 | sizeBytes: preparedSizeBytes ?? json.length, |
| 165 | 171 | }); |
| @@ -584,6 +590,10 @@ async function resolveOpenApiFetchHeaders( · 418 unmodified lines | ||
| 584 | 590 | return { headers, warnings: [] }; |
| 585 | 591 | } |
| 586 | 592 | |
| 593 | +function preparedCacheVersion(profile: OpenApiPrepareProfile, includeDts: boolean): string { | |
| 594 | + return `${OPENAPI_PREPARED_CACHE_VERSION}:${profile}:${includeDts ? "dts" : "nodts"}`; | |
| 595 | +} | |
| 596 | + | |
| 587 | 597 | async function loadCachedOpenApiSpec( |
| 588 | 598 | ctx: ActionCtx, |
| 589 | 599 | specUrl: string, |
| @@ -599,125 +609,185 @@ async function loadCachedOpenApiSpec( · 9 unmodified lines | ||
| 599 | 609 | return prepared.dts ? "ready" : "failed"; |
| 600 | 610 | }; |
| 601 | 611 | |
| 602 | − if (!hasHeaders) { | |
| 603 | − try { | |
| 604 | − const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, { | |
| 605 | − specUrl, | |
| 606 | − version: OPENAPI_PREPARED_CACHE_VERSION, | |
| 607 | − maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS, | |
| 608 | − }); | |
| 609 | − | |
| 610 | − if (entry) { | |
| 611 | − const blob = await ctx.storage.get(entry.storageId); | |
| 612 | − if (blob) { | |
| 613 | − const json = await blob.text(); | |
| 614 | − let parsedJson: unknown; | |
| 615 | − try { | |
| 616 | − parsedJson = JSON.parse(json); | |
| 617 | − } catch { | |
| 618 | − parsedJson = undefined; | |
| 612 | + const parsePreparedResponse = async (preparedResponse: unknown): Promise<{ | |
| 613 | + prepared: PreparedOpenApiSpec; | |
| 614 | + preparedStorageId?: Id<"_storage">; | |
| 615 | + preparedSizeBytes?: number; | |
| 616 | + }> => { | |
| 617 | + let preparedPayload: unknown; | |
| 618 | + let preparedStorageId: Id<"_storage"> | undefined; | |
| 619 | + let preparedSizeBytes: number | undefined; | |
| 620 | + | |
| 621 | + if (typeof preparedResponse === "string") { | |
| 622 | + try { | |
| 623 | + const parsed = JSON.parse(preparedResponse) as unknown; | |
| 624 | + const parsedEnvelope = preparedOpenApiEnvelopeSchema.safeParse(parsed); | |
| 625 | + if (parsedEnvelope.success) { | |
| 626 | + preparedStorageId = parsedEnvelope.data.storageId as Id<"_storage">; | |
| 627 | + preparedSizeBytes = parsedEnvelope.data.sizeBytes; | |
| 628 | + | |
| 629 | + const blob = await ctx.storage.get(preparedStorageId); | |
| 630 | + if (!blob) { | |
| 631 | + throw new Error(`Prepared OpenAPI blob missing for '${sourceName}'`); | |
| 619 | 632 | } |
| 633 | + const text = await blob.text(); | |
| 634 | + preparedPayload = JSON.parse(text); | |
| 635 | + } else { | |
| 636 | + preparedPayload = parsed; | |
| 637 | + } | |
| 638 | + } catch { | |
| 639 | + preparedPayload = undefined; | |
| 640 | + } | |
| 641 | + } else { | |
| 642 | + preparedPayload = preparedResponse; | |
| 643 | + } | |
| 644 | + | |
| 645 | + const prepared = toPreparedOpenApiSpec(preparedPayload); | |
| 646 | + if (!prepared) { | |
| 647 | + throw new Error(`Prepared OpenAPI payload for '${sourceName}' was invalid`); | |
| 648 | + } | |
| 649 | + | |
| 650 | + return { | |
| 651 | + prepared, | |
| 652 | + preparedStorageId, | |
| 653 | + preparedSizeBytes, | |
| 654 | + }; | |
| 655 | + }; | |
| 656 | + | |
| 657 | + const loadForProfile = async (profile: OpenApiPrepareProfile): Promise<PreparedOpenApiSpec> => { | |
| 658 | + const cacheVersion = preparedCacheVersion(profile, includeDts); | |
| 659 | + | |
| 660 | + if (!hasHeaders) { | |
| 661 | + try { | |
| 662 | + const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, { | |
| 663 | + specUrl, | |
| 664 | + version: cacheVersion, | |
| 665 | + maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS, | |
| 666 | + }); | |
| 667 | + | |
| 668 | + if (entry) { | |
| 669 | + const blob = await ctx.storage.get(entry.storageId); | |
| 670 | + if (blob) { | |
| 671 | + const json = await blob.text(); | |
| 672 | + let parsedJson: unknown; | |
| 673 | + try { | |
| 674 | + parsedJson = JSON.parse(json); | |
| 675 | + } catch { | |
| 676 | + parsedJson = undefined; | |
| 677 | + } | |
| 620 | 678 | |
| 621 | − if (parsedJson !== undefined) { | |
| 622 | − const prepared = toPreparedOpenApiSpec(parsedJson); | |
| 623 | − if (prepared && (!includeDts || getDtsStatus(prepared) !== "skipped")) { | |
| 624 | − return prepared; | |
| 679 | + if (parsedJson !== undefined) { | |
| 680 | + const prepared = toPreparedOpenApiSpec(parsedJson); | |
| 681 | + if (prepared && (!includeDts || getDtsStatus(prepared) !== "skipped")) { | |
| 682 | + return prepared; | |
| 683 | + } | |
| 625 | 684 | } |
| 626 | 685 | } |
| 627 | 686 | } |
| 687 | + } catch (error) { | |
| 688 | + const message = error instanceof Error ? error.message : String(error); | |
| 689 | + console.warn(`[executor] OpenAPI cache read failed for '${sourceName}' (${profile}): ${message}`); | |
| 628 | 690 | } |
| 629 | − } catch (error) { | |
| 630 | − const message = error instanceof Error ? error.message : String(error); | |
| 631 | − console.warn(`[executor] OpenAPI cache read failed for '${sourceName}': ${message}`); | |
| 632 | 691 | } |
| 633 | − } | |
| 634 | 692 | |
| 635 | − const externalPrepared = await loadPreparedOpenApiSpecFromExternalGenerate( | |
| 636 | − specUrl, | |
| 637 | − sourceName, | |
| 638 | − includeDts, | |
| 639 | − headers, | |
| 640 | − ); | |
| 641 | − if (externalPrepared) { | |
| 642 | − if (!hasHeaders) { | |
| 643 | − await cachePreparedOpenApiSpec(ctx, specUrl, sourceName, externalPrepared); | |
| 693 | + const externalPrepared = await loadPreparedOpenApiSpecFromExternalGenerate( | |
| 694 | + specUrl, | |
| 695 | + sourceName, | |
| 696 | + includeDts, | |
| 697 | + profile, | |
| 698 | + headers, | |
| 699 | + ); | |
| 700 | + if (externalPrepared) { | |
| 701 | + if (!hasHeaders) { | |
| 702 | + await cachePreparedOpenApiSpec( | |
| 703 | + ctx, | |
| 704 | + specUrl, | |
| 705 | + cacheVersion, | |
| 706 | + sourceName, | |
| 707 | + externalPrepared, | |
| 708 | + ); | |
| 709 | + } | |
| 710 | + return externalPrepared; | |
| 644 | 711 | } |
| 645 | − return externalPrepared; | |
| 646 | − } | |
| 647 | 712 | |
| 648 | − let preparedResponse: unknown = undefined; | |
| 649 | − let lastPrepareError: string | undefined; | |
| 650 | − for (let attempt = 1; attempt <= OPENAPI_PREPARE_MAX_ATTEMPTS; attempt += 1) { | |
| 651 | − try { | |
| 652 | − preparedResponse = await ctx.runAction(internal.runtimeNode.prepareOpenApiSpec, { | |
| 653 | − specUrl, | |
| 654 | − sourceName, | |
| 655 | − includeDts, | |
| 656 | − }); | |
| 657 | − lastPrepareError = undefined; | |
| 658 | − break; | |
| 659 | − } catch (error) { | |
| 660 | − const message = error instanceof Error ? error.message : String(error); | |
| 661 | − lastPrepareError = message; | |
| 662 | − if (attempt >= OPENAPI_PREPARE_MAX_ATTEMPTS) { | |
| 713 | + let preparedResponse: unknown = undefined; | |
| 714 | + let lastPrepareError: string | undefined; | |
| 715 | + const prepareOpenApiSpecAction = internal.runtimeNode.prepareOpenApiSpec as unknown as { | |
| 716 | + _doNotReferenceDirectly?: true; | |
| 717 | + }; | |
| 718 | + const maxAttempts = profile === "full" && !includeDts ? 1 : OPENAPI_PREPARE_MAX_ATTEMPTS; | |
| 719 | + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { | |
| 720 | + try { | |
| 721 | + preparedResponse = await ctx.runAction(prepareOpenApiSpecAction as never, { | |
| 722 | + specUrl, | |
| 723 | + sourceName, | |
| 724 | + includeDts, | |
| 725 | + profile, | |
| 726 | + } as never); | |
| 727 | + lastPrepareError = undefined; | |
| 663 | 728 | break; |
| 729 | + } catch (error) { | |
| 730 | + const message = error instanceof Error ? error.message : String(error); | |
| 731 | + lastPrepareError = message; | |
| 732 | + if (attempt >= maxAttempts) { | |
| 733 | + break; | |
| 734 | + } | |
| 735 | + | |
| 736 | + const delayMs = OPENAPI_PREPARE_RETRY_BASE_DELAY_MS * attempt; | |
| 737 | + await new Promise((resolve) => setTimeout(resolve, delayMs)); | |
| 664 | 738 | } |
| 739 | + } | |
| 665 | 740 | |
| 666 | − const delayMs = OPENAPI_PREPARE_RETRY_BASE_DELAY_MS * attempt; | |
| 667 | − await new Promise((resolve) => setTimeout(resolve, delayMs)); | |
| 741 | + if (lastPrepareError) { | |
| 742 | + throw new Error( | |
| 743 | + `Failed to prepare OpenAPI source '${sourceName}' with profile '${profile}' after ${maxAttempts} attempt(s): ${lastPrepareError}`, | |
| 744 | + ); | |
| 668 | 745 | } |
| 669 | − } | |
| 670 | 746 | |
| 671 | − if (lastPrepareError) { | |
| 672 | − throw new Error( | |
| 673 | − `Failed to prepare OpenAPI source '${sourceName}' after ${OPENAPI_PREPARE_MAX_ATTEMPTS} attempts: ${lastPrepareError}`, | |
| 674 | − ); | |
| 675 | − } | |
| 747 | + const parsedPrepared = await parsePreparedResponse(preparedResponse); | |
| 748 | + if (!hasHeaders) { | |
| 749 | + await cachePreparedOpenApiSpec( | |
| 750 | + ctx, | |
| 751 | + specUrl, | |
| 752 | + cacheVersion, | |
| 753 | + sourceName, | |
| 754 | + parsedPrepared.prepared, | |
| 755 | + parsedPrepared.preparedStorageId, | |
| 756 | + parsedPrepared.preparedSizeBytes, | |
| 757 | + ); | |
| 758 | + } | |
| 759 | + | |
| 760 | + return parsedPrepared.prepared; | |
| 761 | + }; | |
| 676 | 762 | |
| 677 | − let preparedPayload: unknown; | |
| 678 | − let preparedStorageId: Id<"_storage"> | undefined; | |
| 679 | − let preparedSizeBytes: number | undefined; | |
| 680 | − if (typeof preparedResponse === "string") { | |
| 763 | + const profilesToTry: OpenApiPrepareProfile[] = includeDts ? ["full"] : ["full", "inventory"]; | |
| 764 | + const profileErrors: string[] = []; | |
| 765 | + | |
| 766 | + for (let index = 0; index < profilesToTry.length; index += 1) { | |
| 767 | + const profile = profilesToTry[index]!; | |
| 681 | 768 | try { |
| 682 | − const parsed = JSON.parse(preparedResponse) as unknown; | |
| 683 | − const parsedEnvelope = preparedOpenApiEnvelopeSchema.safeParse(parsed); | |
| 684 | − if (parsedEnvelope.success) { | |
| 685 | − preparedStorageId = parsedEnvelope.data.storageId as Id<"_storage">; | |
| 686 | − preparedSizeBytes = parsedEnvelope.data.sizeBytes; | |
| 687 | − | |
| 688 | − const blob = await ctx.storage.get(preparedStorageId); | |
| 689 | − if (!blob) { | |
| 690 | − throw new Error(`Prepared OpenAPI blob missing for '${sourceName}'`); | |
| 691 | − } | |
| 692 | − const text = await blob.text(); | |
| 693 | − preparedPayload = JSON.parse(text); | |
| 694 | − } else { | |
| 695 | − preparedPayload = parsed; | |
| 769 | + const prepared = await loadForProfile(profile); | |
| 770 | + if (index === 0) { | |
| 771 | + return prepared; | |
| 696 | 772 | } |
| 697 | − } catch { | |
| 698 | − preparedPayload = undefined; | |
| 699 | − } | |
| 700 | − } else { | |
| 701 | − preparedPayload = preparedResponse; | |
| 702 | − } | |
| 703 | 773 | |
| 704 | − const prepared = toPreparedOpenApiSpec(preparedPayload); | |
| 705 | − if (!prepared) { | |
| 706 | − throw new Error(`Prepared OpenAPI payload for '${sourceName}' was invalid`); | |
| 707 | − } | |
| 708 | − | |
| 709 | − if (!hasHeaders) { | |
| 710 | − await cachePreparedOpenApiSpec( | |
| 711 | − ctx, | |
| 712 | − specUrl, | |
| 713 | − sourceName, | |
| 714 | − prepared, | |
| 715 | − preparedStorageId, | |
| 716 | − preparedSizeBytes, | |
| 717 | − ); | |
| 774 | + const warning = `${OPENAPI_PROFILE_FALLBACK_WARNING_PREFIX} Source '${sourceName}' fell back to '${profile}' profile after full profile failed: ${profileErrors[0] ?? "unknown error"}`; | |
| 775 | + return { | |
| 776 | + ...prepared, | |
| 777 | + warnings: [...(prepared.warnings ?? []), warning], | |
| 778 | + }; | |
| 779 | + } catch (error) { | |
| 780 | + const message = error instanceof Error ? error.message : String(error); | |
| 781 | + profileErrors.push(message); | |
| 782 | + if (index === profilesToTry.length - 1) { | |
| 783 | + break; | |
| 784 | + } | |
| 785 | + } | |
| 718 | 786 | } |
| 719 | 787 | |
| 720 | − return prepared; | |
| 788 | + throw new Error( | |
| 789 | + `Failed to prepare OpenAPI source '${sourceName}' after ${profilesToTry.length} profile attempt(s): ${profileErrors.join("; ")}`, | |
| 790 | + ); | |
| 721 | 791 | } |
| 722 | 792 | |
| 723 | 793 | export async function loadSourceArtifact( |
| @@ -761,8 +831,11 @@ export async function loadSourceArtifact( · 37 unmodified lines | ||
| 761 | 831 | resolvedOpenApiHeaders.headers, |
| 762 | 832 | ); |
| 763 | 833 | const artifact = compileOpenApiArtifactFromPrepared(source, prepared); |
| 834 | + const usedFallbackProfile = (prepared.warnings ?? []).some((warning) => | |
| 835 | + warning.startsWith(OPENAPI_PROFILE_FALLBACK_WARNING_PREFIX) | |
| 836 | + ); | |
| 764 | 837 | |
| 765 | − if (sharedArtifactCacheEligible && sharedArtifactCacheKey) { | |
| 838 | + if (sharedArtifactCacheEligible && sharedArtifactCacheKey && !usedFallbackProfile) { | |
| 766 | 839 | await putSharedOpenApiArtifactCache(ctx, sharedArtifactCacheKey, artifact); |
| 767 | 840 | } |
| 768 | 841 | |