import { Data, Effect, Exit, Match, Option, Schema } from "effect"; import { OpenApiSpecOverrideError } from "./errors"; export const JsonPatchOperationSchema = Schema.Union([ Schema.Struct({ op: Schema.Literal("add"), path: Schema.String, value: Schema.Unknown, }), Schema.Struct({ op: Schema.Literal("remove"), path: Schema.String, }), Schema.Struct({ op: Schema.Literal("replace"), path: Schema.String, value: Schema.Unknown, }), Schema.Struct({ op: Schema.Literal("move"), path: Schema.String, from: Schema.String, }), Schema.Struct({ op: Schema.Literal("copy"), path: Schema.String, from: Schema.String, }), Schema.Struct({ op: Schema.Literal("test"), path: Schema.String, value: Schema.Unknown, }), ]); export const SpecOverridesSchema = Schema.Array(JsonPatchOperationSchema); export type JsonPatchOperation = typeof JsonPatchOperationSchema.Type; export type SpecOverrides = typeof SpecOverridesSchema.Type; const decodeSpecOverridesText = Schema.decodeUnknownExit( Schema.fromJsonString(SpecOverridesSchema), ); const decodeSpecOverrides = Schema.decodeUnknownOption(SpecOverridesSchema); export type SpecOverridesTextResult = | { readonly ok: true; readonly value: SpecOverrides } | { readonly ok: false; readonly message: string }; /** Decode preset or persisted override data at a plugin boundary. */ export const decodeOpenApiSpecOverrides = (value: unknown): SpecOverrides | undefined => decodeSpecOverrides(value).pipe(Option.getOrUndefined); /** Decode user-authored JSON Patch text into the persisted override contract. */ export const parseSpecOverridesText = (text: string): SpecOverridesTextResult => { if (text.trim().length === 0) return { ok: true, value: [] }; const decoded = decodeSpecOverridesText(text); return Exit.isSuccess(decoded) ? { ok: true, value: decoded.value } : { ok: false, message: "Spec overrides must be a JSON array of RFC 6902 add, remove, replace, move, copy, or test operations.", }; }; /** Format persisted overrides for the advanced JSON editor. */ export const formatSpecOverridesText = (overrides: SpecOverrides | undefined): string => overrides && overrides.length > 0 ? JSON.stringify(overrides, null, 2) : ""; type JsonPrimitive = string | number | boolean | null; type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; interface JsonObject { [key: string]: JsonValue; } class PatchApplicationFailure extends Data.TaggedError("PatchApplicationFailure")<{ readonly detail: string; }> {} const patchFailure = (detail: string) => new PatchApplicationFailure({ detail }); const isJsonValue = (value: unknown): value is JsonValue => { if (value === null) return true; if (typeof value === "string" || typeof value === "boolean") return true; if (typeof value === "number") return Number.isFinite(value); if (Array.isArray(value)) return value.every(isJsonValue); if (typeof value !== "object") return false; const prototype = Object.getPrototypeOf(value); if (prototype !== Object.prototype && prototype !== null) return false; return Object.values(value).every(isJsonValue); }; const overrideError = (operationIndex: number, operation: string, path: string, detail: string) => new OpenApiSpecOverrideError({ operationIndex, operation, path, message: `Spec override ${operationIndex + 1} (${operation} ${path || "/"}) failed: ${detail}`, }); const cloneJsonValue = (value: JsonValue): JsonValue => { if (Array.isArray(value)) return value.map(cloneJsonValue); if (value !== null && typeof value === "object") { const clone: JsonObject = Object.create(null); for (const [key, entry] of Object.entries(value)) clone[key] = cloneJsonValue(entry); return clone; } return value; }; const parsePointer = Effect.fn("OpenApi.parseJsonPointer")(function* (pointer: string) { if (pointer === "") return []; if (!pointer.startsWith("/")) { return yield* patchFailure(`Invalid JSON Pointer ${JSON.stringify(pointer)}`); } const segments: string[] = []; for (const segment of pointer.slice(1).split("/")) { if (/~(?:[^01]|$)/.test(segment)) { return yield* patchFailure(`Invalid JSON Pointer escape in ${JSON.stringify(pointer)}`); } segments.push(segment.replaceAll("~1", "/").replaceAll("~0", "~")); } return segments; }); const arrayIndex = Effect.fn("OpenApi.jsonPatchArrayIndex")(function* ( segment: string, length: number, pointer: string, options: { readonly append: boolean; readonly existing: boolean }, ) { if (segment === "-") { if (options.append) return length; return yield* patchFailure(`JSON Pointer ${JSON.stringify(pointer)} cannot use '-' here`); } if (!/^(0|[1-9]\d*)$/.test(segment)) { return yield* patchFailure( `JSON Pointer ${JSON.stringify(pointer)} has invalid array index ${JSON.stringify(segment)}`, ); } const index = Number(segment); const upperBound = options.existing ? length - 1 : length; if (!Number.isSafeInteger(index) || index < 0 || index > upperBound) { return yield* patchFailure( `JSON Pointer ${JSON.stringify(pointer)} has array index ${index} outside the valid range`, ); } return index; }); const valueAt = Effect.fn("OpenApi.jsonPatchValueAt")(function* (root: JsonValue, pointer: string) { const segments = yield* parsePointer(pointer); let current = root; for (const segment of segments) { if (Array.isArray(current)) { current = current[ yield* arrayIndex(segment, current.length, pointer, { append: false, existing: true, }) ]!; continue; } if (current !== null && typeof current === "object") { if (!Object.hasOwn(current, segment)) { return yield* patchFailure(`JSON Pointer ${JSON.stringify(pointer)} does not exist`); } current = current[segment]!; continue; } return yield* patchFailure(`JSON Pointer ${JSON.stringify(pointer)} cannot be traversed`); } return current; }); const parentAt = Effect.fn("OpenApi.jsonPatchParentAt")(function* ( root: JsonValue, pointer: string, ) { const segments = yield* parsePointer(pointer); if (segments.length === 0) { return yield* patchFailure("The document root has no parent"); } const parentPointer = `/${segments .slice(0, -1) .map((segment) => segment.replaceAll("~", "~0").replaceAll("/", "~1")) .join("/")}`; const parent = segments.length === 1 ? root : yield* valueAt(root, parentPointer); if (parent === null || typeof parent !== "object") { return yield* patchFailure(`JSON Pointer ${JSON.stringify(pointer)} has no container`); } return { parent, segment: segments[segments.length - 1]! }; }); const addValue = Effect.fn("OpenApi.jsonPatchAddValue")(function* ( root: JsonValue, pointer: string, value: JsonValue, ) { if (pointer === "") return cloneJsonValue(value); const { parent, segment } = yield* parentAt(root, pointer); if (Array.isArray(parent)) { const index = yield* arrayIndex(segment, parent.length, pointer, { append: true, existing: false, }); parent.splice(index, 0, cloneJsonValue(value)); } else { parent[segment] = cloneJsonValue(value); } return root; }); const removeValue = Effect.fn("OpenApi.jsonPatchRemoveValue")(function* ( root: JsonValue, pointer: string, ) { if (pointer === "") return { root: null, removed: root }; const { parent, segment } = yield* parentAt(root, pointer); if (Array.isArray(parent)) { const index = yield* arrayIndex(segment, parent.length, pointer, { append: false, existing: true, }); return { root, removed: parent.splice(index, 1)[0]! }; } if (!Object.hasOwn(parent, segment)) { return yield* patchFailure(`JSON Pointer ${JSON.stringify(pointer)} does not exist`); } const removed = parent[segment]!; delete parent[segment]; return { root, removed }; }); const replaceValue = Effect.fn("OpenApi.jsonPatchReplaceValue")(function* ( root: JsonValue, pointer: string, value: JsonValue, ) { if (pointer === "") return cloneJsonValue(value); yield* valueAt(root, pointer); const { parent, segment } = yield* parentAt(root, pointer); if (Array.isArray(parent)) { const index = yield* arrayIndex(segment, parent.length, pointer, { append: false, existing: true, }); parent[index] = cloneJsonValue(value); } else { parent[segment] = cloneJsonValue(value); } return root; }); const jsonEquals = (left: JsonValue, right: JsonValue): boolean => { if (left === right) return true; if (Array.isArray(left) || Array.isArray(right)) { return ( Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((entry, index) => jsonEquals(entry, right[index]!)) ); } if (left === null || right === null || typeof left !== "object" || typeof right !== "object") { return false; } const leftKeys = Object.keys(left); const rightKeys = Object.keys(right); return ( leftKeys.length === rightKeys.length && leftKeys.every((key) => Object.hasOwn(right, key) && jsonEquals(left[key]!, right[key]!)) ); }; const operationValue = Effect.fn("OpenApi.jsonPatchOperationValue")(function* ( operation: JsonPatchOperation, ) { if (!("value" in operation) || !isJsonValue(operation.value)) { return yield* patchFailure(`${operation.op} requires a JSON value`); } return operation.value; }); const applyOperation = (root: JsonValue, operation: JsonPatchOperation) => Match.value(operation).pipe( Match.when({ op: "add" }, (entry) => Effect.gen(function* () { const value = yield* operationValue(entry); return yield* addValue(root, entry.path, value); }), ), Match.when({ op: "remove" }, (entry) => removeValue(root, entry.path).pipe(Effect.map((result) => result.root)), ), Match.when({ op: "replace" }, (entry) => Effect.gen(function* () { const value = yield* operationValue(entry); return yield* replaceValue(root, entry.path, value); }), ), Match.when({ op: "copy" }, (entry) => Effect.gen(function* () { const value = yield* valueAt(root, entry.from); return yield* addValue(root, entry.path, value); }), ), Match.when({ op: "move" }, (entry) => Effect.gen(function* () { if (entry.path.startsWith(`${entry.from}/`)) { return yield* patchFailure("A value cannot be moved into one of its children"); } const removed = yield* removeValue(root, entry.from); return yield* addValue(removed.root, entry.path, removed.removed); }), ), Match.when({ op: "test" }, (entry) => Effect.gen(function* () { const expected = yield* operationValue(entry); const actual = yield* valueAt(root, entry.path); if (!jsonEquals(actual, expected)) { return yield* patchFailure("The test value did not match"); } return root; }), ), Match.exhaustive, ); /** Apply ordered RFC 6902 operations to a cloned JSON document. */ export const applySpecOverrides = Effect.fn("OpenApi.applySpecOverrides")(function* ( document: unknown, overrides: SpecOverrides, ) { if (!isJsonValue(document)) { return yield* overrideError( 0, "parse", "", "OpenAPI document must contain only JSON-compatible values", ); } let result = cloneJsonValue(document); for (const [operationIndex, operation] of overrides.entries()) { const applied = yield* applyOperation(result, operation).pipe( Effect.mapError((failure) => overrideError(operationIndex, operation.op, operation.path, failure.detail), ), ); result = applied; } if (result === null || typeof result !== "object" || Array.isArray(result)) { return yield* overrideError( Math.max(0, overrides.length - 1), overrides.at(-1)?.op ?? "parse", overrides.at(-1)?.path ?? "", "Spec overrides must leave the OpenAPI document as an object", ); } return result; });