@effect/openapi-generator 4.0.0-beta.0

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