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
5Apply 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
9 IntegrationSlug,9 IntegrationSlug,
10} from "@executor-js/sdk/shared";10} from "@executor-js/sdk/shared";
11 11
12import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors";12import {
13 OpenApiParseError,
14 OpenApiExtractionError,
15 OpenApiOAuthError,
16 OpenApiSpecOverrideError,
17} from "../sdk/errors";
13import { SpecPreviewSummary } from "../sdk/preview";18import { SpecPreviewSummary } from "../sdk/preview";
19import { SpecOverridesSchema } from "../sdk/spec-overrides";
14 20
15// ---------------------------------------------------------------------------21// ---------------------------------------------------------------------------
16// Errors — the plugin-domain tagged errors flow directly to clients22// Errors — the plugin-domain tagged errors flow directly to clients
@@ -25,6 +31,7 @@ const DomainErrors = [ · 8 unmodified lines
25 OpenApiParseError,31 OpenApiParseError,
26 OpenApiExtractionError,32 OpenApiExtractionError,
27 OpenApiOAuthError,33 OpenApiOAuthError,
34 OpenApiSpecOverrideError,
28 IntegrationAlreadyExistsError,35 IntegrationAlreadyExistsError,
29] as const;36] as const;
30 37
@@ -35,6 +42,7 @@ const UpdateSpecErrors = [ · 4 unmodified lines
35 OpenApiParseError,42 OpenApiParseError,
36 OpenApiExtractionError,43 OpenApiExtractionError,
37 OpenApiOAuthError,44 OpenApiOAuthError,
45 OpenApiSpecOverrideError,
38 IntegrationNotFound,46 IntegrationNotFound,
39] as const;47] as const;
40 48
@@ -79,11 +87,13 @@ const AddSpecPayload = Schema.Struct({ · 38 unmodified lines
79 family: Schema.optional(Schema.String),87 family: Schema.optional(Schema.String),
80 healthCheck: Schema.optional(HealthCheckSpec),88 healthCheck: Schema.optional(HealthCheckSpec),
81 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)),89 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)),
90 specOverrides: Schema.optional(SpecOverridesSchema),
82});91});
83 92
84const PreviewSpecPayload = Schema.Struct({93const PreviewSpecPayload = Schema.Struct({
85 spec: Schema.String,94 spec: Schema.String,
86 specFormat: Schema.optional(Schema.String),95 specFormat: Schema.optional(Schema.String),
96 specOverrides: Schema.optional(SpecOverridesSchema),
87});97});
88 98
89// The `configure` payload — the new/updated auth methods to merge onto the99// The `configure` payload — the new/updated auth methods to merge onto the
@@ -109,6 +119,7 @@ const AddSpecResponse = Schema.Struct({ · 19 unmodified lines
109// "re-fetch from the stored source URL".119// "re-fetch from the stored source URL".
110const UpdateSpecPayload = Schema.Struct({120const UpdateSpecPayload = Schema.Struct({
111 spec: Schema.optional(OpenApiSpecInputPayload),121 spec: Schema.optional(OpenApiSpecInputPayload),
122 specOverrides: Schema.optional(SpecOverridesSchema),
112});123});
113 124
114const UpdateSpecResponse = Schema.Struct({125const UpdateSpecResponse = Schema.Struct({
@@ -138,6 +149,7 @@ const OpenApiConfigView = Schema.Struct({ · 23 unmodified lines
138 baseUrl: Schema.optional(Schema.String),149 baseUrl: Schema.optional(Schema.String),
139 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),150 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
140 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),151 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
152 specOverrides: Schema.optional(SpecOverridesSchema),
141 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)),153 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)),
142});154});
143 155
packages/plugins/openapi/src/api/handlers.ts+6−0
@@ -47,6 +47,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 46 unmodified lines
47 const preview = yield* ext.previewSpec({47 const preview = yield* ext.previewSpec({
48 spec: payload.spec,48 spec: payload.spec,
49 specFormat: payload.specFormat,49 specFormat: payload.specFormat,
50 specOverrides: payload.specOverrides,
50 });51 });
51 return specPreviewSummary(preview);52 return specPreviewSummary(preview);
52 }),53 }),
@@ -68,6 +69,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 15 unmodified lines
68 family: payload.family,69 family: payload.family,
69 healthCheck: payload.healthCheck,70 healthCheck: payload.healthCheck,
70 authenticationTemplate: payload.authenticationTemplate,71 authenticationTemplate: payload.authenticationTemplate,
72 specOverrides: payload.specOverrides,
71 });73 });
72 }),74 }),
73 ),75 ),
@@ -100,6 +102,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 26 unmodified lines
100 baseUrl: config.baseUrl,102 baseUrl: config.baseUrl,
101 headers: config.headers ? { ...config.headers } : undefined,103 headers: config.headers ? { ...config.headers } : undefined,
102 queryParams: config.queryParams ? { ...config.queryParams } : undefined,104 queryParams: config.queryParams ? { ...config.queryParams } : undefined,
105 specOverrides: config.specOverrides ? [...config.specOverrides] : undefined,
103 authenticationTemplate: config.authenticationTemplate106 authenticationTemplate: config.authenticationTemplate
104 ? [...config.authenticationTemplate]107 ? [...config.authenticationTemplate]
105 : undefined,108 : undefined,
@@ -127,6 +130,9 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 21 unmodified lines
127 const ext = yield* OpenApiExtensionService;130 const ext = yield* OpenApiExtensionService;
128 const result = yield* ext.updateSpec(params.slug, {131 const result = yield* ext.updateSpec(params.slug, {
129 ...(payload.spec !== undefined ? { spec: payload.spec } : {}),132 ...(payload.spec !== undefined ? { spec: payload.spec } : {}),
133 ...(payload.specOverrides !== undefined
134 ? { specOverrides: payload.specOverrides }
135 : {}),
130 });136 });
131 return {137 return {
132 slug: result.slug,138 slug: result.slug,
packages/plugins/openapi/src/promise.ts+1−0
@@ -6,6 +6,7 @@ export type { · 5 unmodified lines
6 OpenApiSpecInput,6 OpenApiSpecInput,
7 OpenApiPreviewInput,7 OpenApiPreviewInput,
8} from "./sdk/plugin";8} from "./sdk/plugin";
9export type { JsonPatchOperation, SpecOverrides } from "./sdk/spec-overrides";
9 10
10// Auth-template authoring helpers. Author apikey methods as canonical11// Auth-template authoring helpers. Author apikey methods as canonical
11// placements, or request-shaped: `headers: { Authorization: ["Bearer ",12// 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
49import { type Authentication } from "../sdk/types";49import { type Authentication } from "../sdk/types";
50import { resolveServerUrl } from "../sdk/openapi-utils";50import { resolveServerUrl } from "../sdk/openapi-utils";
51import { detectedAuthenticationTemplates } from "../sdk/derive-auth";51import { detectedAuthenticationTemplates } from "../sdk/derive-auth";
52import { parseSpecOverridesText } from "../sdk/spec-overrides";
53import { SpecOverridesEditor } from "./SpecOverridesEditor";
52 54
53const normalizePresetUrl = (url: string): string => {55const normalizePresetUrl = (url: string): string => {
54 const trimmed = url.trim();56 const trimmed = url.trim();
@@ -154,6 +156,7 @@ export default function AddOpenApiIntegration(props: { · 99 unmodified lines
154 const openApiPresets = openApiPlugin?.presets;156 const openApiPresets = openApiPlugin?.presets;
155 const initialPreset = openApiPresets?.find((preset) => preset.id === props.initialPreset) ?? null;157 const initialPreset = openApiPresets?.find((preset) => preset.id === props.initialPreset) ?? null;
156 const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");158 const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");
159 const [specOverridesText, setSpecOverridesText] = useState("");
157 const [analyzing, setAnalyzing] = useState(false);160 const [analyzing, setAnalyzing] = useState(false);
158 const [analyzeError, setAnalyzeError] = useState<string | null>(null);161 const [analyzeError, setAnalyzeError] = useState<string | null>(null);
159 162
@@ -233,6 +236,10 @@ export default function AddOpenApiIntegration(props: { · 73 unmodified lines
233 : resolvedIntegrationId);236 : resolvedIntegrationId);
234 const resolvedDescription =237 const resolvedDescription =
235 descriptionDraft ?? (preview ? Option.getOrElse(preview.description, () => "") : "");238 descriptionDraft ?? (preview ? Option.getOrElse(preview.description, () => "") : "");
239 const parsedSpecOverrides = useMemo(
240 () => parseSpecOverridesText(specOverridesText),
241 [specOverridesText],
242 );
236 243
237 // Register EVERY spec-detected auth method, not just a single selected one.244 // Register EVERY spec-detected auth method, not just a single selected one.
238 // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the245 // 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
333 // args filled (an empty draft, `hcOperation === ""`, imposes no constraint).340 // args filled (an empty draft, `hcOperation === ""`, imposes no constraint).
334 const canAdd =341 const canAdd =
335 preview !== null &&342 preview !== null &&
343 parsedSpecOverrides.ok &&
336 !slugAlreadyExists &&344 !slugAlreadyExists &&
337 (!previewHasNoServers || resolvedBaseUrl.length > 0) &&345 (!previewHasNoServers || resolvedBaseUrl.length > 0) &&
338 !(hcOperation.length > 0 && hcMissingRequired);346 !(hcOperation.length > 0 && hcMissingRequired);
@@ -343,8 +351,19 @@ export default function AddOpenApiIntegration(props: { · 4 unmodified lines
343 setAnalyzing(true);351 setAnalyzing(true);
344 setAnalyzeError(null);352 setAnalyzeError(null);
345 setAddError(null);353 setAddError(null);
354 if (!parsedSpecOverrides.ok) {
355 setAnalyzeError(parsedSpecOverrides.message);
356 setAnalyzing(false);
357 return;
358 }
346 const exit = await doPreview({359 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 },
348 });367 });
349 if (Exit.isFailure(exit)) {368 if (Exit.isFailure(exit)) {
350 setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec"));369 setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec"));
@@ -375,6 +394,9 @@ export default function AddOpenApiIntegration(props: { · 24 unmodified lines
375 specFormat: initialPreset?.specFormat,394 specFormat: initialPreset?.specFormat,
376 family: initialPreset?.family,395 family: initialPreset?.family,
377 healthCheck: initialPreset?.healthCheck,396 healthCheck: initialPreset?.healthCheck,
397 ...(parsedSpecOverrides.ok && parsedSpecOverrides.value.length > 0
398 ? { specOverrides: parsedSpecOverrides.value }
399 : {}),
378 // Always send the edited method list (even empty) when the user has400 // Always send the edited method list (even empty) when the user has
379 // inspected a preview: an explicit [] means "no auth methods", while401 // inspected a preview: an explicit [] means "no auth methods", while
380 // OMITTING the field tells the server to derive defaults from the402 // OMITTING the field tells the server to derive defaults from the
@@ -399,6 +421,7 @@ export default function AddOpenApiIntegration(props: { · 18 unmodified lines
399 resolvedDescription,421 resolvedDescription,
400 resolvedBaseUrl,422 resolvedBaseUrl,
401 editedAuthenticationTemplate,423 editedAuthenticationTemplate,
424 parsedSpecOverrides,
402 initialPreset?.family,425 initialPreset?.family,
403 initialPreset?.healthCheck,426 initialPreset?.healthCheck,
404 initialPreset?.specFormat,427 initialPreset?.specFormat,
@@ -539,6 +562,17 @@ export default function AddOpenApiIntegration(props: { · 134 unmodified lines
539 />562 />
540 ) : null}563 ) : null}
541 564
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
542 {preview && (576 {preview && (
543 <AuthMethodListEditor577 <AuthMethodListEditor
544 list={authMethodList}578 list={authMethodList}
packages/plugins/openapi/src/react/SpecOverridesEditor.tsx+50−0
@@ -0,0 +1,50 @@
1import { useState } from "react";
2import { ChevronDown } from "lucide-react";
3
4import { cn } from "@executor-js/react/lib/utils";
5import {
6 Collapsible,
7 CollapsibleContent,
8 CollapsibleTrigger,
9} from "@executor-js/react/components/collapsible";
10import { FieldLabel } from "@executor-js/react/components/field";
11import { Textarea } from "@executor-js/react/components/textarea";
12
13import { parseSpecOverridesText } from "../sdk/spec-overrides";
14
15export 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
13import { Textarea } from "@executor-js/react/components/textarea";13import { Textarea } from "@executor-js/react/components/textarea";
14 14
15import { openApiConfigAtom, updateOpenApiSpec } from "./atoms";15import { openApiConfigAtom, updateOpenApiSpec } from "./atoms";
16import { SpecOverridesEditor } from "./SpecOverridesEditor";
17import { formatSpecOverridesText, parseSpecOverridesText } from "../sdk/spec-overrides";
16 18
17// ---------------------------------------------------------------------------19// ---------------------------------------------------------------------------
18// Update spec — the openapi plugin's section of the integration Edit sheet.20// 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
47 const doUpdate = useAtomSet(updateOpenApiSpec, { mode: "promiseExit" });49 const doUpdate = useAtomSet(updateOpenApiSpec, { mode: "promiseExit" });
48 const [refetchStaged, setRefetchStaged] = useState(false);50 const [refetchStaged, setRefetchStaged] = useState(false);
49 const [pasted, setPasted] = useState("");51 const [pasted, setPasted] = useState("");
52 const [specOverridesDraft, setSpecOverridesDraft] = useState<string | null>(null);
50 const [error, setError] = useState<string | null>(null);53 const [error, setError] = useState<string | null>(null);
51 54
52 const config =55 const config =
53 AsyncResult.isSuccess(configResult) && configResult.value ? configResult.value : null;56 AsyncResult.isSuccess(configResult) && configResult.value ? configResult.value : null;
54 57
55 const specUrl = config?.specUrl;58 const specUrl = config?.specUrl;
59 const specOverridesText = specOverridesDraft ?? formatSpecOverridesText(config?.specOverrides);
56 60
57 // The staged apply, rebuilt whenever the staged inputs change. Reported to61 // The staged apply, rebuilt whenever the staged inputs change. Reported to
58 // the sheet through a ref-stable callback so Save can run it.62 // the sheet through a ref-stable callback so Save can run it.
59 const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => {63 const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => {
60 const spec = pasted.trim().length > 0 ? { kind: "blob" as const, value: pasted } : undefined;64 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 }
62 setError(null);73 setError(null);
63 const exit = await doUpdate({74 const exit = await doUpdate({
64 params: { slug },75 params: { slug },
65 payload: spec ? { spec } : {},76 payload: {
77 ...(spec ? { spec } : {}),
78 ...(specOverridesDraft !== null ? { specOverrides: parsedOverrides.value } : {}),
79 },
66 reactivityKeys: integrationWriteKeys,80 reactivityKeys: integrationWriteKeys,
67 });81 });
68 if (Exit.isFailure(exit)) {82 if (Exit.isFailure(exit)) {
@@ -71,12 +85,13 @@ export default function UpdateSpecSection(props: EditSheetSectionProps) { · 2 unmodified lines
71 }85 }
72 setRefetchStaged(false);86 setRefetchStaged(false);
73 setPasted("");87 setPasted("");
88 setSpecOverridesDraft(null);
74 return { ok: true, summary: outcomeSummary(exit.value) };89 return { ok: true, summary: outcomeSummary(exit.value) };
75 }, [doUpdate, pasted, refetchStaged, slug]);90 }, [doUpdate, pasted, refetchStaged, slug, specOverridesDraft, specOverridesText]);
76 91
77 const onPendingChangeRef = useRef(props.onPendingChange);92 const onPendingChangeRef = useRef(props.onPendingChange);
78 onPendingChangeRef.current = props.onPendingChange;93 onPendingChangeRef.current = props.onPendingChange;
79 const hasStagedChange = refetchStaged || pasted.trim().length > 0;94 const hasStagedChange = refetchStaged || pasted.trim().length > 0 || specOverridesDraft !== null;
80 useEffect(() => {95 useEffect(() => {
81 onPendingChangeRef.current?.(hasStagedChange ? applyStaged : null);96 onPendingChangeRef.current?.(hasStagedChange ? applyStaged : null);
82 // Clear the staged apply if this section unmounts mid-edit.97 // Clear the staged apply if this section unmounts mid-edit.
@@ -129,6 +144,8 @@ export default function UpdateSpecSection(props: EditSheetSectionProps) { · 46 unmodified lines
129 </div>144 </div>
130 )}145 )}
131 146
147 <SpecOverridesEditor value={specOverridesText} onChange={setSpecOverridesDraft} />
148
132 {error && <FormErrorAlert message={error} />}149 {error && <FormErrorAlert message={error} />}
133 </div>150 </div>
134 );151 );
packages/plugins/openapi/src/sdk/config.ts+8−1
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
7} from "@executor-js/sdk/http-auth";7} from "@executor-js/sdk/http-auth";
8 8
9import type { Authentication } from "./types";9import type { Authentication } from "./types";
10import { SpecOverridesSchema, type SpecOverrides } from "./spec-overrides";
10 11
11// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
12// OpenAPI integration config — the opaque blob stored on the catalog13// OpenAPI integration config — the opaque blob stored on the catalog
@@ -17,6 +18,7 @@ import type { Authentication } from "./types"; · 4 unmodified lines
17// StoredSource credential config. The config carries only:18// StoredSource credential config. The config carries only:
18// - the content hash of the spec blob and/or the source URL to (re)fetch19// - the content hash of the spec blob and/or the source URL to (re)fetch
19// from,20// from,
21// - optional RFC 6902 overrides and the raw source hash they apply to,
20// - the optional base URL override,22// - the optional base URL override,
21// - the auth templates a connection's value is rendered through.23// - the auth templates a connection's value is rendered through.
22// The resolved spec text itself lives in the plugin blob store, keyed24// The resolved spec text itself lives in the plugin blob store, keyed
@@ -43,6 +45,8 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ · 20 unmodified lines
43 /** Hex SHA-256 of the resolved spec text — the content address of the spec45 /** Hex SHA-256 of the resolved spec text — the content address of the spec
44 * blob (`spec/<hash>` in the plugin blob store). */46 * blob (`spec/<hash>` in the plugin blob store). */
45 specHash: Schema.optional(Schema.String),47 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),
46 /** Origin URL the spec was fetched from, when known. Enables refresh. */50 /** Origin URL the spec was fetched from, when known. Enables refresh. */
47 specUrl: Schema.optional(Schema.String),51 specUrl: Schema.optional(Schema.String),
48 /** Optional base URL override. */52 /** Optional base URL override. */
@@ -55,17 +59,20 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ · 6 unmodified lines
55 specFormat: Schema.optional(Schema.String),59 specFormat: Schema.optional(Schema.String),
56 /** Catalog family for grouped integration display. */60 /** Catalog family for grouped integration display. */
57 family: Schema.optional(Schema.String),61 family: Schema.optional(Schema.String),
62 /** Ordered RFC 6902 operations reapplied whenever the source spec is refreshed. */
63 specOverrides: Schema.optional(SpecOverridesSchema),
58 /** The auth methods a connection's value can be applied through. */64 /** The auth methods a connection's value can be applied through. */
59 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),65 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),
60});66});
61 67
62export type OpenApiIntegrationConfig = Omit<68export type OpenApiIntegrationConfig = Omit<
63 typeof OpenApiIntegrationConfigSchema.Type,69 typeof OpenApiIntegrationConfigSchema.Type,
64 "authenticationTemplate"70 "authenticationTemplate" | "specOverrides"
65> & {71> & {
66 /** Branded over the schema's structural form so the template renderer can72 /** Branded over the schema's structural form so the template renderer can
67 * treat `slug` as an `AuthTemplateSlug`. */73 * treat `slug` as an `AuthTemplateSlug`. */
68 readonly authenticationTemplate?: readonly Authentication[];74 readonly authenticationTemplate?: readonly Authentication[];
75 readonly specOverrides?: SpecOverrides;
69};76};
70 77
71const decodeConfig = Schema.decodeUnknownOption(OpenApiIntegrationConfigSchema);78const 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
25 { httpApiStatus: 400 },25 { httpApiStatus: 400 },
26) {}26) {}
27 27
28export 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
28export class OpenApiInvocationError extends Data.TaggedError("OpenApiInvocationError")<{39export class OpenApiInvocationError extends Data.TaggedError("OpenApiInvocationError")<{
29 readonly message: string;40 readonly message: string;
30 readonly statusCode: Option.Option<number>;41 readonly statusCode: Option.Option<number>;
packages/plugins/openapi/src/sdk/index.ts+8−0
@@ -1,4 +1,11 @@
1export { parse, resolveSpecText, fetchSpecText } from "./parse";1export { parse, resolveSpecText, fetchSpecText } from "./parse";
2export {
3 applySpecOverrides,
4 JsonPatchOperationSchema,
5 SpecOverridesSchema,
6 type JsonPatchOperation,
7 type SpecOverrides,
8} from "./spec-overrides";
2export { extract, streamOperationBindingsFromStructure } from "./extract";9export { extract, streamOperationBindingsFromStructure } from "./extract";
3export {10export {
4 structuralSplit,11 structuralSplit,
@@ -92,6 +99,7 @@ export { · 87 unmodified lines
92 OpenApiExtractionError,99 OpenApiExtractionError,
93 OpenApiInvocationError,100 OpenApiInvocationError,
94 OpenApiOAuthError,101 OpenApiOAuthError,
102 OpenApiSpecOverrideError,
95 OpenApiAuthRequiredError,103 OpenApiAuthRequiredError,
96} from "./errors";104} from "./errors";
97 105
packages/plugins/openapi/src/sdk/parse.ts+3−2
@@ -79,7 +79,7 @@ export const resolveSpecText = (input: string, credentials?: SpecFetchCredential · 78 unmodified lines
79 * the 128MB Cloudflare Workers memory cap.79 * the 128MB Cloudflare Workers memory cap.
80 */80 */
81export const parse = Effect.fn("OpenApi.parse")(function* (text: string) {81export const parse = Effect.fn("OpenApi.parse")(function* (text: string) {
82 const api = yield* parseTextToObject(text);82 const api = yield* parseSpecObject(text);
83 83
84 if (!isOpenApi3(api)) {84 if (!isOpenApi3(api)) {
85 return yield* new OpenApiExtractionErrorFromParse({85 return yield* new OpenApiExtractionErrorFromParse({
@@ -98,7 +98,8 @@ export const parse = Effect.fn("OpenApi.parse")(function* (text: string) { · 12 unmodified lines
98const isOpenApi3 = (doc: OpenAPI.Document): doc is OpenAPIV3.Document | OpenAPIV3_1.Document =>98const isOpenApi3 = (doc: OpenAPI.Document): doc is OpenAPIV3.Document | OpenAPIV3_1.Document =>
99 "openapi" in doc && typeof doc.openapi === "string" && doc.openapi.startsWith("3.");99 "openapi" in doc && typeof doc.openapi === "string" && doc.openapi.startsWith("3.");
100 100
101const parseTextToObject = (text: string): Effect.Effect<OpenAPI.Document, OpenApiParseError> =>101/** Parse JSON or YAML text into an object without applying OpenAPI version validation. */
102export const parseSpecObject = (text: string): Effect.Effect<OpenAPI.Document, OpenApiParseError> =>
102 Effect.gen(function* () {103 Effect.gen(function* () {
103 const trimmed = text.trim();104 const trimmed = text.trim();
104 if (trimmed.length === 0) {105 if (trimmed.length === 0) {
packages/plugins/openapi/src/sdk/plugin.ts+109−11
@@ -22,8 +22,13 @@ import { · 21 unmodified lines
22} from "@executor-js/sdk/core";22} from "@executor-js/sdk/core";
23 23
24import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config";24import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config";
25import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors";25import {
26import { parse, resolveSpecText } from "./parse";26 OpenApiExtractionError,
27 OpenApiOAuthError,
28 OpenApiParseError,
29 OpenApiSpecOverrideError,
30} from "./errors";
31import { parse, parseSpecObject, resolveSpecText } from "./parse";
27import { extract } from "./extract";32import { extract } from "./extract";
28import {33import {
29 OAuth2AuthorizationCodeFlow,34 OAuth2AuthorizationCodeFlow,
@@ -57,6 +62,9 @@ import { · 27 unmodified lines
57} from "./backing";62} from "./backing";
58import type { InvokeOptions } from "./invoke";63import type { InvokeOptions } from "./invoke";
59import { resolveServerUrl } from "./openapi-utils";64import { resolveServerUrl } from "./openapi-utils";
65import { applySpecOverrides, SpecOverridesSchema, type SpecOverrides } from "./spec-overrides";
66
67const encodeJsonText = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);
60 68
61// ---------------------------------------------------------------------------69// ---------------------------------------------------------------------------
62// Extension input shapes70// Extension input shapes
@@ -67,6 +75,7 @@ export type OpenApiSpecInput = typeof OpenApiSpecInputSchema.Type; · 4 unmodified lines
67export interface OpenApiPreviewInput {75export interface OpenApiPreviewInput {
68 readonly spec: string;76 readonly spec: string;
69 readonly specFormat?: string;77 readonly specFormat?: string;
78 readonly specOverrides?: SpecOverrides;
70}79}
71 80
72/** Add an OpenAPI integration to the catalog. The integration is the API81/** Add an OpenAPI integration to the catalog. The integration is the API
@@ -87,6 +96,8 @@ export interface OpenApiSpecConfig { · 14 unmodified lines
87 /** Static query params applied to every request. */96 /** Static query params applied to every request. */
88 readonly queryParams?: Record<string, string>;97 readonly queryParams?: Record<string, string>;
89 readonly specFormat?: string;98 readonly specFormat?: string;
99 /** Ordered RFC 6902 operations applied after fetching/conversion and before parsing. */
100 readonly specOverrides?: SpecOverrides;
90 readonly family?: string;101 readonly family?: string;
91 readonly healthCheck?: HealthCheckSpec;102 readonly healthCheck?: HealthCheckSpec;
92 /** Auth methods a connection's value renders through - canonical103 /** Auth methods a connection's value renders through - canonical
@@ -124,6 +135,8 @@ export interface OpenApiUpdateSpecInput { · 31 unmodified lines
124 /** New spec source. Omit to re-fetch from the integration's stored135 /** New spec source. Omit to re-fetch from the integration's stored
125 * `specUrl`. */136 * `specUrl`. */
126 readonly spec?: OpenApiSpecInput;137 readonly spec?: OpenApiSpecInput;
138 /** Replacement override list. Omit to keep existing overrides; pass [] to clear them. */
139 readonly specOverrides?: SpecOverrides;
127}140}
128 141
129export interface OpenApiPluginExtension {142export interface OpenApiPluginExtension {
@@ -131,7 +144,11 @@ export interface OpenApiPluginExtension { · 1 unmodified line
131 input: string | OpenApiPreviewInput,144 input: string | OpenApiPreviewInput,
132 ) => Effect.Effect<145 ) => Effect.Effect<
133 SpecPreview,146 SpecPreview,
134 OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError | StorageFailure147 | OpenApiParseError
148 | OpenApiExtractionError
149 | OpenApiOAuthError
150 | OpenApiSpecOverrideError
151 | StorageFailure
135 >;152 >;
136 readonly addSpec: (153 readonly addSpec: (
137 config: OpenApiSpecConfig,154 config: OpenApiSpecConfig,
@@ -140,6 +157,7 @@ export interface OpenApiPluginExtension { · 2 unmodified lines
140 | OpenApiParseError157 | OpenApiParseError
141 | OpenApiExtractionError158 | OpenApiExtractionError
142 | OpenApiOAuthError159 | OpenApiOAuthError
160 | OpenApiSpecOverrideError
143 | IntegrationAlreadyExistsError161 | IntegrationAlreadyExistsError
144 | StorageFailure162 | StorageFailure
145 >;163 >;
@@ -154,6 +172,7 @@ export interface OpenApiPluginExtension { · 8 unmodified lines
154 | OpenApiParseError172 | OpenApiParseError
155 | OpenApiExtractionError173 | OpenApiExtractionError
156 | OpenApiOAuthError174 | OpenApiOAuthError
175 | OpenApiSpecOverrideError
157 | IntegrationNotFoundError176 | IntegrationNotFoundError
158 | StorageFailure177 | StorageFailure
159 >;178 >;
@@ -179,6 +198,7 @@ export interface OpenApiPluginExtension { · 19 unmodified lines
179const PreviewSpecInputSchema = Schema.Struct({198const PreviewSpecInputSchema = Schema.Struct({
180 spec: Schema.String,199 spec: Schema.String,
181 specFormat: Schema.optional(Schema.String),200 specFormat: Schema.optional(Schema.String),
201 specOverrides: Schema.optional(SpecOverridesSchema),
182});202});
183 203
184const StaticPreviewServerVariableSchema = Schema.Struct({204const StaticPreviewServerVariableSchema = Schema.Struct({
@@ -282,6 +302,7 @@ const AddIntegrationInputSchema = Schema.Struct({ · 97 unmodified lines
282 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),302 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
283 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),303 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
284 specFormat: Schema.optional(Schema.String),304 specFormat: Schema.optional(Schema.String),
305 specOverrides: Schema.optional(SpecOverridesSchema),
285 family: Schema.optional(Schema.String),306 family: Schema.optional(Schema.String),
286 healthCheck: Schema.optional(HealthCheckSpec),307 healthCheck: Schema.optional(HealthCheckSpec),
287 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),308 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),
@@ -596,10 +617,36 @@ export const openApiPlugin = definePlugin< · 308 unmodified lines
596 OpenapiStore,617 OpenapiStore,
597 OpenApiPluginOptions618 OpenApiPluginOptions
598>((options?: OpenApiPluginOptions) => {619>((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
599 const resolveSpecForInput = (640 const resolveSpecForInput = (
600 config: Pick<OpenApiSpecConfig, "spec" | "specFormat" | "headers" | "queryParams" | "baseUrl">,641 config: Pick<
642 OpenApiSpecConfig,
643 "spec" | "specFormat" | "specOverrides" | "headers" | "queryParams" | "baseUrl"
644 >,
601 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,645 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 > =>
603 Effect.gen(function* () {650 Effect.gen(function* () {
604 const adapter = yield* resolveSpecFormatAdapter(651 const adapter = yield* resolveSpecFormatAdapter(
605 options?.specFormats ?? [],652 options?.specFormats ?? [],
@@ -611,7 +658,7 @@ export const openApiPlugin = definePlugin< · 5 unmodified lines
611 message: "Spec format adapters require a URL spec input",658 message: "Spec format adapters require a URL spec input",
612 });659 });
613 }660 }
614 return yield* adapter.fetch({661 const resolved = yield* adapter.fetch({
615 urls: [config.spec.url],662 urls: [config.spec.url],
616 credentials: {663 credentials: {
617 ...(config.headers ? { headers: config.headers } : {}),664 ...(config.headers ? { headers: config.headers } : {}),
@@ -619,14 +666,21 @@ export const openApiPlugin = definePlugin< · 1 unmodified line
619 },666 },
620 httpClientLayer,667 httpClientLayer,
621 });668 });
669 return yield* applyOverridesToResolvedSpec(resolved, config.specOverrides);
622 }670 }
623 if (config.spec.kind === "url") {671 if (config.spec.kind === "url") {
624 const specText = yield* resolveSpecText(config.spec.url).pipe(672 const specText = yield* resolveSpecText(config.spec.url).pipe(
625 Effect.provide(httpClientLayer),673 Effect.provide(httpClientLayer),
626 );674 );
627 return { specText, specUrl: config.spec.url };675 return yield* applyOverridesToResolvedSpec(
676 { specText, specUrl: config.spec.url },
677 config.specOverrides,
678 );
628 }679 }
629 return { specText: config.spec.value };680 return yield* applyOverridesToResolvedSpec(
681 { specText: config.spec.value },
682 config.specOverrides,
683 );
630 });684 });
631 685
632 return {686 return {
@@ -768,6 +822,10 @@ export const openApiPlugin = definePlugin< · 135 unmodified lines
768 }822 }
769 823
770 const specHash = yield* sha256Hex(resolved.specText);824 const specHash = yield* sha256Hex(resolved.specText);
825 const sourceSpecHash =
826 config.specOverrides && config.specOverrides.length > 0
827 ? yield* sha256Hex(resolved.sourceSpecText)
828 : undefined;
771 829
772 const integrationConfig: OpenApiIntegrationConfig = {830 const integrationConfig: OpenApiIntegrationConfig = {
773 ...(resolved.config ?? {}),831 ...(resolved.config ?? {}),
@@ -783,6 +841,9 @@ export const openApiPlugin = definePlugin< · 9 unmodified lines
783 ...(config.queryParams ? { queryParams: config.queryParams } : {}),841 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
784 ...(config.specFormat ? { specFormat: config.specFormat } : {}),842 ...(config.specFormat ? { specFormat: config.specFormat } : {}),
785 ...(config.family ? { family: config.family } : {}),843 ...(config.family ? { family: config.family } : {}),
844 ...(config.specOverrides && config.specOverrides.length > 0
845 ? { specOverrides: config.specOverrides, sourceSpecHash }
846 : {}),
786 // Prefer the caller's explicit template; otherwise derive from the847 // Prefer the caller's explicit template; otherwise derive from the
787 // spec's declared security schemes.848 // spec's declared security schemes.
788 ...(config.authenticationTemplate849 ...(config.authenticationTemplate
@@ -801,6 +862,9 @@ export const openApiPlugin = definePlugin< · 12 unmodified lines
801 // leaves only an unreferenced blob behind - while blob backends like862 // leaves only an unreferenced blob behind - while blob backends like
802 // R2 couldn't roll back with the transaction anyway.863 // R2 couldn't roll back with the transaction anyway.
803 yield* ctx.storage.putSpec(specHash, resolved.specText);864 yield* ctx.storage.putSpec(specHash, resolved.specText);
865 if (sourceSpecHash) {
866 yield* ctx.storage.putSpec(sourceSpecHash, resolved.sourceSpecText);
867 }
804 // The content-addressed defs blob lets the serve path resolve the868 // The content-addressed defs blob lets the serve path resolve the
805 // shared `definitions` without re-parsing the spec. Same idempotent,869 // shared `definitions` without re-parsing the spec. Same idempotent,
806 // outside-the-transaction rationale as the spec blob.870 // outside-the-transaction rationale as the spec blob.
@@ -870,8 +934,21 @@ export const openApiPlugin = definePlugin< · 63 unmodified lines
870 // The new spec source: explicit input wins; otherwise re-fetch from934 // The new spec source: explicit input wins; otherwise re-fetch from
871 // where the spec originally came from. A pasted-blob integration has935 // where the spec originally came from. A pasted-blob integration has
872 // no origin, so updating it requires a new input.936 // 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;
873 const specInput: OpenApiSpecInput | null =945 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);
875 if (specInput === null) {952 if (specInput === null) {
876 return yield* new OpenApiParseError({953 return yield* new OpenApiParseError({
877 message:954 message:
@@ -885,6 +962,7 @@ export const openApiPlugin = definePlugin< · 7 unmodified lines
885 {962 {
886 spec: specInput,963 spec: specInput,
887 specFormat: current.specFormat,964 specFormat: current.specFormat,
965 specOverrides: nextOverrides,
888 headers: current.headers,966 headers: current.headers,
889 queryParams: current.queryParams,967 queryParams: current.queryParams,
890 baseUrl: current.baseUrl,968 baseUrl: current.baseUrl,
@@ -903,17 +981,28 @@ export const openApiPlugin = definePlugin< · 12 unmodified lines
903 // the blob outside the transaction - re-puts are idempotent and an981 // the blob outside the transaction - re-puts are idempotent and an
904 // aborted config update just leaves an unreferenced blob.982 // aborted config update just leaves an unreferenced blob.
905 const specHash = yield* sha256Hex(resolved.specText);983 const specHash = yield* sha256Hex(resolved.specText);
984 const sourceSpecHash =
985 nextOverrides.length > 0 ? yield* sha256Hex(resolved.sourceSpecText) : undefined;
906 yield* ctx.storage.putSpec(specHash, resolved.specText);986 yield* ctx.storage.putSpec(specHash, resolved.specText);
987 if (sourceSpecHash) {
988 yield* ctx.storage.putSpec(sourceSpecHash, resolved.sourceSpecText);
989 }
907 if (compiled) {990 if (compiled) {
908 yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs));991 yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs));
909 }992 }
910 993
994 const {
995 sourceSpecHash: _currentSourceSpecHash,
996 specOverrides: _currentSpecOverrides,
997 ...currentWithoutOverrides
998 } = current;
911 const nextConfig: OpenApiIntegrationConfig = {999 const nextConfig: OpenApiIntegrationConfig = {
912 ...current,1000 ...currentWithoutOverrides,
913 specHash,1001 specHash,
914 ...((resolved.specUrl ?? specInputToSpecUrl(specInput)) !== undefined1002 ...((resolved.specUrl ?? specInputToSpecUrl(specInput)) !== undefined
915 ? { specUrl: resolved.specUrl ?? specInputToSpecUrl(specInput) }1003 ? { specUrl: resolved.specUrl ?? specInputToSpecUrl(specInput) }
916 : {}),1004 : {}),
1005 ...(nextOverrides.length > 0 ? { specOverrides: nextOverrides, sourceSpecHash } : {}),
917 };1006 };
918 1007
919 yield* ctx.transaction(1008 yield* ctx.transaction(
@@ -985,7 +1074,11 @@ export const openApiPlugin = definePlugin< · 65 unmodified lines
985 ? { kind: "url" as const, url: previewInput.spec.trim() }1074 ? { kind: "url" as const, url: previewInput.spec.trim() }
986 : { kind: "blob" as const, value: previewInput.spec };1075 : { kind: "blob" as const, value: previewInput.spec };
987 const resolved = yield* resolveSpecForInput(1076 const resolved = yield* resolveSpecForInput(
988 { spec, specFormat: previewInput.specFormat },1077 {
1078 spec,
1079 specFormat: previewInput.specFormat,
1080 specOverrides: previewInput.specOverrides,
1081 },
989 httpClientLayer,1082 httpClientLayer,
990 );1083 );
991 const preview = yield* previewSpecText(resolved.specText);1084 const preview = yield* previewSpecText(resolved.specText);
@@ -1093,6 +1186,8 @@ export const openApiPlugin = definePlugin< · 101 unmodified lines
1093 Effect.succeed(openApiToolFailure("openapi_extraction_failed", message)),1186 Effect.succeed(openApiToolFailure("openapi_extraction_failed", message)),
1094 OpenApiOAuthError: ({ message }: OpenApiOAuthError) =>1187 OpenApiOAuthError: ({ message }: OpenApiOAuthError) =>
1095 Effect.succeed(openApiToolFailure("openapi_oauth_failed", message)),1188 Effect.succeed(openApiToolFailure("openapi_oauth_failed", message)),
1189 OpenApiSpecOverrideError: ({ message }) =>
1190 Effect.succeed(openApiToolFailure("openapi_spec_override_failed", message)),
1096 }),1191 }),
1097 ),1192 ),
1098 }),1193 }),
@@ -1117,6 +1212,7 @@ export const openApiPlugin = definePlugin< · 18 unmodified lines
1117 headers: input.headers,1212 headers: input.headers,
1118 queryParams: input.queryParams,1213 queryParams: input.queryParams,
1119 specFormat: input.specFormat,1214 specFormat: input.specFormat,
1215 specOverrides: input.specOverrides,
1120 family: input.family,1216 family: input.family,
1121 healthCheck: input.healthCheck,1217 healthCheck: input.healthCheck,
1122 authenticationTemplate: input.authenticationTemplate as1218 authenticationTemplate: input.authenticationTemplate as
@@ -1137,6 +1233,8 @@ export const openApiPlugin = definePlugin< · 14 unmodified lines
1137 Effect.succeed(openApiToolFailure("openapi_extraction_failed", message)),1233 Effect.succeed(openApiToolFailure("openapi_extraction_failed", message)),
1138 OpenApiOAuthError: ({ message }: OpenApiOAuthError) =>1234 OpenApiOAuthError: ({ message }: OpenApiOAuthError) =>
1139 Effect.succeed(openApiToolFailure("openapi_oauth_failed", message)),1235 Effect.succeed(openApiToolFailure("openapi_oauth_failed", message)),
1236 OpenApiSpecOverrideError: ({ message }) =>
1237 Effect.succeed(openApiToolFailure("openapi_spec_override_failed", message)),
1140 IntegrationAlreadyExistsError: ({ slug }: IntegrationAlreadyExistsError) =>1238 IntegrationAlreadyExistsError: ({ slug }: IntegrationAlreadyExistsError) =>
1141 Effect.succeed(1239 Effect.succeed(
1142 openApiToolFailure(1240 openApiToolFailure(
packages/plugins/openapi/src/sdk/spec-overrides-lifecycle.test.ts+164−0
@@ -0,0 +1,164 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Option, Schema } from "effect";
3import { FetchHttpClient } from "effect/unstable/http";
4import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
5
6import { createExecutor, sha256Hex } from "@executor-js/sdk";
7import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
8
9import { openApiPlugin } from "./plugin";
10import { applySpecOverrides, type SpecOverrides } from "./spec-overrides";
11import { serveMutableOpenApiSpecTestServer } from "../testing";
12
13const testPlugins = () =>
14 [openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), memoryCredentialsPlugin()] as const;
15
16const 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
49const 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
57const encodeJsonText = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);
58
59describe("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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3
4import {
5 applySpecOverrides,
6 formatSpecOverridesText,
7 parseSpecOverridesText,
8 type SpecOverrides,
9} from "./spec-overrides";
10
11const apply = (document: unknown, overrides: SpecOverrides) =>
12 Effect.runPromise(applySpecOverrides(document, overrides));
13
14describe("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 @@
1import { Data, Effect, Exit, Match, Schema } from "effect";
2
3import { OpenApiSpecOverrideError } from "./errors";
4
5export 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
37export const SpecOverridesSchema = Schema.Array(JsonPatchOperationSchema);
38
39export type JsonPatchOperation = typeof JsonPatchOperationSchema.Type;
40export type SpecOverrides = typeof SpecOverridesSchema.Type;
41
42const decodeSpecOverridesText = Schema.decodeUnknownExit(
43 Schema.fromJsonString(SpecOverridesSchema),
44);
45
46export 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. */
51export 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. */
64export const formatSpecOverridesText = (overrides: SpecOverrides | undefined): string =>
65 overrides && overrides.length > 0 ? JSON.stringify(overrides, null, 2) : "";
66
67type JsonPrimitive = string | number | boolean | null;
68type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
69interface JsonObject {
70 [key: string]: JsonValue;
71}
72
73class PatchApplicationFailure extends Data.TaggedError("PatchApplicationFailure")<{
74 readonly detail: string;
75}> {}
76
77const patchFailure = (detail: string) => new PatchApplicationFailure({ detail });
78
79const 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
90const 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
98const 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
108const 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
123const 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
148const 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
174const 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
193const 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
212const 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
233const 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
253const 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
274const 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
283const 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. */
329export 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});