1/**
2 * OpenAPI spec patching utilities.
3 *
4 * Handles parsing and applying JSON Patch documents (RFC 6902) to OpenAPI
5 * specs. Supports patches from:
6 * - JSON files (.json)
7 * - YAML files (.yaml, .yml)
8 * - Inline JSON strings
9 *
10 * @since 4.0.0
11 */
12
13import * as Effect from "effect/Effect"
14import * as Yaml from "effect/encoding/Yaml"
15import * as FileSystem from "effect/FileSystem"
16import { constFalse, constUndefined } from "effect/Function"
17import * as JsonPatch from "effect/JsonPatch"
18import * as Path from "effect/Path"
19import * as Predicate from "effect/Predicate"
20import * as Schema from "effect/Schema"
21
22// =============================================================================
23// Error Types
24// =============================================================================
25
26/**
27 * Error thrown when parsing a JSON Patch input fails.
28 *
29 * **Details**
30 *
31 * This error occurs when:
32 * - A patch file cannot be read
33 * - JSON or YAML syntax is invalid
34 * - The file format is unsupported
35 *
36 * **Example** (Creating a parse error)
37 *
38 * ```ts import.meta.vitest
39 * import { JsonPatchParseError } from "@effect/openapi-generator/OpenApiPatch"
40 *
41 * const error = new JsonPatchParseError({
42 * source: "./patches/fix.json",
43 * reason: "Unexpected token at position 42"
44 * })
45 *
46 * error.message // => "Failed to parse patch from ./patches/fix.json: Unexpected token at position 42"
47 * ```
48 *
49 * @category errors
50 * @since 4.0.0
51 */
52export class JsonPatchParseError extends Schema.Error<JsonPatchParseError>("JsonPatchParseError")({
53 _tag: Schema.tag("JsonPatchParseError"),
54 source: Schema.String,
55 reason: Schema.String
56}) {
57 override get message() {
58 return `Failed to parse patch from ${this.source}: ${this.reason}`
59 }
60}
61
62/**
63 * Error thrown when a parsed value does not conform to the JSON Patch schema.
64 *
65 * **Details**
66 *
67 * This error occurs when:
68 * - The patch is not an array
69 * - An operation is missing required fields (op, path)
70 * - An operation has an unsupported op value
71 * - An add/replace operation is missing the value field
72 *
73 * **Example** (Creating a validation error)
74 *
75 * ```ts import.meta.vitest
76 * import { JsonPatchValidationError } from "@effect/openapi-generator/OpenApiPatch"
77 *
78 * const error = new JsonPatchValidationError({
79 * source: "inline",
80 * reason: "Expected 'add' | 'remove' | 'replace' at [0].op, got 'copy'"
81 * })
82 *
83 * error.message // => "Invalid JSON Patch from inline: Expected 'add' | 'remove' | 'replace' at [0].op, got 'copy'"
84 * ```
85 *
86 * @category errors
87 * @since 4.0.0
88 */
89export class JsonPatchValidationError extends Schema.Error<JsonPatchValidationError>("JsonPatchValidationError")({
90 _tag: Schema.tag("JsonPatchValidationError"),
91 source: Schema.String,
92 reason: Schema.String
93}) {
94 override get message() {
95 return `Invalid JSON Patch from ${this.source}: ${this.reason}`
96 }
97}
98
99/**
100 * Error thrown when applying a JSON Patch operation fails.
101 *
102 * **Details**
103 *
104 * This error occurs when:
105 * - A path does not exist for remove/replace operations
106 * - An array index is out of bounds
107 * - The target location is not a valid container
108 *
109 * **Example** (Creating an application error)
110 *
111 * ```ts import.meta.vitest
112 * import { JsonPatchApplicationError } from "@effect/openapi-generator/OpenApiPatch"
113 *
114 * const error = new JsonPatchApplicationError({
115 * source: "./patches/fix.json",
116 * operationIndex: 2,
117 * operation: "remove",
118 * path: "/paths/~1users",
119 * reason: "Property \"users\" does not exist"
120 * })
121 *
122 * error.message // => 'Failed to apply patch from ./patches/fix.json: operation 2 (remove at /paths/~1users): Property "users" does not exist'
123 * ```
124 *
125 * @category errors
126 * @since 4.0.0
127 */
128export class JsonPatchApplicationError extends Schema.Error<JsonPatchApplicationError>("JsonPatchApplicationError")({
129 _tag: Schema.tag("JsonPatchApplicationError"),
130 source: Schema.String,
131 operationIndex: Schema.Natural,
132 operation: Schema.String,
133 path: Schema.String,
134 reason: Schema.String
135}) {
136 override get message() {
137 return `Failed to apply patch from ${this.source}: operation ${this.operationIndex} ` +
138 `(${this.operation} at ${this.path}): ${this.reason}`
139 }
140}
141
142/**
143 * Error thrown when multiple JSON Patch operations fail.
144 *
145 * **Details**
146 *
147 * This error aggregates all application errors so users can see every
148 * failing operation at once instead of fixing them one at a time.
149 *
150 * **Example** (Creating an aggregate error)
151 *
152 * ```ts import.meta.vitest
153 * import { JsonPatchAggregateError, JsonPatchApplicationError } from "@effect/openapi-generator/OpenApiPatch"
154 *
155 * const error = new JsonPatchAggregateError({
156 * errors: [
157 * new JsonPatchApplicationError({
158 * source: "./fix.json",
159 * operationIndex: 0,
160 * operation: "replace",
161 * path: "/info/x",
162 * reason: "Property does not exist"
163 * }),
164 * new JsonPatchApplicationError({
165 * source: "./fix.json",
166 * operationIndex: 2,
167 * operation: "remove",
168 * path: "/paths/~1users",
169 * reason: "Property does not exist"
170 * })
171 * ]
172 * })
173 *
174 * error.message.split("\n")[0] // => "2 patch operations failed:"
175 * ```
176 *
177 * @category errors
178 * @since 4.0.0
179 */
180export class JsonPatchAggregateError extends Schema.Error<JsonPatchAggregateError>("JsonPatchAggregateError")({
181 _tag: Schema.tag("JsonPatchAggregateError"),
182 errors: Schema.Array(Schema.Unknown)
183}) {
184 override get message() {
185 const errors = this.errors as ReadonlyArray<JsonPatchApplicationError>
186 const count = errors.length
187 const plural = count === 1 ? "operation" : "operations"
188 const details = errors
189 .map((e, i) => ` ${i + 1}. [${e.source}] op ${e.operationIndex} (${e.operation} at ${e.path}): ${e.reason}`)
190 .join("\n")
191 return `${count} patch ${plural} failed:\n${details}`
192 }
193}
194
195// =============================================================================
196// Schema
197// =============================================================================
198
199/**
200 * Schema for a JSON Patch "add" operation.
201 *
202 * @category schemas
203 * @since 4.0.0
204 */
205export const JsonPatchAdd: Schema.Codec<
206 Extract<
207 JsonPatch.JsonPatchOperation,
208 { op: "add" }
209 >
210> = Schema.Struct({
211 op: Schema.Literal("add"),
212 path: Schema.String,
213 value: Schema.Json,
214 description: Schema.optionalKey(Schema.String)
215})
216
217/**
218 * Schema for a JSON Patch "remove" operation.
219 *
220 * @category schemas
221 * @since 4.0.0
222 */
223export const JsonPatchRemove: Schema.Codec<
224 Extract<
225 JsonPatch.JsonPatchOperation,
226 { op: "remove" }
227 >
228> = Schema.Struct({
229 op: Schema.Literal("remove"),
230 path: Schema.String,
231 description: Schema.optionalKey(Schema.String)
232})
233
234/**
235 * Schema for a JSON Patch "replace" operation.
236 *
237 * @category schemas
238 * @since 4.0.0
239 */
240export const JsonPatchReplace: Schema.Codec<
241 Extract<
242 JsonPatch.JsonPatchOperation,
243 { op: "replace" }
244 >
245> = Schema.Struct({
246 op: Schema.Literal("replace"),
247 path: Schema.String,
248 value: Schema.Json,
249 description: Schema.optionalKey(Schema.String)
250})
251
252/**
253 * Schema for a single JSON Patch operation.
254 *
255 * **Details**
256 *
257 * Supports the subset of RFC 6902 operations that Effect's JsonPatch module
258 * implements: `add`, `remove`, and `replace`.
259 *
260 * @category schemas
261 * @since 4.0.0
262 */
263export const JsonPatchOperation: Schema.Codec<JsonPatch.JsonPatchOperation> = Schema.Union([
264 JsonPatchAdd,
265 JsonPatchRemove,
266 JsonPatchReplace
267])
268
269/**
270 * Schema for a JSON Patch document (array of operations).
271 *
272 * **Details**
273 *
274 * A JSON Patch document is an ordered list of operations to apply to a JSON
275 * document. Operations are applied in sequence, with each operation seeing
276 * the result of previous operations.
277 *
278 * **Example** (Decoding a patch document)
279 *
280 * ```ts import.meta.vitest
281 * import { Schema } from "effect"
282 * import { JsonPatchDocument } from "@effect/openapi-generator/OpenApiPatch"
283 *
284 * const patch = Schema.decodeUnknownSync(JsonPatchDocument)([
285 * { op: "add", path: "/foo", value: "bar" },
286 * { op: "remove", path: "/baz" },
287 * { op: "replace", path: "/qux", value: 42 }
288 * ])
289 *
290 * patch.map((operation) => operation.op) // => ["add", "remove", "replace"]
291 * ```
292 *
293 * @category schemas
294 * @since 4.0.0
295 */
296export const JsonPatchDocument = Schema.Array(JsonPatchOperation)
297
298/**
299 * Type for a JSON Patch document.
300 *
301 * @category models
302 * @since 4.0.0
303 */
304export type JsonPatchDocument = typeof JsonPatchDocument.Type
305
306// =============================================================================
307// Parsing Functions
308// =============================================================================
309
310const decodeJsonPatchDocument = Schema.decodeUnknownEffect(JsonPatchDocument)
311
312/**
313 * Check if a string looks like it could be a file path.
314 *
315 * Heuristic: contains path separators or ends with a known extension.
316 */
317const looksLikeFilePath = (input: string): boolean => {
318 const trimmed = input.trim()
319 if (trimmed.startsWith("[")) return false
320 if (trimmed.includes("/") || trimmed.includes("\\")) return true
321 if (/\.(json|yaml|yml)$/i.test(trimmed)) return true
322 return false
323}
324
325/**
326 * Determine file format from extension.
327 */
328const getFileFormat = Effect.fn(function*(filePath: string) {
329 const path = yield* Path.Path
330 const { ext } = path.parse(filePath)
331 if (ext === ".json") return "json"
332 if (ext === ".yaml" || ext === ".yml") return "yaml"
333 return undefined
334})
335
336/**
337 * Check if a file path exists and is a file.
338 */
339const checkFileExists = Effect.fn("checkFileExists")(function*(filePath: string) {
340 const fs = yield* FileSystem.FileSystem
341 const path = yield* Path.Path
342 const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath)
343 const exists = yield* Effect.orElseSucceed(fs.exists(absolutePath), constFalse)
344 if (!exists) return false
345 const stat = yield* Effect.orElseSucceed(fs.stat(absolutePath), constUndefined)
346 return Predicate.isNotUndefined(stat) && stat.type === "File"
347})
348
349/**
350 * Parse content as JSON.
351 */
352const parseJsonContent = Effect.fnUntraced(function*(content: string, source: string) {
353 return yield* Effect.try({
354 try: () => JSON.parse(content) as unknown,
355 catch: (error) =>
356 new JsonPatchParseError({
357 source,
358 reason: error instanceof Error ? error.message : String(error)
359 })
360 })
361})
362
363/**
364 * Parse content as YAML.
365 */
366const parseYamlContent = Effect.fnUntraced(function*(content: string, source: string) {
367 return yield* Effect.try({
368 try: () => Yaml.parse(content) as unknown,
369 catch: (error) =>
370 new JsonPatchParseError({
371 source,
372 reason: error instanceof Error ? error.message : String(error)
373 })
374 })
375})
376
377/**
378 * Read and parse a patch file.
379 */
380const parsePatchFile = Effect.fn("parsePatchFile")(function*(filePath: string) {
381 const fs = yield* FileSystem.FileSystem
382 const path = yield* Path.Path
383 const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath)
384
385 const fileFormat = yield* getFileFormat(filePath)
386 if (Predicate.isUndefined(fileFormat)) {
387 return yield* new JsonPatchParseError({
388 source: filePath,
389 reason: `Unsupported file format. Expected .json, .yaml, or .yml`
390 })
391 }
392
393 const content = yield* Effect.mapError(fs.readFileString(absolutePath), (error) =>
394 new JsonPatchParseError({
395 source: filePath,
396 reason: `Failed to read file: ${error.message}`
397 }))
398
399 const parsed = fileFormat === "json"
400 ? yield* parseJsonContent(content, filePath)
401 : yield* parseYamlContent(content, filePath)
402
403 return yield* Effect.mapError(decodeJsonPatchDocument(parsed), (error) =>
404 new JsonPatchValidationError({
405 source: filePath,
406 reason: error.message
407 }))
408})
409
410/**
411 * Parse inline JSON string as a patch document.
412 */
413const parseInlinePatch = Effect.fn("parseInlinePatch")(function*(input: string) {
414 const parsed = yield* parseJsonContent(input, "inline")
415 return yield* Effect.mapError(decodeJsonPatchDocument(parsed), (error) =>
416 new JsonPatchValidationError({
417 source: "inline",
418 reason: error.message
419 }))
420})
421
422/**
423 * Parse a JSON Patch from either a file path or inline JSON string.
424 *
425 * **Details**
426 *
427 * The input is first checked as a file path. If the file exists, it is read
428 * and parsed based on its extension (.json, .yaml, .yml). Otherwise, the
429 * input is parsed as inline JSON.
430 *
431 * **Example** (Parsing patch input)
432 *
433 * ```ts import.meta.vitest
434 * import { Effect } from "effect"
435 * import { parsePatchInput } from "@effect/openapi-generator/OpenApiPatch"
436 *
437 * // From inline JSON
438 * const fromInline = parsePatchInput(
439 * '[{"op":"replace","path":"/info/title","value":"My API"}]'
440 * )
441 *
442 * const program = Effect.gen(function*() {
443 * const patch = yield* fromInline
444 * return [patch[0].op, patch[0].path]
445 * })
446 *
447 * Effect.runSync(program) // => ["replace", "/info/title"]
448 * ```
449 *
450 * @category parsing
451 * @since 4.0.0
452 */
453export const parsePatchInput = Effect.fn("parsePatchInput")(function*(input: string) {
454 if (looksLikeFilePath(input)) {
455 const exists = yield* checkFileExists(input)
456 if (exists) {
457 return yield* parsePatchFile(input)
458 }
459 }
460 return yield* parseInlinePatch(input)
461})
462
463// =============================================================================
464// Application Functions
465// =============================================================================
466
467/**
468 * Apply a sequence of JSON patches to a document.
469 *
470 * **Details**
471 *
472 * Patches are applied in order, with each patch operating on the result of
473 * the previous one. All operations are attempted, and if any fail, the errors
474 * are accumulated and reported together so users can fix all issues at once.
475 *
476 * **Example** (Applying patches)
477 *
478 * ```ts import.meta.vitest
479 * import { Effect } from "effect"
480 * import { applyPatches } from "@effect/openapi-generator/OpenApiPatch"
481 *
482 * const document = { info: { title: "Old Title" }, paths: {} }
483 * const patches = [
484 * {
485 * source: "inline",
486 * patch: [{ op: "replace" as const, path: "/info/title", value: "New Title" }]
487 * }
488 * ]
489 *
490 * const program = Effect.gen(function*() {
491 * const result = yield* applyPatches(patches, document)
492 * return (result as typeof document).info.title
493 * })
494 *
495 * Effect.runSync(program) // => "New Title"
496 * ```
497 *
498 * @category transforming
499 * @since 4.0.0
500 */
501export const applyPatches = Effect.fn("applyPatches")(function*(
502 patches: ReadonlyArray<{ readonly source: string; readonly patch: JsonPatchDocument }>,
503 document: Schema.Json
504) {
505 let result: Schema.Json = document
506 const errors: Array<JsonPatchApplicationError> = []
507
508 for (const { source, patch } of patches) {
509 for (let i = 0; i < patch.length; i++) {
510 const op = patch[i]
511 yield* Effect.ignore(Effect.try({
512 try: () => {
513 result = JsonPatch.apply([op], result)
514 },
515 catch: (error) =>
516 errors.push(
517 new JsonPatchApplicationError({
518 source,
519 operationIndex: i,
520 operation: op.op,
521 path: op.path,
522 reason: error instanceof Error ? error.message : String(error)
523 })
524 )
525 }))
526 }
527 }
528
529 if (errors.length > 0) {
530 return yield* new JsonPatchAggregateError({ errors })
531 }
532
533 return result
534})