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 | 9 | IntegrationSlug, |
| 10 | 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 | 18 | import { SpecPreviewSummary } from "../sdk/preview"; |
| 19 | +import { SpecOverridesSchema } from "../sdk/spec-overrides"; | |
| 14 | 20 | |
| 15 | 21 | // --------------------------------------------------------------------------- |
| 16 | 22 | // Errors — the plugin-domain tagged errors flow directly to clients |
| @@ -25,6 +31,7 @@ const DomainErrors = [ · 8 unmodified lines | ||
| 25 | 31 | OpenApiParseError, |
| 26 | 32 | OpenApiExtractionError, |
| 27 | 33 | OpenApiOAuthError, |
| 34 | + OpenApiSpecOverrideError, | |
| 28 | 35 | IntegrationAlreadyExistsError, |
| 29 | 36 | ] as const; |
| 30 | 37 | |
| @@ -35,6 +42,7 @@ const UpdateSpecErrors = [ · 4 unmodified lines | ||
| 35 | 42 | OpenApiParseError, |
| 36 | 43 | OpenApiExtractionError, |
| 37 | 44 | OpenApiOAuthError, |
| 45 | + OpenApiSpecOverrideError, | |
| 38 | 46 | IntegrationNotFound, |
| 39 | 47 | ] as const; |
| 40 | 48 | |
| @@ -79,11 +87,13 @@ const AddSpecPayload = Schema.Struct({ · 38 unmodified lines | ||
| 79 | 87 | family: Schema.optional(Schema.String), |
| 80 | 88 | healthCheck: Schema.optional(HealthCheckSpec), |
| 81 | 89 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)), |
| 90 | + specOverrides: Schema.optional(SpecOverridesSchema), | |
| 82 | 91 | }); |
| 83 | 92 | |
| 84 | 93 | const PreviewSpecPayload = Schema.Struct({ |
| 85 | 94 | spec: Schema.String, |
| 86 | 95 | specFormat: Schema.optional(Schema.String), |
| 96 | + specOverrides: Schema.optional(SpecOverridesSchema), | |
| 87 | 97 | }); |
| 88 | 98 | |
| 89 | 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 | 119 | // "re-fetch from the stored source URL". |
| 110 | 120 | const UpdateSpecPayload = Schema.Struct({ |
| 111 | 121 | spec: Schema.optional(OpenApiSpecInputPayload), |
| 122 | + specOverrides: Schema.optional(SpecOverridesSchema), | |
| 112 | 123 | }); |
| 113 | 124 | |
| 114 | 125 | const UpdateSpecResponse = Schema.Struct({ |
| @@ -138,6 +149,7 @@ const OpenApiConfigView = Schema.Struct({ · 23 unmodified lines | ||
| 138 | 149 | baseUrl: Schema.optional(Schema.String), |
| 139 | 150 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 140 | 151 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 152 | + specOverrides: Schema.optional(SpecOverridesSchema), | |
| 141 | 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 | 47 | const preview = yield* ext.previewSpec({ |
| 48 | 48 | spec: payload.spec, |
| 49 | 49 | specFormat: payload.specFormat, |
| 50 | + specOverrides: payload.specOverrides, | |
| 50 | 51 | }); |
| 51 | 52 | return specPreviewSummary(preview); |
| 52 | 53 | }), |
| @@ -68,6 +69,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 15 unmodified lines | ||
| 68 | 69 | family: payload.family, |
| 69 | 70 | healthCheck: payload.healthCheck, |
| 70 | 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 | 102 | baseUrl: config.baseUrl, |
| 101 | 103 | headers: config.headers ? { ...config.headers } : undefined, |
| 102 | 104 | queryParams: config.queryParams ? { ...config.queryParams } : undefined, |
| 105 | + specOverrides: config.specOverrides ? [...config.specOverrides] : undefined, | |
| 103 | 106 | authenticationTemplate: config.authenticationTemplate |
| 104 | 107 | ? [...config.authenticationTemplate] |
| 105 | 108 | : undefined, |
| @@ -127,6 +130,9 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 21 unmodified lines | ||
| 127 | 130 | const ext = yield* OpenApiExtensionService; |
| 128 | 131 | const result = yield* ext.updateSpec(params.slug, { |
| 129 | 132 | ...(payload.spec !== undefined ? { spec: payload.spec } : {}), |
| 133 | + ...(payload.specOverrides !== undefined | |
| 134 | + ? { specOverrides: payload.specOverrides } | |
| 135 | + : {}), | |
| 130 | 136 | }); |
| 131 | 137 | return { |
| 132 | 138 | slug: result.slug, |
| @@ -6,6 +6,7 @@ export type { · 5 unmodified lines | ||
| 6 | 6 | OpenApiSpecInput, |
| 7 | 7 | OpenApiPreviewInput, |
| 8 | 8 | } from "./sdk/plugin"; |
| 9 | +export type { JsonPatchOperation, SpecOverrides } from "./sdk/spec-overrides"; | |
| 9 | 10 | |
| 10 | 11 | // Auth-template authoring helpers. Author apikey methods as canonical |
| 11 | 12 | // placements, or request-shaped: `headers: { Authorization: ["Bearer ", |
| @@ -49,6 +49,8 @@ import type { SpecPreviewSummary } from "../sdk/preview"; · 48 unmodified lines | ||
| 49 | 49 | import { type Authentication } from "../sdk/types"; |
| 50 | 50 | import { resolveServerUrl } from "../sdk/openapi-utils"; |
| 51 | 51 | import { detectedAuthenticationTemplates } from "../sdk/derive-auth"; |
| 52 | +import { parseSpecOverridesText } from "../sdk/spec-overrides"; | |
| 53 | +import { SpecOverridesEditor } from "./SpecOverridesEditor"; | |
| 52 | 54 | |
| 53 | 55 | const normalizePresetUrl = (url: string): string => { |
| 54 | 56 | const trimmed = url.trim(); |
| @@ -154,6 +156,7 @@ export default function AddOpenApiIntegration(props: { · 99 unmodified lines | ||
| 154 | 156 | const openApiPresets = openApiPlugin?.presets; |
| 155 | 157 | const initialPreset = openApiPresets?.find((preset) => preset.id === props.initialPreset) ?? null; |
| 156 | 158 | const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); |
| 159 | + const [specOverridesText, setSpecOverridesText] = useState(""); | |
| 157 | 160 | const [analyzing, setAnalyzing] = useState(false); |
| 158 | 161 | const [analyzeError, setAnalyzeError] = useState<string | null>(null); |
| 159 | 162 | |
| @@ -233,6 +236,10 @@ export default function AddOpenApiIntegration(props: { · 73 unmodified lines | ||
| 233 | 236 | : resolvedIntegrationId); |
| 234 | 237 | const resolvedDescription = |
| 235 | 238 | descriptionDraft ?? (preview ? Option.getOrElse(preview.description, () => "") : ""); |
| 239 | + const parsedSpecOverrides = useMemo( | |
| 240 | + () => parseSpecOverridesText(specOverridesText), | |
| 241 | + [specOverridesText], | |
| 242 | + ); | |
| 236 | 243 | |
| 237 | 244 | // Register EVERY spec-detected auth method, not just a single selected one. |
| 238 | 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 | 340 | // args filled (an empty draft, `hcOperation === ""`, imposes no constraint). |
| 334 | 341 | const canAdd = |
| 335 | 342 | preview !== null && |
| 343 | + parsedSpecOverrides.ok && | |
| 336 | 344 | !slugAlreadyExists && |
| 337 | 345 | (!previewHasNoServers || resolvedBaseUrl.length > 0) && |
| 338 | 346 | !(hcOperation.length > 0 && hcMissingRequired); |
| @@ -343,8 +351,19 @@ export default function AddOpenApiIntegration(props: { · 4 unmodified lines | ||
| 343 | 351 | setAnalyzing(true); |
| 344 | 352 | setAnalyzeError(null); |
| 345 | 353 | setAddError(null); |
| 354 | + if (!parsedSpecOverrides.ok) { | |
| 355 | + setAnalyzeError(parsedSpecOverrides.message); | |
| 356 | + setAnalyzing(false); | |
| 357 | + return; | |
| 358 | + } | |
| 346 | 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 | 368 | if (Exit.isFailure(exit)) { |
| 350 | 369 | setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec")); |
| @@ -375,6 +394,9 @@ export default function AddOpenApiIntegration(props: { · 24 unmodified lines | ||
| 375 | 394 | specFormat: initialPreset?.specFormat, |
| 376 | 395 | family: initialPreset?.family, |
| 377 | 396 | healthCheck: initialPreset?.healthCheck, |
| 397 | + ...(parsedSpecOverrides.ok && parsedSpecOverrides.value.length > 0 | |
| 398 | + ? { specOverrides: parsedSpecOverrides.value } | |
| 399 | + : {}), | |
| 378 | 400 | // Always send the edited method list (even empty) when the user has |
| 379 | 401 | // inspected a preview: an explicit [] means "no auth methods", while |
| 380 | 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 | 421 | resolvedDescription, |
| 400 | 422 | resolvedBaseUrl, |
| 401 | 423 | editedAuthenticationTemplate, |
| 424 | + parsedSpecOverrides, | |
| 402 | 425 | initialPreset?.family, |
| 403 | 426 | initialPreset?.healthCheck, |
| 404 | 427 | initialPreset?.specFormat, |
| @@ -539,6 +562,17 @@ export default function AddOpenApiIntegration(props: { · 134 unmodified lines | ||
| 539 | 562 | /> |
| 540 | 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 | 576 | {preview && ( |
| 543 | 577 | <AuthMethodListEditor |
| 544 | 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 | 13 | import { Textarea } from "@executor-js/react/components/textarea"; |
| 14 | 14 | |
| 15 | 15 | import { openApiConfigAtom, updateOpenApiSpec } from "./atoms"; |
| 16 | +import { SpecOverridesEditor } from "./SpecOverridesEditor"; | |
| 17 | +import { formatSpecOverridesText, parseSpecOverridesText } from "../sdk/spec-overrides"; | |
| 16 | 18 | |
| 17 | 19 | // --------------------------------------------------------------------------- |
| 18 | 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 | 49 | const doUpdate = useAtomSet(updateOpenApiSpec, { mode: "promiseExit" }); |
| 48 | 50 | const [refetchStaged, setRefetchStaged] = useState(false); |
| 49 | 51 | const [pasted, setPasted] = useState(""); |
| 52 | + const [specOverridesDraft, setSpecOverridesDraft] = useState<string | null>(null); | |
| 50 | 53 | const [error, setError] = useState<string | null>(null); |
| 51 | 54 | |
| 52 | 55 | const config = |
| 53 | 56 | AsyncResult.isSuccess(configResult) && configResult.value ? configResult.value : null; |
| 54 | 57 | |
| 55 | 58 | const specUrl = config?.specUrl; |
| 59 | + const specOverridesText = specOverridesDraft ?? formatSpecOverridesText(config?.specOverrides); | |
| 56 | 60 | |
| 57 | 61 | // The staged apply, rebuilt whenever the staged inputs change. Reported to |
| 58 | 62 | // the sheet through a ref-stable callback so Save can run it. |
| 59 | 63 | const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => { |
| 60 | 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 | 73 | setError(null); |
| 63 | 74 | const exit = await doUpdate({ |
| 64 | 75 | params: { slug }, |
| 65 | − payload: spec ? { spec } : {}, | |
| 76 | + payload: { | |
| 77 | + ...(spec ? { spec } : {}), | |
| 78 | + ...(specOverridesDraft !== null ? { specOverrides: parsedOverrides.value } : {}), | |
| 79 | + }, | |
| 66 | 80 | reactivityKeys: integrationWriteKeys, |
| 67 | 81 | }); |
| 68 | 82 | if (Exit.isFailure(exit)) { |
| @@ -71,12 +85,13 @@ export default function UpdateSpecSection(props: EditSheetSectionProps) { · 2 unmodified lines | ||
| 71 | 85 | } |
| 72 | 86 | setRefetchStaged(false); |
| 73 | 87 | setPasted(""); |
| 88 | + setSpecOverridesDraft(null); | |
| 74 | 89 | return { ok: true, summary: outcomeSummary(exit.value) }; |
| 75 | − }, [doUpdate, pasted, refetchStaged, slug]); | |
| 90 | + }, [doUpdate, pasted, refetchStaged, slug, specOverridesDraft, specOverridesText]); | |
| 76 | 91 | |
| 77 | 92 | const onPendingChangeRef = useRef(props.onPendingChange); |
| 78 | 93 | onPendingChangeRef.current = props.onPendingChange; |
| 79 | − const hasStagedChange = refetchStaged || pasted.trim().length > 0; | |
| 94 | + const hasStagedChange = refetchStaged || pasted.trim().length > 0 || specOverridesDraft !== null; | |
| 80 | 95 | useEffect(() => { |
| 81 | 96 | onPendingChangeRef.current?.(hasStagedChange ? applyStaged : null); |
| 82 | 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 | 144 | </div> |
| 130 | 145 | )} |
| 131 | 146 | |
| 147 | + <SpecOverridesEditor value={specOverridesText} onChange={setSpecOverridesDraft} /> | |
| 148 | + | |
| 132 | 149 | {error && <FormErrorAlert message={error} />} |
| 133 | 150 | </div> |
| 134 | 151 | ); |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | } from "@executor-js/sdk/http-auth"; |
| 8 | 8 | |
| 9 | 9 | import type { Authentication } from "./types"; |
| 10 | +import { SpecOverridesSchema, type SpecOverrides } from "./spec-overrides"; | |
| 10 | 11 | |
| 11 | 12 | // --------------------------------------------------------------------------- |
| 12 | 13 | // OpenAPI integration config — the opaque blob stored on the catalog |
| @@ -17,6 +18,7 @@ import type { Authentication } from "./types"; · 4 unmodified lines | ||
| 17 | 18 | // StoredSource credential config. The config carries only: |
| 18 | 19 | // - the content hash of the spec blob and/or the source URL to (re)fetch |
| 19 | 20 | // from, |
| 21 | +// - optional RFC 6902 overrides and the raw source hash they apply to, | |
| 20 | 22 | // - the optional base URL override, |
| 21 | 23 | // - the auth templates a connection's value is rendered through. |
| 22 | 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 | 45 | /** Hex SHA-256 of the resolved spec text — the content address of the spec |
| 44 | 46 | * blob (`spec/<hash>` in the plugin blob store). */ |
| 45 | 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 | 50 | /** Origin URL the spec was fetched from, when known. Enables refresh. */ |
| 47 | 51 | specUrl: Schema.optional(Schema.String), |
| 48 | 52 | /** Optional base URL override. */ |
| @@ -55,17 +59,20 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ · 6 unmodified lines | ||
| 55 | 59 | specFormat: Schema.optional(Schema.String), |
| 56 | 60 | /** Catalog family for grouped integration display. */ |
| 57 | 61 | family: Schema.optional(Schema.String), |
| 62 | + /** Ordered RFC 6902 operations reapplied whenever the source spec is refreshed. */ | |
| 63 | + specOverrides: Schema.optional(SpecOverridesSchema), | |
| 58 | 64 | /** The auth methods a connection's value can be applied through. */ |
| 59 | 65 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)), |
| 60 | 66 | }); |
| 61 | 67 | |
| 62 | 68 | export type OpenApiIntegrationConfig = Omit< |
| 63 | 69 | typeof OpenApiIntegrationConfigSchema.Type, |
| 64 | − "authenticationTemplate" | |
| 70 | + "authenticationTemplate" | "specOverrides" | |
| 65 | 71 | > & { |
| 66 | 72 | /** Branded over the schema's structural form so the template renderer can |
| 67 | 73 | * treat `slug` as an `AuthTemplateSlug`. */ |
| 68 | 74 | readonly authenticationTemplate?: readonly Authentication[]; |
| 75 | + readonly specOverrides?: SpecOverrides; | |
| 69 | 76 | }; |
| 70 | 77 | |
| 71 | 78 | const decodeConfig = Schema.decodeUnknownOption(OpenApiIntegrationConfigSchema); |
| @@ -25,6 +25,17 @@ export class OpenApiExtractionError extends Schema.TaggedErrorClass<OpenApiExtra · 24 unmodified lines | ||
| 25 | 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 | 39 | export class OpenApiInvocationError extends Data.TaggedError("OpenApiInvocationError")<{ |
| 29 | 40 | readonly message: string; |
| 30 | 41 | readonly statusCode: Option.Option<number>; |
| @@ -1,4 +1,11 @@ | ||
| 1 | 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 | 9 | export { extract, streamOperationBindingsFromStructure } from "./extract"; |
| 3 | 10 | export { |
| 4 | 11 | structuralSplit, |
| @@ -92,6 +99,7 @@ export { · 87 unmodified lines | ||
| 92 | 99 | OpenApiExtractionError, |
| 93 | 100 | OpenApiInvocationError, |
| 94 | 101 | OpenApiOAuthError, |
| 102 | + OpenApiSpecOverrideError, | |
| 95 | 103 | OpenApiAuthRequiredError, |
| 96 | 104 | } from "./errors"; |
| 97 | 105 | |
| @@ -79,7 +79,7 @@ export const resolveSpecText = (input: string, credentials?: SpecFetchCredential · 78 unmodified lines | ||
| 79 | 79 | * the 128MB Cloudflare Workers memory cap. |
| 80 | 80 | */ |
| 81 | 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 | 84 | if (!isOpenApi3(api)) { |
| 85 | 85 | return yield* new OpenApiExtractionErrorFromParse({ |
| @@ -98,7 +98,8 @@ export const parse = Effect.fn("OpenApi.parse")(function* (text: string) { · 12 unmodified lines | ||
| 98 | 98 | const isOpenApi3 = (doc: OpenAPI.Document): doc is OpenAPIV3.Document | OpenAPIV3_1.Document => |
| 99 | 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 | 103 | Effect.gen(function* () { |
| 103 | 104 | const trimmed = text.trim(); |
| 104 | 105 | if (trimmed.length === 0) { |
| @@ -22,8 +22,13 @@ import { · 21 unmodified lines | ||
| 22 | 22 | } from "@executor-js/sdk/core"; |
| 23 | 23 | |
| 24 | 24 | import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config"; |
| 25 | −import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors"; | |
| 26 | −import { parse, resolveSpecText } from "./parse"; | |
| 25 | +import { | |
| 26 | + OpenApiExtractionError, | |
| 27 | + OpenApiOAuthError, | |
| 28 | + OpenApiParseError, | |
| 29 | + OpenApiSpecOverrideError, | |
| 30 | +} from "./errors"; | |
| 31 | +import { parse, parseSpecObject, resolveSpecText } from "./parse"; | |
| 27 | 32 | import { extract } from "./extract"; |
| 28 | 33 | import { |
| 29 | 34 | OAuth2AuthorizationCodeFlow, |
| @@ -57,6 +62,9 @@ import { · 27 unmodified lines | ||
| 57 | 62 | } from "./backing"; |
| 58 | 63 | import type { InvokeOptions } from "./invoke"; |
| 59 | 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 | 70 | // Extension input shapes |
| @@ -67,6 +75,7 @@ export type OpenApiSpecInput = typeof OpenApiSpecInputSchema.Type; · 4 unmodified lines | ||
| 67 | 75 | export interface OpenApiPreviewInput { |
| 68 | 76 | readonly spec: string; |
| 69 | 77 | readonly specFormat?: string; |
| 78 | + readonly specOverrides?: SpecOverrides; | |
| 70 | 79 | } |
| 71 | 80 | |
| 72 | 81 | /** Add an OpenAPI integration to the catalog. The integration is the API |
| @@ -87,6 +96,8 @@ export interface OpenApiSpecConfig { · 14 unmodified lines | ||
| 87 | 96 | /** Static query params applied to every request. */ |
| 88 | 97 | readonly queryParams?: Record<string, string>; |
| 89 | 98 | readonly specFormat?: string; |
| 99 | + /** Ordered RFC 6902 operations applied after fetching/conversion and before parsing. */ | |
| 100 | + readonly specOverrides?: SpecOverrides; | |
| 90 | 101 | readonly family?: string; |
| 91 | 102 | readonly healthCheck?: HealthCheckSpec; |
| 92 | 103 | /** Auth methods a connection's value renders through - canonical |
| @@ -124,6 +135,8 @@ export interface OpenApiUpdateSpecInput { · 31 unmodified lines | ||
| 124 | 135 | /** New spec source. Omit to re-fetch from the integration's stored |
| 125 | 136 | * `specUrl`. */ |
| 126 | 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 | 142 | export interface OpenApiPluginExtension { |
| @@ -131,7 +144,11 @@ export interface OpenApiPluginExtension { · 1 unmodified line | ||
| 131 | 144 | input: string | OpenApiPreviewInput, |
| 132 | 145 | ) => Effect.Effect< |
| 133 | 146 | SpecPreview, |
| 134 | − OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError | StorageFailure | |
| 147 | + | OpenApiParseError | |
| 148 | + | OpenApiExtractionError | |
| 149 | + | OpenApiOAuthError | |
| 150 | + | OpenApiSpecOverrideError | |
| 151 | + | StorageFailure | |
| 135 | 152 | >; |
| 136 | 153 | readonly addSpec: ( |
| 137 | 154 | config: OpenApiSpecConfig, |
| @@ -140,6 +157,7 @@ export interface OpenApiPluginExtension { · 2 unmodified lines | ||
| 140 | 157 | | OpenApiParseError |
| 141 | 158 | | OpenApiExtractionError |
| 142 | 159 | | OpenApiOAuthError |
| 160 | + | OpenApiSpecOverrideError | |
| 143 | 161 | | IntegrationAlreadyExistsError |
| 144 | 162 | | StorageFailure |
| 145 | 163 | >; |
| @@ -154,6 +172,7 @@ export interface OpenApiPluginExtension { · 8 unmodified lines | ||
| 154 | 172 | | OpenApiParseError |
| 155 | 173 | | OpenApiExtractionError |
| 156 | 174 | | OpenApiOAuthError |
| 175 | + | OpenApiSpecOverrideError | |
| 157 | 176 | | IntegrationNotFoundError |
| 158 | 177 | | StorageFailure |
| 159 | 178 | >; |
| @@ -179,6 +198,7 @@ export interface OpenApiPluginExtension { · 19 unmodified lines | ||
| 179 | 198 | const PreviewSpecInputSchema = Schema.Struct({ |
| 180 | 199 | spec: Schema.String, |
| 181 | 200 | specFormat: Schema.optional(Schema.String), |
| 201 | + specOverrides: Schema.optional(SpecOverridesSchema), | |
| 182 | 202 | }); |
| 183 | 203 | |
| 184 | 204 | const StaticPreviewServerVariableSchema = Schema.Struct({ |
| @@ -282,6 +302,7 @@ const AddIntegrationInputSchema = Schema.Struct({ · 97 unmodified lines | ||
| 282 | 302 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 283 | 303 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 284 | 304 | specFormat: Schema.optional(Schema.String), |
| 305 | + specOverrides: Schema.optional(SpecOverridesSchema), | |
| 285 | 306 | family: Schema.optional(Schema.String), |
| 286 | 307 | healthCheck: Schema.optional(HealthCheckSpec), |
| 287 | 308 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)), |
| @@ -596,10 +617,36 @@ export const openApiPlugin = definePlugin< · 308 unmodified lines | ||
| 596 | 617 | OpenapiStore, |
| 597 | 618 | OpenApiPluginOptions |
| 598 | 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 | 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 | 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 | 650 | Effect.gen(function* () { |
| 604 | 651 | const adapter = yield* resolveSpecFormatAdapter( |
| 605 | 652 | options?.specFormats ?? [], |
| @@ -611,7 +658,7 @@ export const openApiPlugin = definePlugin< · 5 unmodified lines | ||
| 611 | 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 | 662 | urls: [config.spec.url], |
| 616 | 663 | credentials: { |
| 617 | 664 | ...(config.headers ? { headers: config.headers } : {}), |
| @@ -619,14 +666,21 @@ export const openApiPlugin = definePlugin< · 1 unmodified line | ||
| 619 | 666 | }, |
| 620 | 667 | httpClientLayer, |
| 621 | 668 | }); |
| 669 | + return yield* applyOverridesToResolvedSpec(resolved, config.specOverrides); | |
| 622 | 670 | } |
| 623 | 671 | if (config.spec.kind === "url") { |
| 624 | 672 | const specText = yield* resolveSpecText(config.spec.url).pipe( |
| 625 | 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 | 686 | return { |
| @@ -768,6 +822,10 @@ export const openApiPlugin = definePlugin< · 135 unmodified lines | ||
| 768 | 822 | } |
| 769 | 823 | |
| 770 | 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 | 830 | const integrationConfig: OpenApiIntegrationConfig = { |
| 773 | 831 | ...(resolved.config ?? {}), |
| @@ -783,6 +841,9 @@ export const openApiPlugin = definePlugin< · 9 unmodified lines | ||
| 783 | 841 | ...(config.queryParams ? { queryParams: config.queryParams } : {}), |
| 784 | 842 | ...(config.specFormat ? { specFormat: config.specFormat } : {}), |
| 785 | 843 | ...(config.family ? { family: config.family } : {}), |
| 844 | + ...(config.specOverrides && config.specOverrides.length > 0 | |
| 845 | + ? { specOverrides: config.specOverrides, sourceSpecHash } | |
| 846 | + : {}), | |
| 786 | 847 | // Prefer the caller's explicit template; otherwise derive from the |
| 787 | 848 | // spec's declared security schemes. |
| 788 | 849 | ...(config.authenticationTemplate |
| @@ -801,6 +862,9 @@ export const openApiPlugin = definePlugin< · 12 unmodified lines | ||
| 801 | 862 | // leaves only an unreferenced blob behind - while blob backends like |
| 802 | 863 | // R2 couldn't roll back with the transaction anyway. |
| 803 | 864 | yield* ctx.storage.putSpec(specHash, resolved.specText); |
| 865 | + if (sourceSpecHash) { | |
| 866 | + yield* ctx.storage.putSpec(sourceSpecHash, resolved.sourceSpecText); | |
| 867 | + } | |
| 804 | 868 | // The content-addressed defs blob lets the serve path resolve the |
| 805 | 869 | // shared `definitions` without re-parsing the spec. Same idempotent, |
| 806 | 870 | // outside-the-transaction rationale as the spec blob. |
| @@ -870,8 +934,21 @@ export const openApiPlugin = definePlugin< · 63 unmodified lines | ||
| 870 | 934 | // The new spec source: explicit input wins; otherwise re-fetch from |
| 871 | 935 | // where the spec originally came from. A pasted-blob integration has |
| 872 | 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 | 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 | 952 | if (specInput === null) { |
| 876 | 953 | return yield* new OpenApiParseError({ |
| 877 | 954 | message: |
| @@ -885,6 +962,7 @@ export const openApiPlugin = definePlugin< · 7 unmodified lines | ||
| 885 | 962 | { |
| 886 | 963 | spec: specInput, |
| 887 | 964 | specFormat: current.specFormat, |
| 965 | + specOverrides: nextOverrides, | |
| 888 | 966 | headers: current.headers, |
| 889 | 967 | queryParams: current.queryParams, |
| 890 | 968 | baseUrl: current.baseUrl, |
| @@ -903,17 +981,28 @@ export const openApiPlugin = definePlugin< · 12 unmodified lines | ||
| 903 | 981 | // the blob outside the transaction - re-puts are idempotent and an |
| 904 | 982 | // aborted config update just leaves an unreferenced blob. |
| 905 | 983 | const specHash = yield* sha256Hex(resolved.specText); |
| 984 | + const sourceSpecHash = | |
| 985 | + nextOverrides.length > 0 ? yield* sha256Hex(resolved.sourceSpecText) : undefined; | |
| 906 | 986 | yield* ctx.storage.putSpec(specHash, resolved.specText); |
| 987 | + if (sourceSpecHash) { | |
| 988 | + yield* ctx.storage.putSpec(sourceSpecHash, resolved.sourceSpecText); | |
| 989 | + } | |
| 907 | 990 | if (compiled) { |
| 908 | 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 | 999 | const nextConfig: OpenApiIntegrationConfig = { |
| 912 | − ...current, | |
| 1000 | + ...currentWithoutOverrides, | |
| 913 | 1001 | specHash, |
| 914 | 1002 | ...((resolved.specUrl ?? specInputToSpecUrl(specInput)) !== undefined |
| 915 | 1003 | ? { specUrl: resolved.specUrl ?? specInputToSpecUrl(specInput) } |
| 916 | 1004 | : {}), |
| 1005 | + ...(nextOverrides.length > 0 ? { specOverrides: nextOverrides, sourceSpecHash } : {}), | |
| 917 | 1006 | }; |
| 918 | 1007 | |
| 919 | 1008 | yield* ctx.transaction( |
| @@ -985,7 +1074,11 @@ export const openApiPlugin = definePlugin< · 65 unmodified lines | ||
| 985 | 1074 | ? { kind: "url" as const, url: previewInput.spec.trim() } |
| 986 | 1075 | : { kind: "blob" as const, value: previewInput.spec }; |
| 987 | 1076 | const resolved = yield* resolveSpecForInput( |
| 988 | − { spec, specFormat: previewInput.specFormat }, | |
| 1077 | + { | |
| 1078 | + spec, | |
| 1079 | + specFormat: previewInput.specFormat, | |
| 1080 | + specOverrides: previewInput.specOverrides, | |
| 1081 | + }, | |
| 989 | 1082 | httpClientLayer, |
| 990 | 1083 | ); |
| 991 | 1084 | const preview = yield* previewSpecText(resolved.specText); |
| @@ -1093,6 +1186,8 @@ export const openApiPlugin = definePlugin< · 101 unmodified lines | ||
| 1093 | 1186 | Effect.succeed(openApiToolFailure("openapi_extraction_failed", message)), |
| 1094 | 1187 | OpenApiOAuthError: ({ message }: OpenApiOAuthError) => |
| 1095 | 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 | 1212 | headers: input.headers, |
| 1118 | 1213 | queryParams: input.queryParams, |
| 1119 | 1214 | specFormat: input.specFormat, |
| 1215 | + specOverrides: input.specOverrides, | |
| 1120 | 1216 | family: input.family, |
| 1121 | 1217 | healthCheck: input.healthCheck, |
| 1122 | 1218 | authenticationTemplate: input.authenticationTemplate as |
| @@ -1137,6 +1233,8 @@ export const openApiPlugin = definePlugin< · 14 unmodified lines | ||
| 1137 | 1233 | Effect.succeed(openApiToolFailure("openapi_extraction_failed", message)), |
| 1138 | 1234 | OpenApiOAuthError: ({ message }: OpenApiOAuthError) => |
| 1139 | 1235 | Effect.succeed(openApiToolFailure("openapi_oauth_failed", message)), |
| 1236 | + OpenApiSpecOverrideError: ({ message }) => | |
| 1237 | + Effect.succeed(openApiToolFailure("openapi_spec_override_failed", message)), | |
| 1140 | 1238 | IntegrationAlreadyExistsError: ({ slug }: IntegrationAlreadyExistsError) => |
| 1141 | 1239 | Effect.succeed( |
| 1142 | 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 | +}); | |