diff --git a/.changeset/persist-openapi-spec-overrides.md b/.changeset/persist-openapi-spec-overrides.md new file mode 100644 index 000000000..9f46c5b56 --- /dev/null +++ b/.changeset/persist-openapi-spec-overrides.md @@ -0,0 +1,6 @@ +--- +"@executor-js/plugin-openapi": patch +"@executor-js/sdk": patch +--- + +Apply persisted RFC 6902 overrides to OpenAPI specifications during preview, import, and refresh so upstream documents can be corrected without maintaining a fork. Figma imports automatically narrow OAuth to the scopes supported by its OAuth app configuration. diff --git a/packages/core/sdk/src/client.ts b/packages/core/sdk/src/client.ts index 3dcfd697b..d0fc0ee97 100644 --- a/packages/core/sdk/src/client.ts +++ b/packages/core/sdk/src/client.ts @@ -116,6 +116,8 @@ export interface IntegrationPreset { readonly family?: string; readonly specFormat?: string; readonly defaultSlug?: string; + /** Plugin-specific RFC 6902 operations applied to a fetched specification. */ + readonly specOverrides?: readonly unknown[]; readonly authTemplate?: readonly IntegrationPresetAuthentication[]; readonly healthCheck?: HealthCheckSpec; } diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 1df466856..6d4dbc23b 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -568,6 +568,8 @@ export interface IntegrationPreset { readonly family?: string; readonly specFormat?: string; readonly defaultSlug?: string; + /** Plugin-specific RFC 6902 operations applied to a fetched specification. */ + readonly specOverrides?: readonly unknown[]; readonly authTemplate?: readonly IntegrationPresetAuthentication[]; readonly healthCheck?: HealthCheckSpec; readonly transport?: "remote" | "stdio"; diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index 91f19cc9d..d1ab56254 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -9,8 +9,14 @@ import { IntegrationSlug, } from "@executor-js/sdk/shared"; -import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors"; +import { + OpenApiParseError, + OpenApiExtractionError, + OpenApiOAuthError, + OpenApiSpecOverrideError, +} from "../sdk/errors"; import { SpecPreviewSummary } from "../sdk/preview"; +import { SpecOverridesSchema } from "../sdk/spec-overrides"; // --------------------------------------------------------------------------- // Errors — the plugin-domain tagged errors flow directly to clients @@ -25,6 +31,7 @@ const DomainErrors = [ OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError, + OpenApiSpecOverrideError, IntegrationAlreadyExistsError, ] as const; @@ -35,6 +42,7 @@ const UpdateSpecErrors = [ OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError, + OpenApiSpecOverrideError, IntegrationNotFound, ] as const; @@ -79,11 +87,13 @@ const AddSpecPayload = Schema.Struct({ family: Schema.optional(Schema.String), healthCheck: Schema.optional(HealthCheckSpec), authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)), + specOverrides: Schema.optional(SpecOverridesSchema), }); const PreviewSpecPayload = Schema.Struct({ spec: Schema.String, specFormat: Schema.optional(Schema.String), + specOverrides: Schema.optional(SpecOverridesSchema), }); // The `configure` payload — the new/updated auth methods to merge onto the @@ -109,6 +119,7 @@ const AddSpecResponse = Schema.Struct({ // "re-fetch from the stored source URL". const UpdateSpecPayload = Schema.Struct({ spec: Schema.optional(OpenApiSpecInputPayload), + specOverrides: Schema.optional(SpecOverridesSchema), }); const UpdateSpecResponse = Schema.Struct({ @@ -138,6 +149,7 @@ const OpenApiConfigView = Schema.Struct({ baseUrl: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), + specOverrides: Schema.optional(SpecOverridesSchema), authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)), }); diff --git a/packages/plugins/openapi/src/api/handlers.ts b/packages/plugins/openapi/src/api/handlers.ts index 422604d7e..b4e892d42 100644 --- a/packages/plugins/openapi/src/api/handlers.ts +++ b/packages/plugins/openapi/src/api/handlers.ts @@ -47,6 +47,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope const preview = yield* ext.previewSpec({ spec: payload.spec, specFormat: payload.specFormat, + specOverrides: payload.specOverrides, }); return specPreviewSummary(preview); }), @@ -68,6 +69,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope family: payload.family, healthCheck: payload.healthCheck, authenticationTemplate: payload.authenticationTemplate, + specOverrides: payload.specOverrides, }); }), ), @@ -100,6 +102,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope baseUrl: config.baseUrl, headers: config.headers ? { ...config.headers } : undefined, queryParams: config.queryParams ? { ...config.queryParams } : undefined, + specOverrides: config.specOverrides ? [...config.specOverrides] : undefined, authenticationTemplate: config.authenticationTemplate ? [...config.authenticationTemplate] : undefined, @@ -127,6 +130,9 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope const ext = yield* OpenApiExtensionService; const result = yield* ext.updateSpec(params.slug, { ...(payload.spec !== undefined ? { spec: payload.spec } : {}), + ...(payload.specOverrides !== undefined + ? { specOverrides: payload.specOverrides } + : {}), }); return { slug: result.slug, diff --git a/packages/plugins/openapi/src/promise.ts b/packages/plugins/openapi/src/promise.ts index eec67f9e8..15fc9a72c 100644 --- a/packages/plugins/openapi/src/promise.ts +++ b/packages/plugins/openapi/src/promise.ts @@ -6,6 +6,7 @@ export type { OpenApiSpecInput, OpenApiPreviewInput, } from "./sdk/plugin"; +export type { JsonPatchOperation, SpecOverrides } from "./sdk/spec-overrides"; // Auth-template authoring helpers. Author apikey methods as canonical // placements, or request-shaped: `headers: { Authorization: ["Bearer ", diff --git a/packages/plugins/openapi/src/react/AddOpenApiIntegration.test.ts b/packages/plugins/openapi/src/react/AddOpenApiIntegration.test.ts index a7dccd0f9..2ffb87096 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiIntegration.test.ts +++ b/packages/plugins/openapi/src/react/AddOpenApiIntegration.test.ts @@ -5,7 +5,9 @@ import { AddOpenApiHealthCheckSection, baseUrlFromSpecInput, openApiPreviewFailureMessage, + resolveOpenApiPreset, } from "./AddOpenApiIntegration"; +import { openApiPresets } from "../sdk/presets"; const visibleText = (node: React.ReactNode): string => { if (node === null || node === undefined || typeof node === "boolean") return ""; @@ -42,6 +44,29 @@ describe("openApiPreviewFailureMessage", () => { }); }); +describe("resolveOpenApiPreset", () => { + it("matches Figma defaults from its canonical spec URL", () => { + const preset = resolveOpenApiPreset( + openApiPresets, + undefined, + "https://raw.githubusercontent.com/figma/rest-api-spec/refs/heads/main/openapi/openapi.yaml#ignored", + ); + + expect(preset?.id).toBe("figma"); + expect(preset?.specOverrides).toBeTruthy(); + }); + + it("prefers an explicitly selected preset over URL matching", () => { + const preset = resolveOpenApiPreset( + openApiPresets, + "stripe", + "https://raw.githubusercontent.com/figma/rest-api-spec/refs/heads/main/openapi/openapi.yaml", + ); + + expect(preset?.id).toBe("stripe"); + }); +}); + describe("AddOpenApiHealthCheckSection", () => { const candidate = { operation: "getCurrentUser", diff --git a/packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx b/packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx index 730f4b8a7..19c90b888 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx @@ -10,7 +10,7 @@ import { type HealthCheckCandidate, type HealthCheckSpec, } from "@executor-js/sdk/shared"; -import { useIntegrationPlugins } from "@executor-js/sdk/client"; +import { useIntegrationPlugins, type IntegrationPreset } from "@executor-js/sdk/client"; import { integrationWriteKeys, healthCheckWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { setIntegrationHealthCheck } from "@executor-js/react/api/atoms"; import { @@ -49,6 +49,12 @@ import type { SpecPreviewSummary } from "../sdk/preview"; import { type Authentication } from "../sdk/types"; import { resolveServerUrl } from "../sdk/openapi-utils"; import { detectedAuthenticationTemplates } from "../sdk/derive-auth"; +import { + decodeOpenApiSpecOverrides, + formatSpecOverridesText, + parseSpecOverridesText, +} from "../sdk/spec-overrides"; +import { SpecOverridesEditor } from "./SpecOverridesEditor"; const normalizePresetUrl = (url: string): string => { const trimmed = url.trim(); @@ -59,6 +65,22 @@ const normalizePresetUrl = (url: string): string => { return parsed.toString().replace(/\/$/, ""); }; +/** Resolve catalog defaults from an explicit preset id or a matching spec URL. */ +export const resolveOpenApiPreset = ( + presets: readonly IntegrationPreset[] | undefined, + initialPresetId: string | undefined, + specUrl: string, +): IntegrationPreset | null => { + const explicitPreset = presets?.find((preset) => preset.id === initialPresetId); + if (explicitPreset) return explicitPreset; + if (specUrl.trim().length === 0) return null; + const normalizedSpecUrl = normalizePresetUrl(specUrl); + return ( + presets?.find((preset) => preset.url && normalizePresetUrl(preset.url) === normalizedSpecUrl) ?? + null + ); +}; + const specInputForAdd = (input: string) => { const value = input.trim(); const parsed = Effect.runSyncExit( @@ -152,11 +174,15 @@ export default function AddOpenApiIntegration(props: { const integrationPlugins = useIntegrationPlugins(); const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi"); const openApiPresets = openApiPlugin?.presets; - const initialPreset = openApiPresets?.find((preset) => preset.id === props.initialPreset) ?? null; const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); + const [specOverridesDraft, setSpecOverridesDraft] = useState(null); const [analyzing, setAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState(null); + const activePreset = resolveOpenApiPreset(openApiPresets, props.initialPreset, specUrl); + const presetSpecOverrides = decodeOpenApiSpecOverrides(activePreset?.specOverrides); + const specOverridesText = specOverridesDraft ?? formatSpecOverridesText(presetSpecOverrides); + // After analysis const [preview, setPreview] = useState(null); const [baseUrl, setBaseUrl] = useState(""); @@ -164,7 +190,7 @@ export default function AddOpenApiIntegration(props: { // until the user types (null = untouched, keep deriving from the preview). const [descriptionDraft, setDescriptionDraft] = useState(null); const identityFallbackName = - initialPreset?.name ?? (preview ? Option.getOrElse(preview.title, () => "") : ""); + activePreset?.name ?? (preview ? Option.getOrElse(preview.title, () => "") : ""); const identity = useIntegrationIdentity({ fallbackName: identityFallbackName, fallbackNamespace: props.initialNamespace, @@ -216,14 +242,11 @@ export default function AddOpenApiIntegration(props: { const firstServerUrl = firstServer ? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {}) : ""; - const previewPresetIcon = - openApiPresets?.find( - (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl), - )?.icon ?? null; + const previewPresetIcon = activePreset?.icon ?? null; const resolvedBaseUrl = baseUrl.trim(); const resolvedIntegrationId = - initialPreset?.defaultSlug || + activePreset?.defaultSlug || slugifyNamespace(identity.namespace) || (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi"); const resolvedDisplayName = @@ -233,13 +256,17 @@ export default function AddOpenApiIntegration(props: { : resolvedIntegrationId); const resolvedDescription = descriptionDraft ?? (preview ? Option.getOrElse(preview.description, () => "") : ""); + const parsedSpecOverrides = useMemo( + () => parseSpecOverridesText(specOverridesText), + [specOverridesText], + ); // Register EVERY spec-detected auth method, not just a single selected one. // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the // freshly-allocated `?? []` fallback arrays. const authenticationTemplate: readonly Authentication[] = useMemo(() => { - if (initialPreset?.authTemplate) { - return initialPreset.authTemplate.flatMap((template): readonly Authentication[] => + if (activePreset?.authTemplate) { + return activePreset.authTemplate.flatMap((template): readonly Authentication[] => template.kind === "oauth2" ? [ { @@ -256,7 +283,7 @@ export default function AddOpenApiIntegration(props: { preview?.oauth2Presets ?? [], resolvedBaseUrl, ); - }, [initialPreset?.authTemplate, preview, resolvedBaseUrl]); + }, [activePreset?.authTemplate, preview, resolvedBaseUrl]); // Editable auth methods, seeded from the spec-detected templates. The add flow // registers EVERY method (P6), so this is a LIST, preserving multi-method @@ -333,6 +360,7 @@ export default function AddOpenApiIntegration(props: { // args filled (an empty draft, `hcOperation === ""`, imposes no constraint). const canAdd = preview !== null && + parsedSpecOverrides.ok && !slugAlreadyExists && (!previewHasNoServers || resolvedBaseUrl.length > 0) && !(hcOperation.length > 0 && hcMissingRequired); @@ -343,8 +371,19 @@ export default function AddOpenApiIntegration(props: { setAnalyzing(true); setAnalyzeError(null); setAddError(null); + if (!parsedSpecOverrides.ok) { + setAnalyzeError(parsedSpecOverrides.message); + setAnalyzing(false); + return; + } const exit = await doPreview({ - payload: { spec: specUrl, specFormat: initialPreset?.specFormat }, + payload: { + spec: specUrl, + specFormat: activePreset?.specFormat, + ...(parsedSpecOverrides.value.length > 0 + ? { specOverrides: parsedSpecOverrides.value } + : {}), + }, }); if (Exit.isFailure(exit)) { setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec")); @@ -372,9 +411,12 @@ export default function AddOpenApiIntegration(props: { ? { description: resolvedDescription.trim() } : {}), baseUrl: resolvedBaseUrl, - specFormat: initialPreset?.specFormat, - family: initialPreset?.family, - healthCheck: initialPreset?.healthCheck, + specFormat: activePreset?.specFormat, + family: activePreset?.family, + healthCheck: activePreset?.healthCheck, + ...(parsedSpecOverrides.ok && parsedSpecOverrides.value.length > 0 + ? { specOverrides: parsedSpecOverrides.value } + : {}), // Always send the edited method list (even empty) when the user has // inspected a preview: an explicit [] means "no auth methods", while // OMITTING the field tells the server to derive defaults from the @@ -399,9 +441,10 @@ export default function AddOpenApiIntegration(props: { resolvedDescription, resolvedBaseUrl, editedAuthenticationTemplate, - initialPreset?.family, - initialPreset?.healthCheck, - initialPreset?.specFormat, + parsedSpecOverrides, + activePreset?.family, + activePreset?.healthCheck, + activePreset?.specFormat, ]); const handleAdd = async () => { @@ -539,6 +582,17 @@ export default function AddOpenApiIntegration(props: { /> ) : null} + { + setSpecOverridesDraft(value); + setAnalyzeError(null); + setPreview(null); + setBaseUrl(""); + }} + disabled={analyzing || adding} + /> + {preview && ( void; + readonly disabled?: boolean; +}) { + const [open, setOpen] = useState(props.value.trim().length > 0); + const parsed = parseSpecOverridesText(props.value); + + return ( + + + Spec overrides + + +
+ JSON Patch (RFC 6902) +