Skip to content

Commit 8e1f840

Browse files
committed
Support persisted OpenAPI spec overrides
1 parent 171de20 commit 8e1f840

15 files changed

Lines changed: 920 additions & 20 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/plugin-openapi": patch
3+
---
4+
5+
Apply persisted RFC 6902 overrides to OpenAPI specifications during preview, import, and refresh so upstream documents can be corrected without maintaining a fork.

packages/plugins/openapi/src/api/group.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,14 @@ import {
99
IntegrationSlug,
1010
} from "@executor-js/sdk/shared";
1111

12-
import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors";
12+
import {
13+
OpenApiParseError,
14+
OpenApiExtractionError,
15+
OpenApiOAuthError,
16+
OpenApiSpecOverrideError,
17+
} from "../sdk/errors";
1318
import { SpecPreviewSummary } from "../sdk/preview";
19+
import { SpecOverridesSchema } from "../sdk/spec-overrides";
1420

1521
// ---------------------------------------------------------------------------
1622
// Errors — the plugin-domain tagged errors flow directly to clients
@@ -25,6 +31,7 @@ const DomainErrors = [
2531
OpenApiParseError,
2632
OpenApiExtractionError,
2733
OpenApiOAuthError,
34+
OpenApiSpecOverrideError,
2835
IntegrationAlreadyExistsError,
2936
] as const;
3037

@@ -35,6 +42,7 @@ const UpdateSpecErrors = [
3542
OpenApiParseError,
3643
OpenApiExtractionError,
3744
OpenApiOAuthError,
45+
OpenApiSpecOverrideError,
3846
IntegrationNotFound,
3947
] as const;
4048

@@ -79,11 +87,13 @@ const AddSpecPayload = Schema.Struct({
7987
family: Schema.optional(Schema.String),
8088
healthCheck: Schema.optional(HealthCheckSpec),
8189
authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)),
90+
specOverrides: Schema.optional(SpecOverridesSchema),
8291
});
8392

8493
const PreviewSpecPayload = Schema.Struct({
8594
spec: Schema.String,
8695
specFormat: Schema.optional(Schema.String),
96+
specOverrides: Schema.optional(SpecOverridesSchema),
8797
});
8898

8999
// The `configure` payload — the new/updated auth methods to merge onto the
@@ -109,6 +119,7 @@ const AddSpecResponse = Schema.Struct({
109119
// "re-fetch from the stored source URL".
110120
const UpdateSpecPayload = Schema.Struct({
111121
spec: Schema.optional(OpenApiSpecInputPayload),
122+
specOverrides: Schema.optional(SpecOverridesSchema),
112123
});
113124

114125
const UpdateSpecResponse = Schema.Struct({
@@ -138,6 +149,7 @@ const OpenApiConfigView = Schema.Struct({
138149
baseUrl: Schema.optional(Schema.String),
139150
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
140151
queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
152+
specOverrides: Schema.optional(SpecOverridesSchema),
141153
authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)),
142154
});
143155

packages/plugins/openapi/src/api/handlers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope
4747
const preview = yield* ext.previewSpec({
4848
spec: payload.spec,
4949
specFormat: payload.specFormat,
50+
specOverrides: payload.specOverrides,
5051
});
5152
return specPreviewSummary(preview);
5253
}),
@@ -68,6 +69,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope
6869
family: payload.family,
6970
healthCheck: payload.healthCheck,
7071
authenticationTemplate: payload.authenticationTemplate,
72+
specOverrides: payload.specOverrides,
7173
});
7274
}),
7375
),
@@ -100,6 +102,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope
100102
baseUrl: config.baseUrl,
101103
headers: config.headers ? { ...config.headers } : undefined,
102104
queryParams: config.queryParams ? { ...config.queryParams } : undefined,
105+
specOverrides: config.specOverrides ? [...config.specOverrides] : undefined,
103106
authenticationTemplate: config.authenticationTemplate
104107
? [...config.authenticationTemplate]
105108
: undefined,
@@ -127,6 +130,9 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope
127130
const ext = yield* OpenApiExtensionService;
128131
const result = yield* ext.updateSpec(params.slug, {
129132
...(payload.spec !== undefined ? { spec: payload.spec } : {}),
133+
...(payload.specOverrides !== undefined
134+
? { specOverrides: payload.specOverrides }
135+
: {}),
130136
});
131137
return {
132138
slug: result.slug,

packages/plugins/openapi/src/promise.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export type {
66
OpenApiSpecInput,
77
OpenApiPreviewInput,
88
} from "./sdk/plugin";
9+
export type { JsonPatchOperation, SpecOverrides } from "./sdk/spec-overrides";
910

1011
// Auth-template authoring helpers. Author apikey methods as canonical
1112
// placements, or request-shaped: `headers: { Authorization: ["Bearer ",

packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ import type { SpecPreviewSummary } from "../sdk/preview";
4949
import { type Authentication } from "../sdk/types";
5050
import { resolveServerUrl } from "../sdk/openapi-utils";
5151
import { detectedAuthenticationTemplates } from "../sdk/derive-auth";
52+
import { parseSpecOverridesText } from "../sdk/spec-overrides";
53+
import { SpecOverridesEditor } from "./SpecOverridesEditor";
5254

5355
const normalizePresetUrl = (url: string): string => {
5456
const trimmed = url.trim();
@@ -154,6 +156,7 @@ export default function AddOpenApiIntegration(props: {
154156
const openApiPresets = openApiPlugin?.presets;
155157
const initialPreset = openApiPresets?.find((preset) => preset.id === props.initialPreset) ?? null;
156158
const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");
159+
const [specOverridesText, setSpecOverridesText] = useState("");
157160
const [analyzing, setAnalyzing] = useState(false);
158161
const [analyzeError, setAnalyzeError] = useState<string | null>(null);
159162

@@ -233,6 +236,10 @@ export default function AddOpenApiIntegration(props: {
233236
: resolvedIntegrationId);
234237
const resolvedDescription =
235238
descriptionDraft ?? (preview ? Option.getOrElse(preview.description, () => "") : "");
239+
const parsedSpecOverrides = useMemo(
240+
() => parseSpecOverridesText(specOverridesText),
241+
[specOverridesText],
242+
);
236243

237244
// Register EVERY spec-detected auth method, not just a single selected one.
238245
// Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the
@@ -333,6 +340,7 @@ export default function AddOpenApiIntegration(props: {
333340
// args filled (an empty draft, `hcOperation === ""`, imposes no constraint).
334341
const canAdd =
335342
preview !== null &&
343+
parsedSpecOverrides.ok &&
336344
!slugAlreadyExists &&
337345
(!previewHasNoServers || resolvedBaseUrl.length > 0) &&
338346
!(hcOperation.length > 0 && hcMissingRequired);
@@ -343,8 +351,19 @@ export default function AddOpenApiIntegration(props: {
343351
setAnalyzing(true);
344352
setAnalyzeError(null);
345353
setAddError(null);
354+
if (!parsedSpecOverrides.ok) {
355+
setAnalyzeError(parsedSpecOverrides.message);
356+
setAnalyzing(false);
357+
return;
358+
}
346359
const exit = await doPreview({
347-
payload: { spec: specUrl, specFormat: initialPreset?.specFormat },
360+
payload: {
361+
spec: specUrl,
362+
specFormat: initialPreset?.specFormat,
363+
...(parsedSpecOverrides.value.length > 0
364+
? { specOverrides: parsedSpecOverrides.value }
365+
: {}),
366+
},
348367
});
349368
if (Exit.isFailure(exit)) {
350369
setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec"));
@@ -375,6 +394,9 @@ export default function AddOpenApiIntegration(props: {
375394
specFormat: initialPreset?.specFormat,
376395
family: initialPreset?.family,
377396
healthCheck: initialPreset?.healthCheck,
397+
...(parsedSpecOverrides.ok && parsedSpecOverrides.value.length > 0
398+
? { specOverrides: parsedSpecOverrides.value }
399+
: {}),
378400
// Always send the edited method list (even empty) when the user has
379401
// inspected a preview: an explicit [] means "no auth methods", while
380402
// OMITTING the field tells the server to derive defaults from the
@@ -399,6 +421,7 @@ export default function AddOpenApiIntegration(props: {
399421
resolvedDescription,
400422
resolvedBaseUrl,
401423
editedAuthenticationTemplate,
424+
parsedSpecOverrides,
402425
initialPreset?.family,
403426
initialPreset?.healthCheck,
404427
initialPreset?.specFormat,
@@ -539,6 +562,17 @@ export default function AddOpenApiIntegration(props: {
539562
/>
540563
) : null}
541564

565+
<SpecOverridesEditor
566+
value={specOverridesText}
567+
onChange={(value) => {
568+
setSpecOverridesText(value);
569+
setAnalyzeError(null);
570+
setPreview(null);
571+
setBaseUrl("");
572+
}}
573+
disabled={analyzing || adding}
574+
/>
575+
542576
{preview && (
543577
<AuthMethodListEditor
544578
list={authMethodList}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useState } from "react";
2+
import { ChevronDown } from "lucide-react";
3+
4+
import { cn } from "@executor-js/react/lib/utils";
5+
import {
6+
Collapsible,
7+
CollapsibleContent,
8+
CollapsibleTrigger,
9+
} from "@executor-js/react/components/collapsible";
10+
import { FieldLabel } from "@executor-js/react/components/field";
11+
import { Textarea } from "@executor-js/react/components/textarea";
12+
13+
import { parseSpecOverridesText } from "../sdk/spec-overrides";
14+
15+
export function SpecOverridesEditor(props: {
16+
readonly value: string;
17+
readonly onChange: (value: string) => void;
18+
readonly disabled?: boolean;
19+
}) {
20+
const [open, setOpen] = useState(props.value.trim().length > 0);
21+
const parsed = parseSpecOverridesText(props.value);
22+
23+
return (
24+
<Collapsible open={open} onOpenChange={setOpen}>
25+
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground">
26+
Spec overrides
27+
<ChevronDown
28+
className={cn("size-3.5 transition-transform", open && "rotate-180")}
29+
aria-hidden="true"
30+
/>
31+
</CollapsibleTrigger>
32+
<CollapsibleContent>
33+
<div className="mt-3 space-y-2">
34+
<FieldLabel>JSON Patch (RFC 6902)</FieldLabel>
35+
<Textarea
36+
value={props.value}
37+
onChange={(event) => props.onChange((event.target as HTMLTextAreaElement).value)}
38+
placeholder={'[{"op":"replace","path":"/info/title","value":"My API"}]'}
39+
rows={5}
40+
maxRows={14}
41+
className="font-mono text-xs"
42+
disabled={props.disabled}
43+
aria-invalid={!parsed.ok}
44+
/>
45+
{!parsed.ok && <p className="text-xs text-destructive">{parsed.message}</p>}
46+
</div>
47+
</CollapsibleContent>
48+
</Collapsible>
49+
);
50+
}

packages/plugins/openapi/src/react/UpdateSpecSection.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { Label } from "@executor-js/react/components/label";
1313
import { Textarea } from "@executor-js/react/components/textarea";
1414

1515
import { openApiConfigAtom, updateOpenApiSpec } from "./atoms";
16+
import { SpecOverridesEditor } from "./SpecOverridesEditor";
17+
import { formatSpecOverridesText, parseSpecOverridesText } from "../sdk/spec-overrides";
1618

1719
// ---------------------------------------------------------------------------
1820
// Update spec — the openapi plugin's section of the integration Edit sheet.
@@ -47,22 +49,34 @@ export default function UpdateSpecSection(props: EditSheetSectionProps) {
4749
const doUpdate = useAtomSet(updateOpenApiSpec, { mode: "promiseExit" });
4850
const [refetchStaged, setRefetchStaged] = useState(false);
4951
const [pasted, setPasted] = useState("");
52+
const [specOverridesDraft, setSpecOverridesDraft] = useState<string | null>(null);
5053
const [error, setError] = useState<string | null>(null);
5154

5255
const config =
5356
AsyncResult.isSuccess(configResult) && configResult.value ? configResult.value : null;
5457

5558
const specUrl = config?.specUrl;
59+
const specOverridesText = specOverridesDraft ?? formatSpecOverridesText(config?.specOverrides);
5660

5761
// The staged apply, rebuilt whenever the staged inputs change. Reported to
5862
// the sheet through a ref-stable callback so Save can run it.
5963
const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => {
6064
const spec = pasted.trim().length > 0 ? { kind: "blob" as const, value: pasted } : undefined;
61-
if (!spec && !refetchStaged) return { ok: true, summary: null };
65+
const parsedOverrides = parseSpecOverridesText(specOverridesText);
66+
if (!parsedOverrides.ok) {
67+
setError(parsedOverrides.message);
68+
return { ok: false };
69+
}
70+
if (!spec && !refetchStaged && specOverridesDraft === null) {
71+
return { ok: true, summary: null };
72+
}
6273
setError(null);
6374
const exit = await doUpdate({
6475
params: { slug },
65-
payload: spec ? { spec } : {},
76+
payload: {
77+
...(spec ? { spec } : {}),
78+
...(specOverridesDraft !== null ? { specOverrides: parsedOverrides.value } : {}),
79+
},
6680
reactivityKeys: integrationWriteKeys,
6781
});
6882
if (Exit.isFailure(exit)) {
@@ -71,12 +85,13 @@ export default function UpdateSpecSection(props: EditSheetSectionProps) {
7185
}
7286
setRefetchStaged(false);
7387
setPasted("");
88+
setSpecOverridesDraft(null);
7489
return { ok: true, summary: outcomeSummary(exit.value) };
75-
}, [doUpdate, pasted, refetchStaged, slug]);
90+
}, [doUpdate, pasted, refetchStaged, slug, specOverridesDraft, specOverridesText]);
7691

7792
const onPendingChangeRef = useRef(props.onPendingChange);
7893
onPendingChangeRef.current = props.onPendingChange;
79-
const hasStagedChange = refetchStaged || pasted.trim().length > 0;
94+
const hasStagedChange = refetchStaged || pasted.trim().length > 0 || specOverridesDraft !== null;
8095
useEffect(() => {
8196
onPendingChangeRef.current?.(hasStagedChange ? applyStaged : null);
8297
// Clear the staged apply if this section unmounts mid-edit.
@@ -129,6 +144,8 @@ export default function UpdateSpecSection(props: EditSheetSectionProps) {
129144
</div>
130145
)}
131146

147+
<SpecOverridesEditor value={specOverridesText} onChange={setSpecOverridesDraft} />
148+
132149
{error && <FormErrorAlert message={error} />}
133150
</div>
134151
);

packages/plugins/openapi/src/sdk/config.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "@executor-js/sdk/http-auth";
88

99
import type { Authentication } from "./types";
10+
import { SpecOverridesSchema, type SpecOverrides } from "./spec-overrides";
1011

1112
// ---------------------------------------------------------------------------
1213
// OpenAPI integration config — the opaque blob stored on the catalog
@@ -17,6 +18,7 @@ import type { Authentication } from "./types";
1718
// StoredSource credential config. The config carries only:
1819
// - the content hash of the spec blob and/or the source URL to (re)fetch
1920
// from,
21+
// - optional RFC 6902 overrides and the raw source hash they apply to,
2022
// - the optional base URL override,
2123
// - the auth templates a connection's value is rendered through.
2224
// The resolved spec text itself lives in the plugin blob store, keyed
@@ -43,6 +45,8 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({
4345
/** Hex SHA-256 of the resolved spec text — the content address of the spec
4446
* blob (`spec/<hash>` in the plugin blob store). */
4547
specHash: Schema.optional(Schema.String),
48+
/** Hex SHA-256 of the unmodified source text when spec overrides are active. */
49+
sourceSpecHash: Schema.optional(Schema.String),
4650
/** Origin URL the spec was fetched from, when known. Enables refresh. */
4751
specUrl: Schema.optional(Schema.String),
4852
/** Optional base URL override. */
@@ -55,17 +59,20 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({
5559
specFormat: Schema.optional(Schema.String),
5660
/** Catalog family for grouped integration display. */
5761
family: Schema.optional(Schema.String),
62+
/** Ordered RFC 6902 operations reapplied whenever the source spec is refreshed. */
63+
specOverrides: Schema.optional(SpecOverridesSchema),
5864
/** The auth methods a connection's value can be applied through. */
5965
authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),
6066
});
6167

6268
export type OpenApiIntegrationConfig = Omit<
6369
typeof OpenApiIntegrationConfigSchema.Type,
64-
"authenticationTemplate"
70+
"authenticationTemplate" | "specOverrides"
6571
> & {
6672
/** Branded over the schema's structural form so the template renderer can
6773
* treat `slug` as an `AuthTemplateSlug`. */
6874
readonly authenticationTemplate?: readonly Authentication[];
75+
readonly specOverrides?: SpecOverrides;
6976
};
7077

7178
const decodeConfig = Schema.decodeUnknownOption(OpenApiIntegrationConfigSchema);

packages/plugins/openapi/src/sdk/errors.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ export class OpenApiExtractionError extends Schema.TaggedErrorClass<OpenApiExtra
2525
{ httpApiStatus: 400 },
2626
) {}
2727

28+
export class OpenApiSpecOverrideError extends Schema.TaggedErrorClass<OpenApiSpecOverrideError>()(
29+
"OpenApiSpecOverrideError",
30+
{
31+
operationIndex: Schema.Number,
32+
operation: Schema.String,
33+
path: Schema.String,
34+
message: Schema.String,
35+
},
36+
{ httpApiStatus: 400 },
37+
) {}
38+
2839
export class OpenApiInvocationError extends Data.TaggedError("OpenApiInvocationError")<{
2940
readonly message: string;
3041
readonly statusCode: Option.Option<number>;

0 commit comments

Comments
 (0)