Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/persist-openapi-spec-overrides.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/core/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/sdk/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
14 changes: 13 additions & 1 deletion packages/plugins/openapi/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +31,7 @@ const DomainErrors = [
OpenApiParseError,
OpenApiExtractionError,
OpenApiOAuthError,
OpenApiSpecOverrideError,
IntegrationAlreadyExistsError,
] as const;

Expand All @@ -35,6 +42,7 @@ const UpdateSpecErrors = [
OpenApiParseError,
OpenApiExtractionError,
OpenApiOAuthError,
OpenApiSpecOverrideError,
IntegrationNotFound,
] as const;

Expand Down Expand Up @@ -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
Expand All @@ -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({
Expand Down Expand Up @@ -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)),
});

Expand Down
6 changes: 6 additions & 0 deletions packages/plugins/openapi/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}),
Expand All @@ -68,6 +69,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope
family: payload.family,
healthCheck: payload.healthCheck,
authenticationTemplate: payload.authenticationTemplate,
specOverrides: payload.specOverrides,
});
}),
),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/openapi/src/promise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ",
Expand Down
25 changes: 25 additions & 0 deletions packages/plugins/openapi/src/react/AddOpenApiIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
Expand Down Expand Up @@ -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",
Expand Down
92 changes: 73 additions & 19 deletions packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand All @@ -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(
Expand Down Expand Up @@ -152,19 +174,23 @@ 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<string | null>(null);
const [analyzing, setAnalyzing] = useState(false);
const [analyzeError, setAnalyzeError] = useState<string | null>(null);

const activePreset = resolveOpenApiPreset(openApiPresets, props.initialPreset, specUrl);
const presetSpecOverrides = decodeOpenApiSpecOverrides(activePreset?.specOverrides);
const specOverridesText = specOverridesDraft ?? formatSpecOverridesText(presetSpecOverrides);

// After analysis
const [preview, setPreview] = useState<SpecPreviewSummary | null>(null);
const [baseUrl, setBaseUrl] = useState("");
// Agent-visible description: prefilled from the spec's `info.description`
// until the user types (null = untouched, keep deriving from the preview).
const [descriptionDraft, setDescriptionDraft] = useState<string | null>(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,
Expand Down Expand Up @@ -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 =
Expand All @@ -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"
? [
{
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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"));
Expand Down Expand Up @@ -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
Expand All @@ -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 () => {
Expand Down Expand Up @@ -539,6 +582,17 @@ export default function AddOpenApiIntegration(props: {
/>
) : null}

<SpecOverridesEditor
value={specOverridesText}
onChange={(value) => {
setSpecOverridesDraft(value);
setAnalyzeError(null);
setPreview(null);
setBaseUrl("");
}}
disabled={analyzing || adding}
/>

{preview && (
<AuthMethodListEditor
list={authMethodList}
Expand All @@ -549,7 +603,7 @@ export default function AddOpenApiIntegration(props: {

{preview ? (
<AddOpenApiHealthCheckSection
presetHealthCheck={initialPreset?.healthCheck}
presetHealthCheck={activePreset?.healthCheck}
candidates={healthCheckCandidates}
selected={hcSelected}
operation={hcOperation}
Expand Down
Loading
Loading