540 lines
15 KiB
TypeScript
540 lines
15 KiB
TypeScript
/**
|
|
* OpenAPI spec patching utilities.
|
|
*
|
|
* Handles parsing and applying JSON Patch documents (RFC 6902) to OpenAPI
|
|
* specs. Supports patches from:
|
|
* - JSON files (.json)
|
|
* - YAML files (.yaml, .yml)
|
|
* - Inline JSON strings
|
|
*
|
|
* @since 4.0.0
|
|
*/
|
|
|
|
import * as Effect from "effect/Effect"
|
|
import * as FileSystem from "effect/FileSystem"
|
|
import { constFalse, constUndefined } from "effect/Function"
|
|
import * as JsonPatch from "effect/JsonPatch"
|
|
import * as Path from "effect/Path"
|
|
import * as Predicate from "effect/Predicate"
|
|
import * as Schema from "effect/Schema"
|
|
import * as Yaml from "yaml"
|
|
|
|
// =============================================================================
|
|
// Error Types
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Error thrown when parsing a JSON Patch input fails.
|
|
*
|
|
* **Details**
|
|
*
|
|
* This error occurs when:
|
|
* - A patch file cannot be read
|
|
* - JSON or YAML syntax is invalid
|
|
* - The file format is unsupported
|
|
*
|
|
* **Example** (Creating a parse error)
|
|
*
|
|
* ```ts
|
|
* import * as OpenApiPatch from "@effect/openapi-generator/OpenApiPatch"
|
|
*
|
|
* const error = new OpenApiPatch.JsonPatchParseError({
|
|
* source: "./patches/fix.json",
|
|
* reason: "Unexpected token at position 42"
|
|
* })
|
|
*
|
|
* console.log(error.message)
|
|
* // "Failed to parse patch from ./patches/fix.json: Unexpected token at position 42"
|
|
* ```
|
|
*
|
|
* @category errors
|
|
* @since 4.0.0
|
|
*/
|
|
export class JsonPatchParseError extends Schema.ErrorClass<JsonPatchParseError>("JsonPatchParseError")({
|
|
_tag: Schema.tag("JsonPatchParseError"),
|
|
source: Schema.String,
|
|
reason: Schema.String
|
|
}) {
|
|
override get message() {
|
|
return `Failed to parse patch from ${this.source}: ${this.reason}`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Error thrown when a parsed value does not conform to the JSON Patch schema.
|
|
*
|
|
* **Details**
|
|
*
|
|
* This error occurs when:
|
|
* - The patch is not an array
|
|
* - An operation is missing required fields (op, path)
|
|
* - An operation has an unsupported op value
|
|
* - An add/replace operation is missing the value field
|
|
*
|
|
* **Example** (Creating a validation error)
|
|
*
|
|
* ```ts
|
|
* import * as OpenApiPatch from "@effect/openapi-generator/OpenApiPatch"
|
|
*
|
|
* const error = new OpenApiPatch.JsonPatchValidationError({
|
|
* source: "inline",
|
|
* reason: "Expected 'add' | 'remove' | 'replace' at [0].op, got 'copy'"
|
|
* })
|
|
*
|
|
* console.log(error.message)
|
|
* // "Invalid JSON Patch from inline: Expected 'add' | 'remove' | 'replace' at [0].op, got 'copy'"
|
|
* ```
|
|
*
|
|
* @category errors
|
|
* @since 4.0.0
|
|
*/
|
|
export class JsonPatchValidationError extends Schema.ErrorClass<JsonPatchValidationError>("JsonPatchValidationError")({
|
|
_tag: Schema.tag("JsonPatchValidationError"),
|
|
source: Schema.String,
|
|
reason: Schema.String
|
|
}) {
|
|
override get message() {
|
|
return `Invalid JSON Patch from ${this.source}: ${this.reason}`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Error thrown when applying a JSON Patch operation fails.
|
|
*
|
|
* **Details**
|
|
*
|
|
* This error occurs when:
|
|
* - A path does not exist for remove/replace operations
|
|
* - An array index is out of bounds
|
|
* - The target location is not a valid container
|
|
*
|
|
* **Example** (Creating an application error)
|
|
*
|
|
* ```ts
|
|
* import * as OpenApiPatch from "@effect/openapi-generator/OpenApiPatch"
|
|
*
|
|
* const error = new OpenApiPatch.JsonPatchApplicationError({
|
|
* source: "./patches/fix.json",
|
|
* operationIndex: 2,
|
|
* operation: "remove",
|
|
* path: "/paths/~1users",
|
|
* reason: "Property \"users\" does not exist"
|
|
* })
|
|
*
|
|
* console.log(error.message)
|
|
* // "Failed to apply patch from ./patches/fix.json: operation 2 (remove at /paths/~1users): Property \"users\" does not exist"
|
|
* ```
|
|
*
|
|
* @category errors
|
|
* @since 4.0.0
|
|
*/
|
|
export class JsonPatchApplicationError
|
|
extends Schema.ErrorClass<JsonPatchApplicationError>("JsonPatchApplicationError")({
|
|
_tag: Schema.tag("JsonPatchApplicationError"),
|
|
source: Schema.String,
|
|
operationIndex: Schema.Number,
|
|
operation: Schema.String,
|
|
path: Schema.String,
|
|
reason: Schema.String
|
|
})
|
|
{
|
|
override get message() {
|
|
return `Failed to apply patch from ${this.source}: operation ${this.operationIndex} ` +
|
|
`(${this.operation} at ${this.path}): ${this.reason}`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Error thrown when multiple JSON Patch operations fail.
|
|
*
|
|
* **Details**
|
|
*
|
|
* This error aggregates all application errors so users can see every
|
|
* failing operation at once instead of fixing them one at a time.
|
|
*
|
|
* **Example** (Creating an aggregate error)
|
|
*
|
|
* ```ts
|
|
* import * as OpenApiPatch from "@effect/openapi-generator/OpenApiPatch"
|
|
*
|
|
* const error = new OpenApiPatch.JsonPatchAggregateError({
|
|
* errors: [
|
|
* new OpenApiPatch.JsonPatchApplicationError({
|
|
* source: "./fix.json",
|
|
* operationIndex: 0,
|
|
* operation: "replace",
|
|
* path: "/info/x",
|
|
* reason: "Property does not exist"
|
|
* }),
|
|
* new OpenApiPatch.JsonPatchApplicationError({
|
|
* source: "./fix.json",
|
|
* operationIndex: 2,
|
|
* operation: "remove",
|
|
* path: "/paths/~1users",
|
|
* reason: "Property does not exist"
|
|
* })
|
|
* ]
|
|
* })
|
|
*
|
|
* console.log(error.message)
|
|
* // "2 patch operations failed:\n 1. ..."
|
|
* ```
|
|
*
|
|
* @category errors
|
|
* @since 4.0.0
|
|
*/
|
|
export class JsonPatchAggregateError extends Schema.ErrorClass<JsonPatchAggregateError>("JsonPatchAggregateError")({
|
|
_tag: Schema.tag("JsonPatchAggregateError"),
|
|
errors: Schema.Array(Schema.Unknown)
|
|
}) {
|
|
override get message() {
|
|
const errors = this.errors as ReadonlyArray<JsonPatchApplicationError>
|
|
const count = errors.length
|
|
const plural = count === 1 ? "operation" : "operations"
|
|
const details = errors
|
|
.map((e, i) => ` ${i + 1}. [${e.source}] op ${e.operationIndex} (${e.operation} at ${e.path}): ${e.reason}`)
|
|
.join("\n")
|
|
return `${count} patch ${plural} failed:\n${details}`
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Schema
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Schema for a JSON Patch "add" operation.
|
|
*
|
|
* @category schemas
|
|
* @since 4.0.0
|
|
*/
|
|
export const JsonPatchAdd: Schema.Codec<
|
|
Extract<
|
|
JsonPatch.JsonPatchOperation,
|
|
{ op: "add" }
|
|
>
|
|
> = Schema.Struct({
|
|
op: Schema.Literal("add"),
|
|
path: Schema.String,
|
|
value: Schema.Json,
|
|
description: Schema.optionalKey(Schema.String)
|
|
})
|
|
|
|
/**
|
|
* Schema for a JSON Patch "remove" operation.
|
|
*
|
|
* @category schemas
|
|
* @since 4.0.0
|
|
*/
|
|
export const JsonPatchRemove: Schema.Codec<
|
|
Extract<
|
|
JsonPatch.JsonPatchOperation,
|
|
{ op: "remove" }
|
|
>
|
|
> = Schema.Struct({
|
|
op: Schema.Literal("remove"),
|
|
path: Schema.String,
|
|
description: Schema.optionalKey(Schema.String)
|
|
})
|
|
|
|
/**
|
|
* Schema for a JSON Patch "replace" operation.
|
|
*
|
|
* @category schemas
|
|
* @since 4.0.0
|
|
*/
|
|
export const JsonPatchReplace: Schema.Codec<
|
|
Extract<
|
|
JsonPatch.JsonPatchOperation,
|
|
{ op: "replace" }
|
|
>
|
|
> = Schema.Struct({
|
|
op: Schema.Literal("replace"),
|
|
path: Schema.String,
|
|
value: Schema.Json,
|
|
description: Schema.optionalKey(Schema.String)
|
|
})
|
|
|
|
/**
|
|
* Schema for a single JSON Patch operation.
|
|
*
|
|
* **Details**
|
|
*
|
|
* Supports the subset of RFC 6902 operations that Effect's JsonPatch module
|
|
* implements: `add`, `remove`, and `replace`.
|
|
*
|
|
* @category schemas
|
|
* @since 4.0.0
|
|
*/
|
|
export const JsonPatchOperation: Schema.Codec<JsonPatch.JsonPatchOperation> = Schema.Union([
|
|
JsonPatchAdd,
|
|
JsonPatchRemove,
|
|
JsonPatchReplace
|
|
])
|
|
|
|
/**
|
|
* Schema for a JSON Patch document (array of operations).
|
|
*
|
|
* **Details**
|
|
*
|
|
* A JSON Patch document is an ordered list of operations to apply to a JSON
|
|
* document. Operations are applied in sequence, with each operation seeing
|
|
* the result of previous operations.
|
|
*
|
|
* **Example** (Decoding a patch document)
|
|
*
|
|
* ```ts
|
|
* import { Schema } from "effect"
|
|
* import * as OpenApiPatch from "@effect/openapi-generator/OpenApiPatch"
|
|
*
|
|
* const patch = Schema.decodeUnknownSync(OpenApiPatch.JsonPatchDocument)([
|
|
* { op: "add", path: "/foo", value: "bar" },
|
|
* { op: "remove", path: "/baz" },
|
|
* { op: "replace", path: "/qux", value: 42 }
|
|
* ])
|
|
* ```
|
|
*
|
|
* @category schemas
|
|
* @since 4.0.0
|
|
*/
|
|
export const JsonPatchDocument = Schema.Array(JsonPatchOperation)
|
|
|
|
/**
|
|
* Type for a JSON Patch document.
|
|
*
|
|
* @category types
|
|
* @since 4.0.0
|
|
*/
|
|
export type JsonPatchDocument = typeof JsonPatchDocument.Type
|
|
|
|
// =============================================================================
|
|
// Parsing Functions
|
|
// =============================================================================
|
|
|
|
const decodeJsonPatchDocument = Schema.decodeUnknownEffect(JsonPatchDocument)
|
|
|
|
/**
|
|
* Check if a string looks like it could be a file path.
|
|
*
|
|
* Heuristic: contains path separators or ends with a known extension.
|
|
*/
|
|
const looksLikeFilePath = (input: string): boolean => {
|
|
const trimmed = input.trim()
|
|
if (trimmed.startsWith("[")) return false
|
|
if (trimmed.includes("/") || trimmed.includes("\\")) return true
|
|
if (/\.(json|yaml|yml)$/i.test(trimmed)) return true
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Determine file format from extension.
|
|
*/
|
|
const getFileFormat = Effect.fn(function*(filePath: string) {
|
|
const path = yield* Path.Path
|
|
const { ext } = path.parse(filePath)
|
|
if (ext === ".json") return "json"
|
|
if (ext === ".yaml" || ext === ".yml") return "yaml"
|
|
return undefined
|
|
})
|
|
|
|
/**
|
|
* Check if a file path exists and is a file.
|
|
*/
|
|
const checkFileExists = Effect.fn("checkFileExists")(function*(filePath: string) {
|
|
const fs = yield* FileSystem.FileSystem
|
|
const path = yield* Path.Path
|
|
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath)
|
|
const exists = yield* Effect.orElseSucceed(fs.exists(absolutePath), constFalse)
|
|
if (!exists) return false
|
|
const stat = yield* Effect.orElseSucceed(fs.stat(absolutePath), constUndefined)
|
|
return Predicate.isNotUndefined(stat) && stat.type === "File"
|
|
})
|
|
|
|
/**
|
|
* Parse content as JSON.
|
|
*/
|
|
const parseJsonContent = Effect.fnUntraced(function*(content: string, source: string) {
|
|
return yield* Effect.try({
|
|
try: () => JSON.parse(content) as unknown,
|
|
catch: (error) =>
|
|
new JsonPatchParseError({
|
|
source,
|
|
reason: error instanceof Error ? error.message : String(error)
|
|
})
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Parse content as YAML.
|
|
*/
|
|
const parseYamlContent = Effect.fnUntraced(function*(content: string, source: string) {
|
|
return yield* Effect.try({
|
|
try: () => Yaml.parse(content) as unknown,
|
|
catch: (error) =>
|
|
new JsonPatchParseError({
|
|
source,
|
|
reason: error instanceof Error ? error.message : String(error)
|
|
})
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Read and parse a patch file.
|
|
*/
|
|
const parsePatchFile = Effect.fn("parsePatchFile")(function*(filePath: string) {
|
|
const fs = yield* FileSystem.FileSystem
|
|
const path = yield* Path.Path
|
|
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath)
|
|
|
|
const fileFormat = yield* getFileFormat(filePath)
|
|
if (Predicate.isUndefined(fileFormat)) {
|
|
return yield* new JsonPatchParseError({
|
|
source: filePath,
|
|
reason: `Unsupported file format. Expected .json, .yaml, or .yml`
|
|
})
|
|
}
|
|
|
|
const content = yield* Effect.mapError(fs.readFileString(absolutePath), (error) =>
|
|
new JsonPatchParseError({
|
|
source: filePath,
|
|
reason: `Failed to read file: ${error.message}`
|
|
}))
|
|
|
|
const parsed = fileFormat === "json"
|
|
? yield* parseJsonContent(content, filePath)
|
|
: yield* parseYamlContent(content, filePath)
|
|
|
|
return yield* Effect.mapError(decodeJsonPatchDocument(parsed), (error) =>
|
|
new JsonPatchValidationError({
|
|
source: filePath,
|
|
reason: error.message
|
|
}))
|
|
})
|
|
|
|
/**
|
|
* Parse inline JSON string as a patch document.
|
|
*/
|
|
const parseInlinePatch = Effect.fn("parseInlinePatch")(function*(input: string) {
|
|
const parsed = yield* parseJsonContent(input, "inline")
|
|
return yield* Effect.mapError(decodeJsonPatchDocument(parsed), (error) =>
|
|
new JsonPatchValidationError({
|
|
source: "inline",
|
|
reason: error.message
|
|
}))
|
|
})
|
|
|
|
/**
|
|
* Parse a JSON Patch from either a file path or inline JSON string.
|
|
*
|
|
* **Details**
|
|
*
|
|
* The input is first checked as a file path. If the file exists, it is read
|
|
* and parsed based on its extension (.json, .yaml, .yml). Otherwise, the
|
|
* input is parsed as inline JSON.
|
|
*
|
|
* **Example** (Parsing patch input)
|
|
*
|
|
* ```ts
|
|
* import { Effect } from "effect"
|
|
* import * as OpenApiPatch from "@effect/openapi-generator/OpenApiPatch"
|
|
*
|
|
* // From inline JSON
|
|
* const fromInline = OpenApiPatch.parsePatchInput(
|
|
* '[{"op":"replace","path":"/info/title","value":"My API"}]'
|
|
* )
|
|
*
|
|
* // From file path
|
|
* const fromFile = OpenApiPatch.parsePatchInput("./patches/fix-api.json")
|
|
*
|
|
* const program = Effect.gen(function*() {
|
|
* const patch = yield* fromInline
|
|
* console.log(patch)
|
|
* // [{ op: "replace", path: "/info/title", value: "My API" }]
|
|
* })
|
|
* ```
|
|
*
|
|
* @category parsing
|
|
* @since 4.0.0
|
|
*/
|
|
export const parsePatchInput = Effect.fn("parsePatchInput")(function*(input: string) {
|
|
if (looksLikeFilePath(input)) {
|
|
const exists = yield* checkFileExists(input)
|
|
if (exists) {
|
|
return yield* parsePatchFile(input)
|
|
}
|
|
}
|
|
return yield* parseInlinePatch(input)
|
|
})
|
|
|
|
// =============================================================================
|
|
// Application Functions
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Apply a sequence of JSON patches to a document.
|
|
*
|
|
* **Details**
|
|
*
|
|
* Patches are applied in order, with each patch operating on the result of
|
|
* the previous one. All operations are attempted, and if any fail, the errors
|
|
* are accumulated and reported together so users can fix all issues at once.
|
|
*
|
|
* **Example** (Applying patches)
|
|
*
|
|
* ```ts
|
|
* import { Effect } from "effect"
|
|
* import * as OpenApiPatch from "@effect/openapi-generator/OpenApiPatch"
|
|
*
|
|
* const document = { info: { title: "Old Title" }, paths: {} }
|
|
* const patches = [
|
|
* {
|
|
* source: "inline",
|
|
* patch: [{ op: "replace" as const, path: "/info/title", value: "New Title" }]
|
|
* }
|
|
* ]
|
|
*
|
|
* const program = Effect.gen(function*() {
|
|
* const result = yield* OpenApiPatch.applyPatches(patches, document)
|
|
* console.log(result)
|
|
* // { info: { title: "New Title" }, paths: {} }
|
|
* })
|
|
* ```
|
|
*
|
|
* @category application
|
|
* @since 4.0.0
|
|
*/
|
|
export const applyPatches = Effect.fn("applyPatches")(function*(
|
|
patches: ReadonlyArray<{ readonly source: string; readonly patch: JsonPatchDocument }>,
|
|
document: Schema.Json
|
|
) {
|
|
let result: Schema.Json = document
|
|
const errors: Array<JsonPatchApplicationError> = []
|
|
|
|
for (const { source, patch } of patches) {
|
|
for (let i = 0; i < patch.length; i++) {
|
|
const op = patch[i]
|
|
yield* Effect.ignore(Effect.try({
|
|
try: () => {
|
|
result = JsonPatch.apply([op], result)
|
|
},
|
|
catch: (error) =>
|
|
errors.push(
|
|
new JsonPatchApplicationError({
|
|
source,
|
|
operationIndex: i,
|
|
operation: op.op,
|
|
path: op.path,
|
|
reason: error instanceof Error ? error.message : String(error)
|
|
})
|
|
)
|
|
}))
|
|
}
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
return yield* new JsonPatchAggregateError({ errors })
|
|
}
|
|
|
|
return result
|
|
})
|