History

Support persisted OpenAPI spec overrides

Rhys Sullivan committed 4 months ago

Commit 8e1f8401 parent 171de2015 files · +920 −20Browse files

Unified · Split

15 files
  1. .changeset/persist-openapi-spec-overrides.md +5 −0
  2. packages/plugins/openapi/src/api/group.ts +13 −1
  3. packages/plugins/openapi/src/api/handlers.ts +6 −0
  4. packages/plugins/openapi/src/promise.ts +1 −0
  5. packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx +35 −1
  6. packages/plugins/openapi/src/react/SpecOverridesEditor.tsx +50 −0
  7. packages/plugins/openapi/src/react/UpdateSpecSection.tsx +21 −4
  8. packages/plugins/openapi/src/sdk/config.ts +8 −1
  9. packages/plugins/openapi/src/sdk/errors.ts +11 −0
  10. packages/plugins/openapi/src/sdk/index.ts +8 −0
  11. packages/plugins/openapi/src/sdk/parse.ts +3 −2
  12. packages/plugins/openapi/src/sdk/plugin.ts +109 −11
  13. packages/plugins/openapi/src/sdk/spec-overrides-lifecycle.test.ts +164 −0
  14. packages/plugins/openapi/src/sdk/spec-overrides.test.ts +125 −0
  15. packages/plugins/openapi/src/sdk/spec-overrides.ts +361 −0
.changeset/persist-openapi-spec-overrides.md+5−0
@@ -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+13−1
@@ -9,8 +9,14 @@ import { · 8 unmodified lines
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 = [ · 8 unmodified lines
2531 OpenApiParseError,
2632 OpenApiExtractionError,
2733 OpenApiOAuthError,
34+ OpenApiSpecOverrideError,
2835 IntegrationAlreadyExistsError,
2936 ] as const;
3037
@@ -35,6 +42,7 @@ const UpdateSpecErrors = [ · 4 unmodified lines
3542 OpenApiParseError,
3643 OpenApiExtractionError,
3744 OpenApiOAuthError,
45+ OpenApiSpecOverrideError,
3846 IntegrationNotFound,
3947 ] as const;
4048
@@ -79,11 +87,13 @@ const AddSpecPayload = Schema.Struct({ · 38 unmodified lines
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({ · 19 unmodified lines
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({ · 23 unmodified lines
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+6−0
@@ -47,6 +47,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 46 unmodified lines
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 · 15 unmodified lines
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 · 26 unmodified lines
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 · 21 unmodified lines
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+1−0
@@ -6,6 +6,7 @@ export type { · 5 unmodified lines
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+35−1
@@ -49,6 +49,8 @@ import type { SpecPreviewSummary } from "../sdk/preview"; · 48 unmodified lines
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: { · 99 unmodified lines
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: { · 73 unmodified lines
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: { · 94 unmodified lines
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: { · 4 unmodified lines
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: { · 24 unmodified lines
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: { · 18 unmodified lines
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: { · 134 unmodified lines
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}
packages/plugins/openapi/src/react/SpecOverridesEditor.tsx+50−0
@@ -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+21−4
@@ -13,6 +13,8 @@ import { Label } from "@executor-js/react/components/label"; · 12 unmodified lines
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) { · 28 unmodified lines
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) { · 2 unmodified lines
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) { · 46 unmodified lines
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+8−1
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
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"; · 4 unmodified lines
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({ · 20 unmodified lines
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({ · 6 unmodified lines
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+11−0
@@ -25,6 +25,17 @@ export class OpenApiExtractionError extends Schema.TaggedErrorClass<OpenApiExtra · 24 unmodified lines
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>;
packages/plugins/openapi/src/sdk/index.ts+8−0
@@ -1,4 +1,11 @@
11 export { parse, resolveSpecText, fetchSpecText } from "./parse";
2+export {
3+ applySpecOverrides,
4+ JsonPatchOperationSchema,
5+ SpecOverridesSchema,
6+ type JsonPatchOperation,
7+ type SpecOverrides,
8+} from "./spec-overrides";
29 export { extract, streamOperationBindingsFromStructure } from "./extract";
310 export {
411 structuralSplit,
@@ -92,6 +99,7 @@ export { · 87 unmodified lines
9299 OpenApiExtractionError,
93100 OpenApiInvocationError,
94101 OpenApiOAuthError,
102+ OpenApiSpecOverrideError,
95103 OpenApiAuthRequiredError,
96104 } from "./errors";
97105
packages/plugins/openapi/src/sdk/parse.ts+3−2
@@ -79,7 +79,7 @@ export const resolveSpecText = (input: string, credentials?: SpecFetchCredential · 78 unmodified lines
7979 * the 128MB Cloudflare Workers memory cap.
8080 */
8181 export const parse = Effect.fn("OpenApi.parse")(function* (text: string) {
82− const api = yield* parseTextToObject(text);
82+ const api = yield* parseSpecObject(text);
8383
8484 if (!isOpenApi3(api)) {
8585 return yield* new OpenApiExtractionErrorFromParse({
@@ -98,7 +98,8 @@ export const parse = Effect.fn("OpenApi.parse")(function* (text: string) { · 12 unmodified lines
9898 const isOpenApi3 = (doc: OpenAPI.Document): doc is OpenAPIV3.Document | OpenAPIV3_1.Document =>
9999 "openapi" in doc && typeof doc.openapi === "string" && doc.openapi.startsWith("3.");
100100
101−const parseTextToObject = (text: string): Effect.Effect<OpenAPI.Document, OpenApiParseError> =>
101+/** Parse JSON or YAML text into an object without applying OpenAPI version validation. */
102+export const parseSpecObject = (text: string): Effect.Effect<OpenAPI.Document, OpenApiParseError> =>
102103 Effect.gen(function* () {
103104 const trimmed = text.trim();
104105 if (trimmed.length === 0) {
packages/plugins/openapi/src/sdk/plugin.ts+109−11
@@ -22,8 +22,13 @@ import { · 21 unmodified lines
2222 } from "@executor-js/sdk/core";
2323
2424 import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config";
25−import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors";
26−import { parse, resolveSpecText } from "./parse";
25+import {
26+ OpenApiExtractionError,
27+ OpenApiOAuthError,
28+ OpenApiParseError,
29+ OpenApiSpecOverrideError,
30+} from "./errors";
31+import { parse, parseSpecObject, resolveSpecText } from "./parse";
2732 import { extract } from "./extract";
2833 import {
2934 OAuth2AuthorizationCodeFlow,
@@ -57,6 +62,9 @@ import { · 27 unmodified lines
5762 } from "./backing";
5863 import type { InvokeOptions } from "./invoke";
5964 import { resolveServerUrl } from "./openapi-utils";
65+import { applySpecOverrides, SpecOverridesSchema, type SpecOverrides } from "./spec-overrides";
66+
67+const encodeJsonText = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);
6068
6169 // ---------------------------------------------------------------------------
6270 // Extension input shapes
@@ -67,6 +75,7 @@ export type OpenApiSpecInput = typeof OpenApiSpecInputSchema.Type; · 4 unmodified lines
6775 export interface OpenApiPreviewInput {
6876 readonly spec: string;
6977 readonly specFormat?: string;
78+ readonly specOverrides?: SpecOverrides;
7079 }
7180
7281 /** Add an OpenAPI integration to the catalog. The integration is the API
@@ -87,6 +96,8 @@ export interface OpenApiSpecConfig { · 14 unmodified lines
8796 /** Static query params applied to every request. */
8897 readonly queryParams?: Record<string, string>;
8998 readonly specFormat?: string;
99+ /** Ordered RFC 6902 operations applied after fetching/conversion and before parsing. */
100+ readonly specOverrides?: SpecOverrides;
90101 readonly family?: string;
91102 readonly healthCheck?: HealthCheckSpec;
92103 /** Auth methods a connection's value renders through - canonical
@@ -124,6 +135,8 @@ export interface OpenApiUpdateSpecInput { · 31 unmodified lines
124135 /** New spec source. Omit to re-fetch from the integration's stored
125136 * `specUrl`. */
126137 readonly spec?: OpenApiSpecInput;
138+ /** Replacement override list. Omit to keep existing overrides; pass [] to clear them. */
139+ readonly specOverrides?: SpecOverrides;
127140 }
128141
129142 export interface OpenApiPluginExtension {
@@ -131,7 +144,11 @@ export interface OpenApiPluginExtension { · 1 unmodified line
131144 input: string | OpenApiPreviewInput,
132145 ) => Effect.Effect<
133146 SpecPreview,
134− OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError | StorageFailure
147+ | OpenApiParseError
148+ | OpenApiExtractionError
149+ | OpenApiOAuthError
150+ | OpenApiSpecOverrideError
151+ | StorageFailure
135152 >;
136153 readonly addSpec: (
137154 config: OpenApiSpecConfig,
@@ -140,6 +157,7 @@ export interface OpenApiPluginExtension { · 2 unmodified lines
140157 | OpenApiParseError
141158 | OpenApiExtractionError
142159 | OpenApiOAuthError
160+ | OpenApiSpecOverrideError
143161 | IntegrationAlreadyExistsError
144162 | StorageFailure
145163 >;
@@ -154,6 +172,7 @@ export interface OpenApiPluginExtension { · 8 unmodified lines
154172 | OpenApiParseError
155173 | OpenApiExtractionError
156174 | OpenApiOAuthError
175+ | OpenApiSpecOverrideError
157176 | IntegrationNotFoundError
158177 | StorageFailure
159178 >;
@@ -179,6 +198,7 @@ export interface OpenApiPluginExtension { · 19 unmodified lines
179198 const PreviewSpecInputSchema = Schema.Struct({
180199 spec: Schema.String,
181200 specFormat: Schema.optional(Schema.String),
201+ specOverrides: Schema.optional(SpecOverridesSchema),
182202 });
183203
184204 const StaticPreviewServerVariableSchema = Schema.Struct({
@@ -282,6 +302,7 @@ const AddIntegrationInputSchema = Schema.Struct({ · 97 unmodified lines
282302 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
283303 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
284304 specFormat: Schema.optional(Schema.String),
305+ specOverrides: Schema.optional(SpecOverridesSchema),
285306 family: Schema.optional(Schema.String),
286307 healthCheck: Schema.optional(HealthCheckSpec),
287308 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),
@@ -596,10 +617,36 @@ export const openApiPlugin = definePlugin< · 308 unmodified lines
596617 OpenapiStore,
597618 OpenApiPluginOptions
598619 >((options?: OpenApiPluginOptions) => {
620+ interface ResolvedSpec extends ConvertedSpec {
621+ readonly sourceSpecText: string;
622+ }
623+
624+ const applyOverridesToResolvedSpec = Effect.fn("OpenApi.applyOverridesToResolvedSpec")(function* (
625+ resolved: ConvertedSpec,
626+ overrides: SpecOverrides | undefined,
627+ ) {
628+ if (!overrides || overrides.length === 0) {
629+ return { ...resolved, sourceSpecText: resolved.specText } satisfies ResolvedSpec;
630+ }
631+ const document = yield* parseSpecObject(resolved.specText);
632+ const patched = yield* applySpecOverrides(document, overrides);
633+ return {
634+ ...resolved,
635+ sourceSpecText: resolved.specText,
636+ specText: encodeJsonText(patched),
637+ } satisfies ResolvedSpec;
638+ });
639+
599640 const resolveSpecForInput = (
600− config: Pick<OpenApiSpecConfig, "spec" | "specFormat" | "headers" | "queryParams" | "baseUrl">,
641+ config: Pick<
642+ OpenApiSpecConfig,
643+ "spec" | "specFormat" | "specOverrides" | "headers" | "queryParams" | "baseUrl"
644+ >,
601645 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
602− ): Effect.Effect<ConvertedSpec, OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError> =>
646+ ): Effect.Effect<
647+ ResolvedSpec,
648+ OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError | OpenApiSpecOverrideError
649+ > =>
603650 Effect.gen(function* () {
604651 const adapter = yield* resolveSpecFormatAdapter(
605652 options?.specFormats ?? [],
@@ -611,7 +658,7 @@ export const openApiPlugin = definePlugin< · 5 unmodified lines
611658 message: "Spec format adapters require a URL spec input",
612659 });
613660 }
614− return yield* adapter.fetch({
661+ const resolved = yield* adapter.fetch({
615662 urls: [config.spec.url],
616663 credentials: {
617664 ...(config.headers ? { headers: config.headers } : {}),
@@ -619,14 +666,21 @@ export const openApiPlugin = definePlugin< · 1 unmodified line
619666 },
620667 httpClientLayer,
621668 });
669+ return yield* applyOverridesToResolvedSpec(resolved, config.specOverrides);
622670 }
623671 if (config.spec.kind === "url") {
624672 const specText = yield* resolveSpecText(config.spec.url).pipe(
625673 Effect.provide(httpClientLayer),
626674 );
627− return { specText, specUrl: config.spec.url };
675+ return yield* applyOverridesToResolvedSpec(
676+ { specText, specUrl: config.spec.url },
677+ config.specOverrides,
678+ );
628679 }
629− return { specText: config.spec.value };
680+ return yield* applyOverridesToResolvedSpec(
681+ { specText: config.spec.value },
682+ config.specOverrides,
683+ );
630684 });
631685
632686 return {
@@ -768,6 +822,10 @@ export const openApiPlugin = definePlugin< · 135 unmodified lines
768822 }
769823
770824 const specHash = yield* sha256Hex(resolved.specText);
825+ const sourceSpecHash =
826+ config.specOverrides && config.specOverrides.length > 0
827+ ? yield* sha256Hex(resolved.sourceSpecText)
828+ : undefined;
771829
772830 const integrationConfig: OpenApiIntegrationConfig = {
773831 ...(resolved.config ?? {}),
@@ -783,6 +841,9 @@ export const openApiPlugin = definePlugin< · 9 unmodified lines
783841 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
784842 ...(config.specFormat ? { specFormat: config.specFormat } : {}),
785843 ...(config.family ? { family: config.family } : {}),
844+ ...(config.specOverrides && config.specOverrides.length > 0
845+ ? { specOverrides: config.specOverrides, sourceSpecHash }
846+ : {}),
786847 // Prefer the caller's explicit template; otherwise derive from the
787848 // spec's declared security schemes.
788849 ...(config.authenticationTemplate
@@ -801,6 +862,9 @@ export const openApiPlugin = definePlugin< · 12 unmodified lines
801862 // leaves only an unreferenced blob behind - while blob backends like
802863 // R2 couldn't roll back with the transaction anyway.
803864 yield* ctx.storage.putSpec(specHash, resolved.specText);
865+ if (sourceSpecHash) {
866+ yield* ctx.storage.putSpec(sourceSpecHash, resolved.sourceSpecText);
867+ }
804868 // The content-addressed defs blob lets the serve path resolve the
805869 // shared `definitions` without re-parsing the spec. Same idempotent,
806870 // outside-the-transaction rationale as the spec blob.
@@ -870,8 +934,21 @@ export const openApiPlugin = definePlugin< · 63 unmodified lines
870934 // The new spec source: explicit input wins; otherwise re-fetch from
871935 // where the spec originally came from. A pasted-blob integration has
872936 // no origin, so updating it requires a new input.
937+ const nextOverrides = input?.specOverrides ?? current.specOverrides ?? [];
938+ const storedSourceHash = current.sourceSpecHash ?? current.specHash;
939+ const storedSourceText =
940+ input?.spec === undefined && !current.specUrl && input?.specOverrides !== undefined
941+ ? storedSourceHash
942+ ? yield* ctx.storage.getSpec(storedSourceHash)
943+ : null
944+ : null;
873945 const specInput: OpenApiSpecInput | null =
874− input?.spec ?? (current.specUrl ? { kind: "url", url: current.specUrl } : null);
946+ input?.spec ??
947+ (current.specUrl
948+ ? { kind: "url", url: current.specUrl }
949+ : storedSourceText
950+ ? { kind: "blob", value: storedSourceText }
951+ : null);
875952 if (specInput === null) {
876953 return yield* new OpenApiParseError({
877954 message:
@@ -885,6 +962,7 @@ export const openApiPlugin = definePlugin< · 7 unmodified lines
885962 {
886963 spec: specInput,
887964 specFormat: current.specFormat,
965+ specOverrides: nextOverrides,
888966 headers: current.headers,
889967 queryParams: current.queryParams,
890968 baseUrl: current.baseUrl,
@@ -903,17 +981,28 @@ export const openApiPlugin = definePlugin< · 12 unmodified lines
903981 // the blob outside the transaction - re-puts are idempotent and an
904982 // aborted config update just leaves an unreferenced blob.
905983 const specHash = yield* sha256Hex(resolved.specText);
984+ const sourceSpecHash =
985+ nextOverrides.length > 0 ? yield* sha256Hex(resolved.sourceSpecText) : undefined;
906986 yield* ctx.storage.putSpec(specHash, resolved.specText);
987+ if (sourceSpecHash) {
988+ yield* ctx.storage.putSpec(sourceSpecHash, resolved.sourceSpecText);
989+ }
907990 if (compiled) {
908991 yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs));
909992 }
910993
994+ const {
995+ sourceSpecHash: _currentSourceSpecHash,
996+ specOverrides: _currentSpecOverrides,
997+ ...currentWithoutOverrides
998+ } = current;
911999 const nextConfig: OpenApiIntegrationConfig = {
912− ...current,
1000+ ...currentWithoutOverrides,
9131001 specHash,
9141002 ...((resolved.specUrl ?? specInputToSpecUrl(specInput)) !== undefined
9151003 ? { specUrl: resolved.specUrl ?? specInputToSpecUrl(specInput) }
9161004 : {}),
1005+ ...(nextOverrides.length > 0 ? { specOverrides: nextOverrides, sourceSpecHash } : {}),
9171006 };
9181007
9191008 yield* ctx.transaction(
@@ -985,7 +1074,11 @@ export const openApiPlugin = definePlugin< · 65 unmodified lines
9851074 ? { kind: "url" as const, url: previewInput.spec.trim() }
9861075 : { kind: "blob" as const, value: previewInput.spec };
9871076 const resolved = yield* resolveSpecForInput(
988− { spec, specFormat: previewInput.specFormat },
1077+ {
1078+ spec,
1079+ specFormat: previewInput.specFormat,
1080+ specOverrides: previewInput.specOverrides,
1081+ },
9891082 httpClientLayer,
9901083 );
9911084 const preview = yield* previewSpecText(resolved.specText);
@@ -1093,6 +1186,8 @@ export const openApiPlugin = definePlugin< · 101 unmodified lines
10931186 Effect.succeed(openApiToolFailure("openapi_extraction_failed", message)),
10941187 OpenApiOAuthError: ({ message }: OpenApiOAuthError) =>
10951188 Effect.succeed(openApiToolFailure("openapi_oauth_failed", message)),
1189+ OpenApiSpecOverrideError: ({ message }) =>
1190+ Effect.succeed(openApiToolFailure("openapi_spec_override_failed", message)),
10961191 }),
10971192 ),
10981193 }),
@@ -1117,6 +1212,7 @@ export const openApiPlugin = definePlugin< · 18 unmodified lines
11171212 headers: input.headers,
11181213 queryParams: input.queryParams,
11191214 specFormat: input.specFormat,
1215+ specOverrides: input.specOverrides,
11201216 family: input.family,
11211217 healthCheck: input.healthCheck,
11221218 authenticationTemplate: input.authenticationTemplate as
@@ -1137,6 +1233,8 @@ export const openApiPlugin = definePlugin< · 14 unmodified lines
11371233 Effect.succeed(openApiToolFailure("openapi_extraction_failed", message)),
11381234 OpenApiOAuthError: ({ message }: OpenApiOAuthError) =>
11391235 Effect.succeed(openApiToolFailure("openapi_oauth_failed", message)),
1236+ OpenApiSpecOverrideError: ({ message }) =>
1237+ Effect.succeed(openApiToolFailure("openapi_spec_override_failed", message)),
11401238 IntegrationAlreadyExistsError: ({ slug }: IntegrationAlreadyExistsError) =>
11411239 Effect.succeed(
11421240 openApiToolFailure(
packages/plugins/openapi/src/sdk/spec-overrides-lifecycle.test.ts+164−0
@@ -0,0 +1,164 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Option, Schema } from "effect";
3+import { FetchHttpClient } from "effect/unstable/http";
4+import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
5+
6+import { createExecutor, sha256Hex } from "@executor-js/sdk";
7+import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
8+
9+import { openApiPlugin } from "./plugin";
10+import { applySpecOverrides, type SpecOverrides } from "./spec-overrides";
11+import { serveMutableOpenApiSpecTestServer } from "../testing";
12+
13+const testPlugins = () =>
14+ [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const;
15+
16+const oauthSpec = {
17+ openapi: "3.1.0",
18+ info: { title: "Scoped API", version: "1.0.0" },
19+ paths: {
20+ "/me": {
21+ get: {
22+ operationId: "getMe",
23+ security: [{ OAuth2: ["current_user:read"] }],
24+ responses: { "200": { description: "ok" } },
25+ },
26+ },
27+ },
28+ components: {
29+ securitySchemes: {
30+ OAuth2: {
31+ type: "oauth2",
32+ flows: {
33+ authorizationCode: {
34+ authorizationUrl: "https://example.com/oauth/authorize",
35+ tokenUrl: "https://example.com/oauth/token",
36+ scopes: {
37+ "current_user:read": "Read the current user",
38+ "files:read": "Read files",
39+ "file_variables:read": "Read variables",
40+ },
41+ },
42+ },
43+ },
44+ },
45+ },
46+ security: [{ OAuth2: ["current_user:read"] }],
47+};
48+
49+const scopeOverrides: SpecOverrides = [
50+ {
51+ op: "replace",
52+ path: "/components/securitySchemes/OAuth2/flows/authorizationCode/scopes",
53+ value: { "current_user:read": "Read the current user" },
54+ },
55+];
56+
57+const encodeJsonText = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);
58+
59+describe("OpenAPI spec override lifecycle", () => {
60+ it.effect("applies scope overrides to preview and persists raw and patched hashes", () =>
61+ Effect.scoped(
62+ Effect.gen(function* () {
63+ const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
64+ const sourceText = encodeJsonText(oauthSpec);
65+
66+ const preview = yield* executor.openapi.previewSpec({
67+ spec: sourceText,
68+ specOverrides: scopeOverrides,
69+ });
70+ expect(preview.oauth2Presets).toHaveLength(1);
71+ expect(preview.oauth2Presets[0]?.scopes).toEqual({
72+ "current_user:read": "Read the current user",
73+ });
74+
75+ yield* executor.openapi.addSpec({
76+ spec: { kind: "blob", value: sourceText },
77+ slug: "scoped_api",
78+ specOverrides: scopeOverrides,
79+ });
80+
81+ const config = yield* executor.openapi.getConfig("scoped_api");
82+ const patchedDocument = yield* applySpecOverrides(oauthSpec, scopeOverrides);
83+ const patchedText = encodeJsonText(patchedDocument);
84+ expect(config?.specOverrides).toEqual(scopeOverrides);
85+ expect(config?.sourceSpecHash).toBe(yield* sha256Hex(sourceText));
86+ expect(config?.specHash).toBe(yield* sha256Hex(patchedText));
87+ const oauth = config?.authenticationTemplate?.find((method) => method.kind === "oauth2");
88+ expect(oauth).toMatchObject({ kind: "oauth2", scopes: ["current_user:read"] });
89+ }),
90+ ),
91+ );
92+
93+ it.effect("changes and clears overrides for an inline spec without repasting the source", () =>
94+ Effect.scoped(
95+ Effect.gen(function* () {
96+ const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
97+ const sourceText = encodeJsonText(oauthSpec);
98+ yield* executor.openapi.addSpec({
99+ spec: { kind: "blob", value: sourceText },
100+ slug: "inline_overrides",
101+ specOverrides: scopeOverrides,
102+ });
103+
104+ const titleOverrides: SpecOverrides = [
105+ { op: "replace", path: "/info/title", value: "Renamed API" },
106+ ];
107+ yield* executor.openapi.updateSpec("inline_overrides", {
108+ specOverrides: titleOverrides,
109+ });
110+ const renamed = yield* executor.openapi.getConfig("inline_overrides");
111+ const renamedDocument = yield* applySpecOverrides(oauthSpec, titleOverrides);
112+ expect(renamed?.specOverrides).toEqual(titleOverrides);
113+ expect(renamed?.specHash).toBe(yield* sha256Hex(encodeJsonText(renamedDocument)));
114+ expect(renamed?.sourceSpecHash).toBe(yield* sha256Hex(sourceText));
115+
116+ yield* executor.openapi.updateSpec("inline_overrides", { specOverrides: [] });
117+ const cleared = yield* executor.openapi.getConfig("inline_overrides");
118+ expect(cleared?.specOverrides).toBeUndefined();
119+ expect(cleared?.sourceSpecHash).toBeUndefined();
120+ expect(cleared?.specHash).toBe(yield* sha256Hex(sourceText));
121+ }),
122+ ),
123+ );
124+
125+ it.effect("reapplies overrides when a URL-hosted spec changes upstream", () =>
126+ Effect.scoped(
127+ Effect.gen(function* () {
128+ const Items = HttpApiGroup.make("items")
129+ .add(HttpApiEndpoint.get("list", "/items", { success: Schema.Array(Schema.String) }))
130+ .add(HttpApiEndpoint.post("create", "/items", { success: Schema.String }));
131+ const InitialApi = HttpApi.make("initial").add(Items);
132+ const server = yield* serveMutableOpenApiSpecTestServer({ initialApi: InitialApi });
133+ const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
134+ const removeList: SpecOverrides = [{ op: "remove", path: "/paths/~1items/get" }];
135+
136+ yield* executor.openapi.addSpec({
137+ spec: { kind: "url", url: server.specUrl },
138+ slug: "refresh_overrides",
139+ specOverrides: removeList,
140+ });
141+ const initialPreview = yield* executor.openapi.previewSpec({
142+ spec: server.specUrl,
143+ specOverrides: removeList,
144+ });
145+ expect(initialPreview.operations.map((operation) => operation.operationId)).not.toContain(
146+ "items.list",
147+ );
148+
149+ const Widgets = HttpApiGroup.make("widgets").add(
150+ HttpApiEndpoint.get("list", "/widgets", { success: Schema.Array(Schema.String) }),
151+ );
152+ const EvolvedApi = HttpApi.make("evolved").add(Items).add(Widgets);
153+ yield* server.setApi(EvolvedApi);
154+
155+ const updated = yield* executor.openapi.updateSpec("refresh_overrides");
156+ expect(updated.addedTools).toEqual(["widgets.list"]);
157+ expect(updated.removedTools).toEqual([]);
158+ const config = yield* executor.openapi.getConfig("refresh_overrides");
159+ expect(config?.specOverrides).toEqual(removeList);
160+ expect(Option.isSome(initialPreview.title)).toBe(true);
161+ }),
162+ ),
163+ );
164+});
packages/plugins/openapi/src/sdk/spec-overrides.test.ts+125−0
@@ -0,0 +1,125 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+
4+import {
5+ applySpecOverrides,
6+ formatSpecOverridesText,
7+ parseSpecOverridesText,
8+ type SpecOverrides,
9+} from "./spec-overrides";
10+
11+const apply = (document: unknown, overrides: SpecOverrides) =>
12+ Effect.runPromise(applySpecOverrides(document, overrides));
13+
14+describe("OpenAPI spec overrides", () => {
15+ it("parses and formats the JSON Patch editor contract", () => {
16+ expect(parseSpecOverridesText("")).toEqual({ ok: true, value: [] });
17+ expect(parseSpecOverridesText('[{"op":"remove","path":"/paths/~1legacy"}]')).toEqual({
18+ ok: true,
19+ value: [{ op: "remove", path: "/paths/~1legacy" }],
20+ });
21+ expect(parseSpecOverridesText('{"op":"remove"}')).toMatchObject({ ok: false });
22+ expect(formatSpecOverridesText([{ op: "remove", path: "/paths/~1legacy" }])).toContain(
23+ '"path": "/paths/~1legacy"',
24+ );
25+ });
26+
27+ it("applies ordered RFC 6902 operations without mutating the source", async () => {
28+ const source = {
29+ components: {
30+ securitySchemes: {
31+ OAuth2: {
32+ flows: {
33+ authorizationCode: {
34+ scopes: { read: "Read", write: "Write" },
35+ },
36+ },
37+ },
38+ },
39+ },
40+ tags: ["one", "two"],
41+ };
42+
43+ const result = await apply(source, [
44+ {
45+ op: "test",
46+ path: "/components/securitySchemes/OAuth2/flows/authorizationCode/scopes/read",
47+ value: "Read",
48+ },
49+ {
50+ op: "replace",
51+ path: "/components/securitySchemes/OAuth2/flows/authorizationCode/scopes",
52+ value: { read: "Read" },
53+ },
54+ { op: "add", path: "/tags/-", value: "three" },
55+ { op: "copy", from: "/tags/0", path: "/primaryTag" },
56+ { op: "move", from: "/tags/1", path: "/tags/0" },
57+ { op: "remove", path: "/tags/2" },
58+ ]);
59+
60+ expect(result).toEqual({
61+ components: {
62+ securitySchemes: {
63+ OAuth2: {
64+ flows: {
65+ authorizationCode: {
66+ scopes: { read: "Read" },
67+ },
68+ },
69+ },
70+ },
71+ },
72+ tags: ["two", "one"],
73+ primaryTag: "one",
74+ });
75+ expect(source.components.securitySchemes.OAuth2.flows.authorizationCode.scopes).toEqual({
76+ read: "Read",
77+ write: "Write",
78+ });
79+ expect(source.tags).toEqual(["one", "two"]);
80+ });
81+
82+ it("reports the exact stale operation and path", async () => {
83+ const error = await Effect.runPromise(
84+ applySpecOverrides({ openapi: "3.1.0" }, [
85+ { op: "replace", path: "/components/securitySchemes/OAuth2", value: {} },
86+ ]).pipe(Effect.flip),
87+ );
88+
89+ expect(error).toMatchObject({
90+ _tag: "OpenApiSpecOverrideError",
91+ operationIndex: 0,
92+ operation: "replace",
93+ path: "/components/securitySchemes/OAuth2",
94+ message: expect.stringContaining(
95+ "Spec override 1 (replace /components/securitySchemes/OAuth2) failed",
96+ ),
97+ });
98+ });
99+
100+ it("rejects invalid pointers and failed test operations", async () => {
101+ const invalidPointer = await Effect.runPromise(
102+ applySpecOverrides({ openapi: "3.1.0" }, [
103+ { op: "add", path: "openapi", value: "3.0.0" },
104+ ]).pipe(Effect.flip),
105+ );
106+ expect(invalidPointer.message).toContain("Invalid JSON Pointer");
107+
108+ const failedTest = await Effect.runPromise(
109+ applySpecOverrides({ openapi: "3.1.0" }, [
110+ { op: "test", path: "/openapi", value: "3.0.0" },
111+ ]).pipe(Effect.flip),
112+ );
113+ expect(failedTest.message).toContain("The test value did not match");
114+ });
115+
116+ it("treats prototype-shaped keys as ordinary JSON properties", async () => {
117+ const result = await apply({ openapi: "3.1.0" }, [
118+ { op: "add", path: "/__proto__", value: { polluted: true } },
119+ ]);
120+
121+ expect(Object.hasOwn(result, "__proto__")).toBe(true);
122+ expect(Object.getPrototypeOf(result)).toBeNull();
123+ expect(Object.hasOwn(Object.prototype, "polluted")).toBe(false);
124+ });
125+});
packages/plugins/openapi/src/sdk/spec-overrides.ts+361−0
@@ -0,0 +1,361 @@
1+import { Data, Effect, Exit, Match, Schema } from "effect";
2+
3+import { OpenApiSpecOverrideError } from "./errors";
4+
5+export const JsonPatchOperationSchema = Schema.Union([
6+ Schema.Struct({
7+ op: Schema.Literal("add"),
8+ path: Schema.String,
9+ value: Schema.Unknown,
10+ }),
11+ Schema.Struct({
12+ op: Schema.Literal("remove"),
13+ path: Schema.String,
14+ }),
15+ Schema.Struct({
16+ op: Schema.Literal("replace"),
17+ path: Schema.String,
18+ value: Schema.Unknown,
19+ }),
20+ Schema.Struct({
21+ op: Schema.Literal("move"),
22+ path: Schema.String,
23+ from: Schema.String,
24+ }),
25+ Schema.Struct({
26+ op: Schema.Literal("copy"),
27+ path: Schema.String,
28+ from: Schema.String,
29+ }),
30+ Schema.Struct({
31+ op: Schema.Literal("test"),
32+ path: Schema.String,
33+ value: Schema.Unknown,
34+ }),
35+]);
36+
37+export const SpecOverridesSchema = Schema.Array(JsonPatchOperationSchema);
38+
39+export type JsonPatchOperation = typeof JsonPatchOperationSchema.Type;
40+export type SpecOverrides = typeof SpecOverridesSchema.Type;
41+
42+const decodeSpecOverridesText = Schema.decodeUnknownExit(
43+ Schema.fromJsonString(SpecOverridesSchema),
44+);
45+
46+export type SpecOverridesTextResult =
47+ | { readonly ok: true; readonly value: SpecOverrides }
48+ | { readonly ok: false; readonly message: string };
49+
50+/** Decode user-authored JSON Patch text into the persisted override contract. */
51+export const parseSpecOverridesText = (text: string): SpecOverridesTextResult => {
52+ if (text.trim().length === 0) return { ok: true, value: [] };
53+ const decoded = decodeSpecOverridesText(text);
54+ return Exit.isSuccess(decoded)
55+ ? { ok: true, value: decoded.value }
56+ : {
57+ ok: false,
58+ message:
59+ "Spec overrides must be a JSON array of RFC 6902 add, remove, replace, move, copy, or test operations.",
60+ };
61+};
62+
63+/** Format persisted overrides for the advanced JSON editor. */
64+export const formatSpecOverridesText = (overrides: SpecOverrides | undefined): string =>
65+ overrides && overrides.length > 0 ? JSON.stringify(overrides, null, 2) : "";
66+
67+type JsonPrimitive = string | number | boolean | null;
68+type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
69+interface JsonObject {
70+ [key: string]: JsonValue;
71+}
72+
73+class PatchApplicationFailure extends Data.TaggedError("PatchApplicationFailure")<{
74+ readonly detail: string;
75+}> {}
76+
77+const patchFailure = (detail: string) => new PatchApplicationFailure({ detail });
78+
79+const isJsonValue = (value: unknown): value is JsonValue => {
80+ if (value === null) return true;
81+ if (typeof value === "string" || typeof value === "boolean") return true;
82+ if (typeof value === "number") return Number.isFinite(value);
83+ if (Array.isArray(value)) return value.every(isJsonValue);
84+ if (typeof value !== "object") return false;
85+ const prototype = Object.getPrototypeOf(value);
86+ if (prototype !== Object.prototype && prototype !== null) return false;
87+ return Object.values(value).every(isJsonValue);
88+};
89+
90+const overrideError = (operationIndex: number, operation: string, path: string, detail: string) =>
91+ new OpenApiSpecOverrideError({
92+ operationIndex,
93+ operation,
94+ path,
95+ message: `Spec override ${operationIndex + 1} (${operation} ${path || "/"}) failed: ${detail}`,
96+ });
97+
98+const cloneJsonValue = (value: JsonValue): JsonValue => {
99+ if (Array.isArray(value)) return value.map(cloneJsonValue);
100+ if (value !== null && typeof value === "object") {
101+ const clone: JsonObject = Object.create(null);
102+ for (const [key, entry] of Object.entries(value)) clone[key] = cloneJsonValue(entry);
103+ return clone;
104+ }
105+ return value;
106+};
107+
108+const parsePointer = Effect.fn("OpenApi.parseJsonPointer")(function* (pointer: string) {
109+ if (pointer === "") return [];
110+ if (!pointer.startsWith("/")) {
111+ return yield* patchFailure(`Invalid JSON Pointer ${JSON.stringify(pointer)}`);
112+ }
113+ const segments: string[] = [];
114+ for (const segment of pointer.slice(1).split("/")) {
115+ if (/~(?:[^01]|$)/.test(segment)) {
116+ return yield* patchFailure(`Invalid JSON Pointer escape in ${JSON.stringify(pointer)}`);
117+ }
118+ segments.push(segment.replaceAll("~1", "/").replaceAll("~0", "~"));
119+ }
120+ return segments;
121+});
122+
123+const arrayIndex = Effect.fn("OpenApi.jsonPatchArrayIndex")(function* (
124+ segment: string,
125+ length: number,
126+ pointer: string,
127+ options: { readonly append: boolean; readonly existing: boolean },
128+) {
129+ if (segment === "-") {
130+ if (options.append) return length;
131+ return yield* patchFailure(`JSON Pointer ${JSON.stringify(pointer)} cannot use '-' here`);
132+ }
133+ if (!/^(0|[1-9]\d*)$/.test(segment)) {
134+ return yield* patchFailure(
135+ `JSON Pointer ${JSON.stringify(pointer)} has invalid array index ${JSON.stringify(segment)}`,
136+ );
137+ }
138+ const index = Number(segment);
139+ const upperBound = options.existing ? length - 1 : length;
140+ if (!Number.isSafeInteger(index) || index < 0 || index > upperBound) {
141+ return yield* patchFailure(
142+ `JSON Pointer ${JSON.stringify(pointer)} has array index ${index} outside the valid range`,
143+ );
144+ }
145+ return index;
146+});
147+
148+const valueAt = Effect.fn("OpenApi.jsonPatchValueAt")(function* (root: JsonValue, pointer: string) {
149+ const segments = yield* parsePointer(pointer);
150+ let current = root;
151+ for (const segment of segments) {
152+ if (Array.isArray(current)) {
153+ current =
154+ current[
155+ yield* arrayIndex(segment, current.length, pointer, {
156+ append: false,
157+ existing: true,
158+ })
159+ ]!;
160+ continue;
161+ }
162+ if (current !== null && typeof current === "object") {
163+ if (!Object.hasOwn(current, segment)) {
164+ return yield* patchFailure(`JSON Pointer ${JSON.stringify(pointer)} does not exist`);
165+ }
166+ current = current[segment]!;
167+ continue;
168+ }
169+ return yield* patchFailure(`JSON Pointer ${JSON.stringify(pointer)} cannot be traversed`);
170+ }
171+ return current;
172+});
173+
174+const parentAt = Effect.fn("OpenApi.jsonPatchParentAt")(function* (
175+ root: JsonValue,
176+ pointer: string,
177+) {
178+ const segments = yield* parsePointer(pointer);
179+ if (segments.length === 0) {
180+ return yield* patchFailure("The document root has no parent");
181+ }
182+ const parentPointer = `/${segments
183+ .slice(0, -1)
184+ .map((segment) => segment.replaceAll("~", "~0").replaceAll("/", "~1"))
185+ .join("/")}`;
186+ const parent = segments.length === 1 ? root : yield* valueAt(root, parentPointer);
187+ if (parent === null || typeof parent !== "object") {
188+ return yield* patchFailure(`JSON Pointer ${JSON.stringify(pointer)} has no container`);
189+ }
190+ return { parent, segment: segments[segments.length - 1]! };
191+});
192+
193+const addValue = Effect.fn("OpenApi.jsonPatchAddValue")(function* (
194+ root: JsonValue,
195+ pointer: string,
196+ value: JsonValue,
197+) {
198+ if (pointer === "") return cloneJsonValue(value);
199+ const { parent, segment } = yield* parentAt(root, pointer);
200+ if (Array.isArray(parent)) {
201+ const index = yield* arrayIndex(segment, parent.length, pointer, {
202+ append: true,
203+ existing: false,
204+ });
205+ parent.splice(index, 0, cloneJsonValue(value));
206+ } else {
207+ parent[segment] = cloneJsonValue(value);
208+ }
209+ return root;
210+});
211+
212+const removeValue = Effect.fn("OpenApi.jsonPatchRemoveValue")(function* (
213+ root: JsonValue,
214+ pointer: string,
215+) {
216+ if (pointer === "") return { root: null, removed: root };
217+ const { parent, segment } = yield* parentAt(root, pointer);
218+ if (Array.isArray(parent)) {
219+ const index = yield* arrayIndex(segment, parent.length, pointer, {
220+ append: false,
221+ existing: true,
222+ });
223+ return { root, removed: parent.splice(index, 1)[0]! };
224+ }
225+ if (!Object.hasOwn(parent, segment)) {
226+ return yield* patchFailure(`JSON Pointer ${JSON.stringify(pointer)} does not exist`);
227+ }
228+ const removed = parent[segment]!;
229+ delete parent[segment];
230+ return { root, removed };
231+});
232+
233+const replaceValue = Effect.fn("OpenApi.jsonPatchReplaceValue")(function* (
234+ root: JsonValue,
235+ pointer: string,
236+ value: JsonValue,
237+) {
238+ if (pointer === "") return cloneJsonValue(value);
239+ yield* valueAt(root, pointer);
240+ const { parent, segment } = yield* parentAt(root, pointer);
241+ if (Array.isArray(parent)) {
242+ const index = yield* arrayIndex(segment, parent.length, pointer, {
243+ append: false,
244+ existing: true,
245+ });
246+ parent[index] = cloneJsonValue(value);
247+ } else {
248+ parent[segment] = cloneJsonValue(value);
249+ }
250+ return root;
251+});
252+
253+const jsonEquals = (left: JsonValue, right: JsonValue): boolean => {
254+ if (left === right) return true;
255+ if (Array.isArray(left) || Array.isArray(right)) {
256+ return (
257+ Array.isArray(left) &&
258+ Array.isArray(right) &&
259+ left.length === right.length &&
260+ left.every((entry, index) => jsonEquals(entry, right[index]!))
261+ );
262+ }
263+ if (left === null || right === null || typeof left !== "object" || typeof right !== "object") {
264+ return false;
265+ }
266+ const leftKeys = Object.keys(left);
267+ const rightKeys = Object.keys(right);
268+ return (
269+ leftKeys.length === rightKeys.length &&
270+ leftKeys.every((key) => Object.hasOwn(right, key) && jsonEquals(left[key]!, right[key]!))
271+ );
272+};
273+
274+const operationValue = Effect.fn("OpenApi.jsonPatchOperationValue")(function* (
275+ operation: JsonPatchOperation,
276+) {
277+ if (!("value" in operation) || !isJsonValue(operation.value)) {
278+ return yield* patchFailure(`${operation.op} requires a JSON value`);
279+ }
280+ return operation.value;
281+});
282+
283+const applyOperation = (root: JsonValue, operation: JsonPatchOperation) =>
284+ Match.value(operation).pipe(
285+ Match.when({ op: "add" }, (entry) =>
286+ Effect.gen(function* () {
287+ const value = yield* operationValue(entry);
288+ return yield* addValue(root, entry.path, value);
289+ }),
290+ ),
291+ Match.when({ op: "remove" }, (entry) =>
292+ removeValue(root, entry.path).pipe(Effect.map((result) => result.root)),
293+ ),
294+ Match.when({ op: "replace" }, (entry) =>
295+ Effect.gen(function* () {
296+ const value = yield* operationValue(entry);
297+ return yield* replaceValue(root, entry.path, value);
298+ }),
299+ ),
300+ Match.when({ op: "copy" }, (entry) =>
301+ Effect.gen(function* () {
302+ const value = yield* valueAt(root, entry.from);
303+ return yield* addValue(root, entry.path, value);
304+ }),
305+ ),
306+ Match.when({ op: "move" }, (entry) =>
307+ Effect.gen(function* () {
308+ if (entry.path.startsWith(`${entry.from}/`)) {
309+ return yield* patchFailure("A value cannot be moved into one of its children");
310+ }
311+ const removed = yield* removeValue(root, entry.from);
312+ return yield* addValue(removed.root, entry.path, removed.removed);
313+ }),
314+ ),
315+ Match.when({ op: "test" }, (entry) =>
316+ Effect.gen(function* () {
317+ const expected = yield* operationValue(entry);
318+ const actual = yield* valueAt(root, entry.path);
319+ if (!jsonEquals(actual, expected)) {
320+ return yield* patchFailure("The test value did not match");
321+ }
322+ return root;
323+ }),
324+ ),
325+ Match.exhaustive,
326+ );
327+
328+/** Apply ordered RFC 6902 operations to a cloned JSON document. */
329+export const applySpecOverrides = Effect.fn("OpenApi.applySpecOverrides")(function* (
330+ document: unknown,
331+ overrides: SpecOverrides,
332+) {
333+ if (!isJsonValue(document)) {
334+ return yield* overrideError(
335+ 0,
336+ "parse",
337+ "",
338+ "OpenAPI document must contain only JSON-compatible values",
339+ );
340+ }
341+
342+ let result = cloneJsonValue(document);
343+ for (const [operationIndex, operation] of overrides.entries()) {
344+ const applied = yield* applyOperation(result, operation).pipe(
345+ Effect.mapError((failure) =>
346+ overrideError(operationIndex, operation.op, operation.path, failure.detail),
347+ ),
348+ );
349+ result = applied;
350+ }
351+
352+ if (result === null || typeof result !== "object" || Array.isArray(result)) {
353+ return yield* overrideError(
354+ Math.max(0, overrides.length - 1),
355+ overrides.at(-1)?.op ?? "parse",
356+ overrides.at(-1)?.path ?? "",
357+ "Spec overrides must leave the OpenAPI document as an object",
358+ );
359+ }
360+ return result;
361+});