@erenthedeveloper0/zen-openapi 0.1.0-alpha.1

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.
package/src/diff.ts ADDED
@@ -0,0 +1,439 @@
1
+ import type { JsonSchemaNode, JsonType } from '@erenthedeveloper0/zen-core'
2
+ import type {
3
+ HttpOperation, OpenApiDocument, OpenApiSchema, OperationObject, ParameterObject, ResponseObject,
4
+ } from './types.ts'
5
+
6
+ /**
7
+ * API change detection — rfcs/0001 §29.5.
8
+ *
9
+ * A governance feature disguised as tooling. The point is not the diff; it is
10
+ * that an API change stops being "a diff in a routes file" and becomes a
11
+ * reviewable statement about compatibility, on the pull request, before it
12
+ * ships.
13
+ *
14
+ * Two asymmetries drive every rule here, and they are the whole model:
15
+ *
16
+ * - **Requests are contravariant.** Accepting *less* breaks callers. Removing
17
+ * a field, adding a required one, or narrowing a type are all breaking.
18
+ * - **Responses are covariant.** Returning *less* breaks callers. Removing a
19
+ * field, dropping a status, or widening a type (a value the client's
20
+ * exhaustive switch has never seen) are all breaking.
21
+ *
22
+ * The classification is deliberately conservative: when a change could break a
23
+ * reasonable consumer, it is reported as breaking. A tool that under-reports is
24
+ * worse than no tool, because it is trusted.
25
+ */
26
+
27
+ export type ChangeKind = 'breaking' | 'compatible' | 'documentation'
28
+
29
+ export interface ApiChange {
30
+ readonly kind: ChangeKind
31
+ readonly code: string
32
+ readonly message: string
33
+ /** `GET /users/{id} → response 200 → users[].email` */
34
+ readonly location: string
35
+ }
36
+
37
+ export interface DiffResult {
38
+ readonly changes: readonly ApiChange[]
39
+ readonly breaking: readonly ApiChange[]
40
+ readonly compatible: readonly ApiChange[]
41
+ readonly documentation: readonly ApiChange[]
42
+ }
43
+
44
+ const METHODS: readonly HttpOperation[] = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']
45
+
46
+ export function diffDocuments(before: OpenApiDocument, after: OpenApiDocument): DiffResult {
47
+ const changes: ApiChange[] = []
48
+ const context = { before: new Resolver(before), after: new Resolver(after), changes }
49
+
50
+ const paths = new Set([...Object.keys(before.paths), ...Object.keys(after.paths)])
51
+ for (const path of [...paths].sort()) {
52
+ const beforeItem = before.paths[path]
53
+ const afterItem = after.paths[path]
54
+
55
+ if (beforeItem !== undefined && afterItem === undefined) {
56
+ changes.push(breaking('OAS_PATH_REMOVED', `Path ${path} was removed.`, path))
57
+ continue
58
+ }
59
+ if (beforeItem === undefined && afterItem !== undefined) {
60
+ changes.push(compatible('OAS_PATH_ADDED', `Path ${path} was added.`, path))
61
+ continue
62
+ }
63
+ if (beforeItem === undefined || afterItem === undefined) continue
64
+
65
+ for (const method of METHODS) {
66
+ const from = beforeItem[method]
67
+ const to = afterItem[method]
68
+ const location = `${method.toUpperCase()} ${path}`
69
+ if (from !== undefined && to === undefined) {
70
+ changes.push(breaking('OAS_OPERATION_REMOVED', `${location} was removed.`, location))
71
+ } else if (from === undefined && to !== undefined) {
72
+ changes.push(compatible('OAS_OPERATION_ADDED', `${location} was added.`, location))
73
+ } else if (from !== undefined && to !== undefined) {
74
+ diffOperation(from, to, location, context)
75
+ }
76
+ }
77
+ }
78
+
79
+ return {
80
+ changes,
81
+ breaking: changes.filter((c) => c.kind === 'breaking'),
82
+ compatible: changes.filter((c) => c.kind === 'compatible'),
83
+ documentation: changes.filter((c) => c.kind === 'documentation'),
84
+ }
85
+ }
86
+
87
+ interface Context {
88
+ readonly before: Resolver
89
+ readonly after: Resolver
90
+ readonly changes: ApiChange[]
91
+ }
92
+
93
+ function diffOperation(from: OperationObject, to: OperationObject, where: string, ctx: Context): void {
94
+ if (from.operationId !== to.operationId) {
95
+ // Generated clients name their methods from this. Renaming it is a source
96
+ // break in every SDK even when the wire format is untouched.
97
+ ctx.changes.push(breaking(
98
+ 'OAS_OPERATION_ID_CHANGED',
99
+ `operationId changed from "${from.operationId}" to "${to.operationId}".`,
100
+ where,
101
+ ))
102
+ }
103
+ if (from.summary !== to.summary || from.description !== to.description) {
104
+ ctx.changes.push(documentation('OAS_DESCRIPTION_CHANGED', 'Summary or description changed.', where))
105
+ }
106
+ if (from.deprecated !== true && to.deprecated === true) {
107
+ ctx.changes.push(documentation('OAS_OPERATION_DEPRECATED', 'Operation was marked deprecated.', where))
108
+ }
109
+ if ((from.security?.length ?? 0) === 0 && (to.security?.length ?? 0) > 0) {
110
+ ctx.changes.push(breaking('OAS_SECURITY_ADDED', 'Operation now requires authentication.', where))
111
+ }
112
+
113
+ diffParameters(from.parameters ?? [], to.parameters ?? [], where, ctx)
114
+ diffRequestBody(from, to, where, ctx)
115
+ diffResponses(from.responses, to.responses, where, ctx)
116
+ }
117
+
118
+ function diffParameters(
119
+ from: readonly ParameterObject[],
120
+ to: readonly ParameterObject[],
121
+ where: string,
122
+ ctx: Context,
123
+ ): void {
124
+ const key = (p: ParameterObject): string => `${p.in}:${p.name}`
125
+ const beforeMap = new Map(from.map((p) => [key(p), p]))
126
+ const afterMap = new Map(to.map((p) => [key(p), p]))
127
+
128
+ for (const [id, parameter] of beforeMap) {
129
+ const next = afterMap.get(id)
130
+ const at = `${where} → ${id}`
131
+ if (next === undefined) {
132
+ ctx.changes.push(breaking('OAS_PARAM_REMOVED', `Parameter ${id} was removed.`, at))
133
+ continue
134
+ }
135
+ if (parameter.required !== true && next.required === true) {
136
+ ctx.changes.push(breaking('OAS_PARAM_NOW_REQUIRED', `Parameter ${id} became required.`, at))
137
+ } else if (parameter.required === true && next.required !== true) {
138
+ ctx.changes.push(compatible('OAS_PARAM_NOW_OPTIONAL', `Parameter ${id} became optional.`, at))
139
+ }
140
+ diffSchema(parameter.schema, next.schema, 'request', at, ctx, new Set())
141
+ }
142
+ for (const [id, parameter] of afterMap) {
143
+ if (beforeMap.has(id)) continue
144
+ const at = `${where} → ${id}`
145
+ ctx.changes.push(parameter.required === true
146
+ ? breaking('OAS_PARAM_ADDED_REQUIRED', `Required parameter ${id} was added.`, at)
147
+ : compatible('OAS_PARAM_ADDED', `Optional parameter ${id} was added.`, at))
148
+ }
149
+ }
150
+
151
+ function diffRequestBody(from: OperationObject, to: OperationObject, where: string, ctx: Context): void {
152
+ const beforeBody = from.requestBody
153
+ const afterBody = to.requestBody
154
+ if (beforeBody === undefined && afterBody === undefined) return
155
+ if (beforeBody === undefined && afterBody !== undefined) {
156
+ ctx.changes.push(afterBody.required === false
157
+ ? compatible('OAS_BODY_ADDED', 'An optional request body was added.', where)
158
+ : breaking('OAS_BODY_ADDED_REQUIRED', 'A required request body was added.', where))
159
+ return
160
+ }
161
+ if (beforeBody !== undefined && afterBody === undefined) {
162
+ ctx.changes.push(compatible('OAS_BODY_REMOVED', 'The request body is no longer read.', where))
163
+ return
164
+ }
165
+ if (beforeBody === undefined || afterBody === undefined) return
166
+
167
+ const media = new Set([...Object.keys(beforeBody.content), ...Object.keys(afterBody.content)])
168
+ for (const type of [...media].sort()) {
169
+ const beforeMedia = beforeBody.content[type]
170
+ const afterMedia = afterBody.content[type]
171
+ const at = `${where} → body (${type})`
172
+ if (beforeMedia === undefined) {
173
+ ctx.changes.push(compatible('OAS_BODY_MEDIA_ADDED', `Media type ${type} is now accepted.`, at))
174
+ continue
175
+ }
176
+ if (afterMedia === undefined) {
177
+ ctx.changes.push(breaking('OAS_BODY_MEDIA_REMOVED', `Media type ${type} is no longer accepted.`, at))
178
+ continue
179
+ }
180
+ diffSchema(beforeMedia.schema, afterMedia.schema, 'request', at, ctx, new Set())
181
+ }
182
+ }
183
+
184
+ function diffResponses(
185
+ from: Readonly<Record<string, ResponseObject>>,
186
+ to: Readonly<Record<string, ResponseObject>>,
187
+ where: string,
188
+ ctx: Context,
189
+ ): void {
190
+ const statuses = new Set([...Object.keys(from), ...Object.keys(to)])
191
+ for (const status of [...statuses].sort()) {
192
+ const beforeResponse = from[status]
193
+ const afterResponse = to[status]
194
+ const at = `${where} → response ${status}`
195
+
196
+ if (beforeResponse !== undefined && afterResponse === undefined) {
197
+ ctx.changes.push(breaking('OAS_STATUS_REMOVED', `Status ${status} is no longer returned.`, at))
198
+ continue
199
+ }
200
+ if (beforeResponse === undefined && afterResponse !== undefined) {
201
+ ctx.changes.push(compatible('OAS_STATUS_ADDED', `Status ${status} was added.`, at))
202
+ continue
203
+ }
204
+ if (beforeResponse === undefined || afterResponse === undefined) continue
205
+
206
+ const beforeContent = beforeResponse.content ?? {}
207
+ const afterContent = afterResponse.content ?? {}
208
+ const media = new Set([...Object.keys(beforeContent), ...Object.keys(afterContent)])
209
+ for (const type of [...media].sort()) {
210
+ const beforeMedia = beforeContent[type]
211
+ const afterMedia = afterContent[type]
212
+ const mediaAt = `${at} (${type})`
213
+ if (beforeMedia === undefined) {
214
+ ctx.changes.push(compatible('OAS_RESPONSE_MEDIA_ADDED', `Media type ${type} was added.`, mediaAt))
215
+ continue
216
+ }
217
+ if (afterMedia === undefined) {
218
+ ctx.changes.push(breaking('OAS_RESPONSE_MEDIA_REMOVED', `Media type ${type} is no longer returned.`, mediaAt))
219
+ continue
220
+ }
221
+ diffSchema(beforeMedia.schema, afterMedia.schema, 'response', mediaAt, ctx, new Set())
222
+ }
223
+ }
224
+ }
225
+
226
+ // ─────────────────────────────────────────────────────────────────────────────
227
+
228
+ type Direction = 'request' | 'response'
229
+
230
+ function diffSchema(
231
+ rawBefore: OpenApiSchema,
232
+ rawAfter: OpenApiSchema,
233
+ direction: Direction,
234
+ where: string,
235
+ ctx: Context,
236
+ seen: Set<string>,
237
+ ): void {
238
+ const guard = `${rawBefore.$ref ?? ''}|${rawAfter.$ref ?? ''}|${where}`
239
+ if (rawBefore.$ref !== undefined || rawAfter.$ref !== undefined) {
240
+ if (seen.has(guard)) return
241
+ seen.add(guard)
242
+ }
243
+
244
+ const before = ctx.before.resolve(rawBefore)
245
+ const after = ctx.after.resolve(rawAfter)
246
+
247
+ diffTypes(before, after, direction, where, ctx)
248
+ diffEnum(before, after, direction, where, ctx)
249
+
250
+ if (before.format !== after.format) {
251
+ ctx.changes.push(breaking(
252
+ 'OAS_FORMAT_CHANGED',
253
+ `Format changed from ${before.format ?? 'none'} to ${after.format ?? 'none'}.`,
254
+ where,
255
+ ))
256
+ }
257
+
258
+ diffProperties(before, after, direction, where, ctx, seen)
259
+
260
+ const beforeItems = typeof before.items === 'object' ? before.items : undefined
261
+ const afterItems = typeof after.items === 'object' ? after.items : undefined
262
+ if (beforeItems !== undefined && afterItems !== undefined) {
263
+ diffSchema(beforeItems, afterItems, direction, `${where}[]`, ctx, seen)
264
+ }
265
+
266
+ if (direction === 'request' && before.additionalProperties !== false && after.additionalProperties === false) {
267
+ ctx.changes.push(breaking(
268
+ 'OAS_ADDITIONAL_PROPERTIES_CLOSED',
269
+ 'Extra properties are no longer accepted.',
270
+ where,
271
+ ))
272
+ }
273
+ }
274
+
275
+ function diffProperties(
276
+ before: OpenApiSchema,
277
+ after: OpenApiSchema,
278
+ direction: Direction,
279
+ where: string,
280
+ ctx: Context,
281
+ seen: Set<string>,
282
+ ): void {
283
+ const beforeProps = before.properties
284
+ const afterProps = after.properties
285
+ if (beforeProps === undefined && afterProps === undefined) return
286
+
287
+ const beforeRequired = new Set(before.required ?? [])
288
+ const afterRequired = new Set(after.required ?? [])
289
+ const names = new Set([...Object.keys(beforeProps ?? {}), ...Object.keys(afterProps ?? {})])
290
+
291
+ for (const name of [...names].sort()) {
292
+ const from = asSchema(beforeProps?.[name])
293
+ const to = asSchema(afterProps?.[name])
294
+ const at = `${where}.${name}`
295
+
296
+ if (from !== undefined && to === undefined) {
297
+ ctx.changes.push(direction === 'response'
298
+ ? breaking('OAS_RESPONSE_FIELD_REMOVED', `Response field "${name}" was removed.`, at)
299
+ : breaking('OAS_REQUEST_FIELD_REMOVED', `Request field "${name}" is no longer accepted.`, at))
300
+ continue
301
+ }
302
+ if (from === undefined && to !== undefined) {
303
+ if (direction === 'response') {
304
+ ctx.changes.push(compatible('OAS_RESPONSE_FIELD_ADDED', `Response field "${name}" was added.`, at))
305
+ } else {
306
+ ctx.changes.push(afterRequired.has(name)
307
+ ? breaking('OAS_REQUEST_FIELD_ADDED_REQUIRED', `Required request field "${name}" was added.`, at)
308
+ : compatible('OAS_REQUEST_FIELD_ADDED', `Optional request field "${name}" was added.`, at))
309
+ }
310
+ continue
311
+ }
312
+ if (from === undefined || to === undefined) continue
313
+
314
+ const wasRequired = beforeRequired.has(name)
315
+ const isRequired = afterRequired.has(name)
316
+ if (!wasRequired && isRequired) {
317
+ ctx.changes.push(direction === 'request'
318
+ ? breaking('OAS_REQUEST_FIELD_NOW_REQUIRED', `Request field "${name}" became required.`, at)
319
+ : compatible('OAS_RESPONSE_FIELD_NOW_GUARANTEED', `Response field "${name}" is now always present.`, at))
320
+ } else if (wasRequired && !isRequired) {
321
+ ctx.changes.push(direction === 'response'
322
+ ? breaking('OAS_RESPONSE_FIELD_NOW_OPTIONAL', `Response field "${name}" may now be absent.`, at)
323
+ : compatible('OAS_REQUEST_FIELD_NOW_OPTIONAL', `Request field "${name}" became optional.`, at))
324
+ }
325
+
326
+ diffSchema(from, to, direction, at, ctx, seen)
327
+ }
328
+ }
329
+
330
+ function diffTypes(
331
+ before: OpenApiSchema,
332
+ after: OpenApiSchema,
333
+ direction: Direction,
334
+ where: string,
335
+ ctx: Context,
336
+ ): void {
337
+ const from = typeSet(before)
338
+ const to = typeSet(after)
339
+ if (from.size === 0 && to.size === 0) return
340
+
341
+ const added = [...to].filter((t) => !from.has(t))
342
+ const removed = [...from].filter((t) => !to.has(t))
343
+
344
+ // Widening a response is breaking (a client that exhaustively handles the old
345
+ // types meets one it has never seen); widening a request is a relaxation.
346
+ for (const type of added) {
347
+ ctx.changes.push(direction === 'response'
348
+ ? breaking('OAS_TYPE_WIDENED', `Response may now be "${type}".`, where)
349
+ : compatible('OAS_TYPE_WIDENED', `Request now also accepts "${type}".`, where))
350
+ }
351
+ for (const type of removed) {
352
+ ctx.changes.push(direction === 'request'
353
+ ? breaking('OAS_TYPE_NARROWED', `Request no longer accepts "${type}".`, where)
354
+ : compatible('OAS_TYPE_NARROWED', `Response is no longer "${type}".`, where))
355
+ }
356
+ }
357
+
358
+ function diffEnum(
359
+ before: OpenApiSchema,
360
+ after: OpenApiSchema,
361
+ direction: Direction,
362
+ where: string,
363
+ ctx: Context,
364
+ ): void {
365
+ const from = before.enum
366
+ const to = after.enum
367
+ if (from === undefined && to === undefined) return
368
+ if (from === undefined || to === undefined) {
369
+ ctx.changes.push(from === undefined
370
+ ? (direction === 'request'
371
+ ? breaking('OAS_ENUM_INTRODUCED', 'The accepted values are now restricted to an enum.', where)
372
+ : compatible('OAS_ENUM_INTRODUCED', 'The returned values are now restricted to an enum.', where))
373
+ : (direction === 'response'
374
+ ? breaking('OAS_ENUM_REMOVED', 'The value is no longer restricted to a known set.', where)
375
+ : compatible('OAS_ENUM_REMOVED', 'Any value is now accepted.', where)))
376
+ return
377
+ }
378
+
379
+ const beforeValues = new Set(from.map((v) => JSON.stringify(v)))
380
+ const afterValues = new Set(to.map((v) => JSON.stringify(v)))
381
+ for (const value of afterValues) {
382
+ if (beforeValues.has(value)) continue
383
+ ctx.changes.push(direction === 'response'
384
+ ? breaking('OAS_ENUM_VALUE_ADDED', `Response may now be ${value}.`, where)
385
+ : compatible('OAS_ENUM_VALUE_ADDED', `Request now also accepts ${value}.`, where))
386
+ }
387
+ for (const value of beforeValues) {
388
+ if (afterValues.has(value)) continue
389
+ ctx.changes.push(direction === 'request'
390
+ ? breaking('OAS_ENUM_VALUE_REMOVED', `Request no longer accepts ${value}.`, where)
391
+ : compatible('OAS_ENUM_VALUE_REMOVED', `Response is no longer ${value}.`, where))
392
+ }
393
+ }
394
+
395
+ /** `true`/`false` are legal schemas; treat them as "no constraints stated". */
396
+ function asSchema(node: JsonSchemaNode | undefined): OpenApiSchema | undefined {
397
+ if (node === undefined) return undefined
398
+ return typeof node === 'boolean' ? (node ? {} : { not: {} }) : node
399
+ }
400
+
401
+ function typeSet(schema: OpenApiSchema): Set<JsonType> {
402
+ const declared = schema.type
403
+ if (declared === undefined) return new Set()
404
+ return new Set(Array.isArray(declared) ? declared : [declared])
405
+ }
406
+
407
+ /** Follows `#/components/schemas/*` so the diff compares shapes, not pointers. */
408
+ class Resolver {
409
+ readonly #schemas: Readonly<Record<string, OpenApiSchema>>
410
+
411
+ constructor(document: OpenApiDocument) {
412
+ this.#schemas = document.components?.schemas ?? {}
413
+ }
414
+
415
+ resolve(schema: OpenApiSchema): OpenApiSchema {
416
+ let current = schema
417
+ for (let hops = 0; hops < 16; hops++) {
418
+ const ref = current.$ref
419
+ if (typeof ref !== 'string') return current
420
+ const name = ref.startsWith('#/components/schemas/') ? ref.slice('#/components/schemas/'.length) : null
421
+ const target = name === null ? undefined : this.#schemas[name]
422
+ if (target === undefined) return {}
423
+ current = target
424
+ }
425
+ return {}
426
+ }
427
+ }
428
+
429
+ function breaking(code: string, message: string, location: string): ApiChange {
430
+ return { kind: 'breaking', code, message, location }
431
+ }
432
+
433
+ function compatible(code: string, message: string, location: string): ApiChange {
434
+ return { kind: 'compatible', code, message, location }
435
+ }
436
+
437
+ function documentation(code: string, message: string, location: string): ApiChange {
438
+ return { kind: 'documentation', code, message, location }
439
+ }