@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/LICENSE +9 -0
- package/README.md +76 -0
- package/dist/diff.d.ts +37 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +320 -0
- package/dist/diff.js.map +1 -0
- package/dist/document.d.ts +75 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +689 -0
- package/dist/document.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.d.ts +29 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +84 -0
- package/dist/plugin.js.map +1 -0
- package/dist/schema.d.ts +103 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +434 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +154 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/ui.d.ts +19 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +192 -0
- package/dist/ui.js.map +1 -0
- package/package.json +65 -0
- package/src/diff.ts +439 -0
- package/src/document.ts +882 -0
- package/src/index.ts +20 -0
- package/src/plugin.ts +135 -0
- package/src/schema.ts +497 -0
- package/src/types.ts +166 -0
- package/src/ui.ts +198 -0
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
|
+
}
|