Unified · Split
| @@ -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. | ||
| @@ -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 | ||
| 12 | import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors"; | 12 | import { |
| 13 | OpenApiParseError, | ||
| 14 | OpenApiExtractionError, | ||
| 15 | OpenApiOAuthError, | ||
| 16 | OpenApiSpecOverrideError, | ||
| 17 | } from "../sdk/errors"; | ||
| 13 | import { SpecPreviewSummary } from "../sdk/preview"; | 18 | import { SpecPreviewSummary } from "../sdk/preview"; |
| 19 | import { SpecOverridesSchema } from "../sdk/spec-overrides"; | ||
| 14 | 20 | ||
| 15 | // --------------------------------------------------------------------------- | 21 | // --------------------------------------------------------------------------- |
| 16 | // Errors — the plugin-domain tagged errors flow directly to clients | 22 | // 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 | ||
| 84 | const PreviewSpecPayload = Schema.Struct({ | 93 | const 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 the | 99 | // 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". |
| 110 | const UpdateSpecPayload = Schema.Struct({ | 120 | const UpdateSpecPayload = Schema.Struct({ |
| 111 | spec: Schema.optional(OpenApiSpecInputPayload), | 121 | spec: Schema.optional(OpenApiSpecInputPayload), |
| 122 | specOverrides: Schema.optional(SpecOverridesSchema), | ||
| 112 | }); | 123 | }); |
| 113 | 124 | ||
| 114 | const UpdateSpecResponse = Schema.Struct({ | 125 | const 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 | ||
| @@ -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.authenticationTemplate | 106 | 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, |
| @@ -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"; |
| 9 | export type { JsonPatchOperation, SpecOverrides } from "./sdk/spec-overrides"; | ||
| 9 | 10 | ||
| 10 | // Auth-template authoring helpers. Author apikey methods as canonical | 11 | // 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 ", |
| @@ -49,6 +49,8 @@ import type { SpecPreviewSummary } from "../sdk/preview"; · 48 unmodified lines | |||
| 49 | import { type Authentication } from "../sdk/types"; | 49 | import { type Authentication } from "../sdk/types"; |
| 50 | import { resolveServerUrl } from "../sdk/openapi-utils"; | 50 | import { resolveServerUrl } from "../sdk/openapi-utils"; |
| 51 | import { detectedAuthenticationTemplates } from "../sdk/derive-auth"; | 51 | import { detectedAuthenticationTemplates } from "../sdk/derive-auth"; |
| 52 | import { parseSpecOverridesText } from "../sdk/spec-overrides"; | ||
| 53 | import { SpecOverridesEditor } from "./SpecOverridesEditor"; | ||
| 52 | 54 | ||
| 53 | const normalizePresetUrl = (url: string): string => { | 55 | const 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 the | 245 | // 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 has | 400 | // Always send the edited method list (even empty) when the user has |
| 379 | // inspected a preview: an explicit [] means "no auth methods", while | 401 | // inspected a preview: an explicit [] means "no auth methods", while |
| 380 | // OMITTING the field tells the server to derive defaults from the | 402 | // 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 | <AuthMethodListEditor | 577 | <AuthMethodListEditor |
| 544 | list={authMethodList} | 578 | list={authMethodList} |
| @@ -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 | } | ||
| @@ -13,6 +13,8 @@ import { Label } from "@executor-js/react/components/label"; · 12 unmodified lines | |||
| 13 | import { Textarea } from "@executor-js/react/components/textarea"; | 13 | import { Textarea } from "@executor-js/react/components/textarea"; |
| 14 | 14 | ||
| 15 | import { openApiConfigAtom, updateOpenApiSpec } from "./atoms"; | 15 | import { openApiConfigAtom, updateOpenApiSpec } from "./atoms"; |
| 16 | import { SpecOverridesEditor } from "./SpecOverridesEditor"; | ||
| 17 | import { 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 to | 61 | // 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 | ); |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | } from "@executor-js/sdk/http-auth"; | 7 | } from "@executor-js/sdk/http-auth"; |
| 8 | 8 | ||
| 9 | import type { Authentication } from "./types"; | 9 | import type { Authentication } from "./types"; |
| 10 | import { SpecOverridesSchema, type SpecOverrides } from "./spec-overrides"; | ||
| 10 | 11 | ||
| 11 | // --------------------------------------------------------------------------- | 12 | // --------------------------------------------------------------------------- |
| 12 | // OpenAPI integration config — the opaque blob stored on the catalog | 13 | // 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)fetch | 19 | // - 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, keyed | 24 | // 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 spec | 45 | /** 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 | ||
| 62 | export type OpenApiIntegrationConfig = Omit< | 68 | export 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 can | 72 | /** 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 | ||
| 71 | const decodeConfig = Schema.decodeUnknownOption(OpenApiIntegrationConfigSchema); | 78 | const decodeConfig = Schema.decodeUnknownOption(OpenApiIntegrationConfigSchema); |
| @@ -25,6 +25,17 @@ export class OpenApiExtractionError extends Schema.TaggedErrorClass<OpenApiExtra · 24 unmodified lines | |||
| 25 | { httpApiStatus: 400 }, | 25 | { httpApiStatus: 400 }, |
| 26 | ) {} | 26 | ) {} |
| 27 | 27 | ||
| 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 | |||
| 28 | export class OpenApiInvocationError extends Data.TaggedError("OpenApiInvocationError")<{ | 39 | export 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>; |
| @@ -1,4 +1,11 @@ | |||
| 1 | export { parse, resolveSpecText, fetchSpecText } from "./parse"; | 1 | export { parse, resolveSpecText, fetchSpecText } from "./parse"; |
| 2 | export { | ||
| 3 | applySpecOverrides, | ||
| 4 | JsonPatchOperationSchema, | ||
| 5 | SpecOverridesSchema, | ||
| 6 | type JsonPatchOperation, | ||
| 7 | type SpecOverrides, | ||
| 8 | } from "./spec-overrides"; | ||
| 2 | export { extract, streamOperationBindingsFromStructure } from "./extract"; | 9 | export { extract, streamOperationBindingsFromStructure } from "./extract"; |
| 3 | export { | 10 | export { |
| 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 | ||
| @@ -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 | */ |
| 81 | export const parse = Effect.fn("OpenApi.parse")(function* (text: string) { | 81 | export 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 | |||
| 98 | const isOpenApi3 = (doc: OpenAPI.Document): doc is OpenAPIV3.Document | OpenAPIV3_1.Document => | 98 | const 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 | ||
| 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> => | ||
| 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) { |
| @@ -22,8 +22,13 @@ import { · 21 unmodified lines | |||
| 22 | } from "@executor-js/sdk/core"; | 22 | } from "@executor-js/sdk/core"; |
| 23 | 23 | ||
| 24 | import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config"; | 24 | import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config"; |
| 25 | import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors"; | 25 | import { |
| 26 | import { parse, resolveSpecText } from "./parse"; | 26 | OpenApiExtractionError, |
| 27 | OpenApiOAuthError, | ||
| 28 | OpenApiParseError, | ||
| 29 | OpenApiSpecOverrideError, | ||
| 30 | } from "./errors"; | ||
| 31 | import { parse, parseSpecObject, resolveSpecText } from "./parse"; | ||
| 27 | import { extract } from "./extract"; | 32 | import { extract } from "./extract"; |
| 28 | import { | 33 | import { |
| 29 | OAuth2AuthorizationCodeFlow, | 34 | OAuth2AuthorizationCodeFlow, |
| @@ -57,6 +62,9 @@ import { · 27 unmodified lines | |||
| 57 | } from "./backing"; | 62 | } from "./backing"; |
| 58 | import type { InvokeOptions } from "./invoke"; | 63 | import type { InvokeOptions } from "./invoke"; |
| 59 | import { resolveServerUrl } from "./openapi-utils"; | 64 | import { resolveServerUrl } from "./openapi-utils"; |
| 65 | import { applySpecOverrides, SpecOverridesSchema, type SpecOverrides } from "./spec-overrides"; | ||
| 66 | |||
| 67 | const encodeJsonText = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); | ||
| 60 | 68 | ||
| 61 | // --------------------------------------------------------------------------- | 69 | // --------------------------------------------------------------------------- |
| 62 | // Extension input shapes | 70 | // Extension input shapes |
| @@ -67,6 +75,7 @@ export type OpenApiSpecInput = typeof OpenApiSpecInputSchema.Type; · 4 unmodified lines | |||
| 67 | export interface OpenApiPreviewInput { | 75 | export 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 API | 81 | /** 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 - canonical | 103 | /** 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 stored | 135 | /** 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 | ||
| 129 | export interface OpenApiPluginExtension { | 142 | export 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 | StorageFailure | 147 | | 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 | | OpenApiParseError | 157 | | OpenApiParseError |
| 141 | | OpenApiExtractionError | 158 | | OpenApiExtractionError |
| 142 | | OpenApiOAuthError | 159 | | OpenApiOAuthError |
| 160 | | OpenApiSpecOverrideError | ||
| 143 | | IntegrationAlreadyExistsError | 161 | | IntegrationAlreadyExistsError |
| 144 | | StorageFailure | 162 | | StorageFailure |
| 145 | >; | 163 | >; |
| @@ -154,6 +172,7 @@ export interface OpenApiPluginExtension { · 8 unmodified lines | |||
| 154 | | OpenApiParseError | 172 | | OpenApiParseError |
| 155 | | OpenApiExtractionError | 173 | | OpenApiExtractionError |
| 156 | | OpenApiOAuthError | 174 | | OpenApiOAuthError |
| 175 | | OpenApiSpecOverrideError | ||
| 157 | | IntegrationNotFoundError | 176 | | IntegrationNotFoundError |
| 158 | | StorageFailure | 177 | | StorageFailure |
| 159 | >; | 178 | >; |
| @@ -179,6 +198,7 @@ export interface OpenApiPluginExtension { · 19 unmodified lines | |||
| 179 | const PreviewSpecInputSchema = Schema.Struct({ | 198 | const 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 | ||
| 184 | const StaticPreviewServerVariableSchema = Schema.Struct({ | 204 | const 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 | OpenApiPluginOptions | 618 | 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 the | 847 | // Prefer the caller's explicit template; otherwise derive from the |
| 787 | // spec's declared security schemes. | 848 | // spec's declared security schemes. |
| 788 | ...(config.authenticationTemplate | 849 | ...(config.authenticationTemplate |
| @@ -801,6 +862,9 @@ export const openApiPlugin = definePlugin< · 12 unmodified lines | |||
| 801 | // leaves only an unreferenced blob behind - while blob backends like | 862 | // 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 the | 868 | // 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 from | 934 | // The new spec source: explicit input wins; otherwise re-fetch from |
| 871 | // where the spec originally came from. A pasted-blob integration has | 935 | // 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 an | 981 | // 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)) !== undefined | 1002 | ...((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 as | 1218 | 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( |
| @@ -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 | }); | ||
| @@ -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 | }); | ||
| @@ -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 | }); | ||