@nifrajs/schema 3.3.0 → 4.0.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.
- package/README.md +47 -0
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +14 -2
- package/dist/adapter.js.map +1 -1
- package/dist/file-kind.d.ts +27 -0
- package/dist/file-kind.d.ts.map +1 -0
- package/dist/file-kind.js +35 -0
- package/dist/file-kind.js.map +1 -0
- package/dist/form.d.ts +119 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +301 -0
- package/dist/form.js.map +1 -0
- package/dist/formats.d.ts.map +1 -1
- package/dist/formats.js +3 -1
- package/dist/formats.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/openapi-import.d.ts +73 -0
- package/dist/openapi-import.d.ts.map +1 -0
- package/dist/openapi-import.js +302 -0
- package/dist/openapi-import.js.map +1 -0
- package/dist/openapi.d.ts +9 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +147 -62
- package/dist/openapi.js.map +1 -1
- package/dist/t.d.ts +31 -4
- package/dist/t.d.ts.map +1 -1
- package/dist/t.js +86 -18
- package/dist/t.js.map +1 -1
- package/package.json +11 -2
- package/src/adapter.ts +17 -2
- package/src/file-kind.ts +48 -0
- package/src/form.ts +394 -0
- package/src/formats.ts +3 -1
- package/src/index.ts +17 -2
- package/src/openapi-import.ts +410 -0
- package/src/openapi.ts +186 -69
- package/src/t.ts +119 -19
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI 3.x inventory import - the structural half of an export/import roundtrip.
|
|
3
|
+
*
|
|
4
|
+
* `toOpenAPI` renders a Nifra app (or contract) as an OpenAPI 3.1 document. This module reads such
|
|
5
|
+
* a document back into a canonical route inventory: method, Nifra-templated path (`{id}` back to
|
|
6
|
+
* `:id`), required query names, body presence, and response statuses. `diffOpenApiInventory` then
|
|
7
|
+
* compares two inventories, so `export -> import -> diff` proves the cycle survives in CI.
|
|
8
|
+
*
|
|
9
|
+
* Deliberate boundaries, all fail-closed or explicitly documented:
|
|
10
|
+
*
|
|
11
|
+
* - Offline and ref-opaque. `$ref`s are never resolved and nothing is fetched. A `$ref` parameter
|
|
12
|
+
* cannot yield a name without resolution, so it throws with the location; `$ref` bodies and
|
|
13
|
+
* response schemas only assert presence, which needs no resolution.
|
|
14
|
+
* - No schema reconstruction. JSON Schema is not turned back into `t` builders here - that is a
|
|
15
|
+
* separate codegen step needing consumer evidence. The inventory proves the route table survived;
|
|
16
|
+
* field-level types ride along in the source document, not in this comparison.
|
|
17
|
+
* - No auth interpretation. `security`, `securitySchemes`, and `servers` are never read. Importing
|
|
18
|
+
* an API must not silently adopt its auth model.
|
|
19
|
+
* - Templated paths degrade conservatively. `/files/*path` exports as `/files/{path}`, indistinguishable
|
|
20
|
+
* from a single-segment `:path`. Import yields `:path` and records a note for every OpenAPI template;
|
|
21
|
+
* a greedy remainder needs a manual `*path` rewrite.
|
|
22
|
+
* - Nifra parameter names follow `/^[A-Za-z_][A-Za-z0-9_]*$/`; `__proto__`, `constructor`, and
|
|
23
|
+
* `prototype` are rejected. Mixed templates are converted only when their boundaries are representable.
|
|
24
|
+
* - Bounded. Path/operation/parameter counts are capped (`ImportOpenAPIOptions`); a document over
|
|
25
|
+
* the caps throws rather than allocating.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export class OpenAPIImportError extends Error {
|
|
29
|
+
constructor(message: string) {
|
|
30
|
+
super(`openapi import: ${message}`)
|
|
31
|
+
this.name = "OpenAPIImportError"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ImportOpenAPIOptions {
|
|
36
|
+
/** Maximum entries under `paths`. Default 5000. */
|
|
37
|
+
readonly maxPaths?: number
|
|
38
|
+
/** Maximum operations on one path item. Default 8. */
|
|
39
|
+
readonly maxOperationsPerPath?: number
|
|
40
|
+
/** Maximum merged parameters per operation. Default 100. */
|
|
41
|
+
readonly maxParameters?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ImportedRouteQuery {
|
|
45
|
+
readonly name: string
|
|
46
|
+
readonly required: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ImportedRoute {
|
|
50
|
+
/** Lowercase HTTP method as it appears in the document. */
|
|
51
|
+
readonly method: string
|
|
52
|
+
/** Nifra-templated path (`/users/:id`). */
|
|
53
|
+
readonly path: string
|
|
54
|
+
readonly operationId?: string
|
|
55
|
+
/** Template parameter names in path order. */
|
|
56
|
+
readonly pathParams: readonly string[]
|
|
57
|
+
readonly query: readonly ImportedRouteQuery[]
|
|
58
|
+
readonly hasBody: boolean
|
|
59
|
+
readonly requestBodyRequired: boolean
|
|
60
|
+
/** Response status keys (`"200"`, `"404"`, `"default"`). */
|
|
61
|
+
readonly responseStatuses: readonly string[]
|
|
62
|
+
/** Per-route degradations (wildcard rewrites). */
|
|
63
|
+
readonly notes: readonly string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ImportedApiInventory {
|
|
67
|
+
readonly title: string
|
|
68
|
+
readonly version: string
|
|
69
|
+
readonly routes: readonly ImportedRoute[]
|
|
70
|
+
readonly warnings: readonly string[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface OpenApiInventoryDrift {
|
|
74
|
+
/** `METHOD /path`, or `inventory` for route-set level drift. */
|
|
75
|
+
readonly route: string
|
|
76
|
+
readonly message: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const SUPPORTED_METHODS: ReadonlySet<string> = new Set([
|
|
80
|
+
"get",
|
|
81
|
+
"put",
|
|
82
|
+
"post",
|
|
83
|
+
"delete",
|
|
84
|
+
"patch",
|
|
85
|
+
"head",
|
|
86
|
+
"options",
|
|
87
|
+
"trace",
|
|
88
|
+
])
|
|
89
|
+
const PATH_ITEM_METADATA = new Set(["$ref", "summary", "description", "servers", "parameters"])
|
|
90
|
+
const RESPONSE_KEY = /^(?:[1-5][0-9]{2}|default)$/
|
|
91
|
+
const TEMPLATE_EXPRESSION = /\{([^{}]*)\}/g
|
|
92
|
+
const NIFRA_PARAM = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
93
|
+
const RESERVED_PARAM_NAMES = new Set(["__proto__", "constructor", "prototype"])
|
|
94
|
+
|
|
95
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
96
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizedLimit(value: number | undefined, fallback: number, label: string): number {
|
|
100
|
+
const max = value ?? fallback
|
|
101
|
+
if (!Number.isSafeInteger(max) || max < 0)
|
|
102
|
+
throw new OpenAPIImportError(`${label} limit must be a finite safe integer >= 0`)
|
|
103
|
+
return max
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function assertLimit(count: number, max: number, label: string): void {
|
|
107
|
+
if (count > max) throw new OpenAPIImportError(`${label} count ${count} exceeds limit ${max}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Collect own keys while stopping as soon as a bounded collection is over its cap. */
|
|
111
|
+
function ownKeysWithinLimit(
|
|
112
|
+
record: Record<string, unknown>,
|
|
113
|
+
max: number,
|
|
114
|
+
label: string,
|
|
115
|
+
include: (key: string) => boolean = () => true,
|
|
116
|
+
): string[] {
|
|
117
|
+
const keys: string[] = []
|
|
118
|
+
for (const key in record) {
|
|
119
|
+
if (!Object.hasOwn(record, key) || !include(key)) continue
|
|
120
|
+
if (keys.length >= max)
|
|
121
|
+
throw new OpenAPIImportError(`${label} count ${keys.length + 1} exceeds limit ${max}`)
|
|
122
|
+
keys.push(key)
|
|
123
|
+
}
|
|
124
|
+
return keys
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function toNifraPath(openapiPath: string): {
|
|
128
|
+
readonly path: string
|
|
129
|
+
readonly templated: boolean
|
|
130
|
+
readonly pathParams: readonly string[]
|
|
131
|
+
} {
|
|
132
|
+
const segments = openapiPath.split("/")
|
|
133
|
+
let templated = false
|
|
134
|
+
const pathParams: string[] = []
|
|
135
|
+
const out = segments.map((segment) => {
|
|
136
|
+
if (!segment.includes("{") && !segment.includes("}")) return segment
|
|
137
|
+
const matches = [...segment.matchAll(TEMPLATE_EXPRESSION)]
|
|
138
|
+
if (matches.length === 0)
|
|
139
|
+
throw new OpenAPIImportError(`unparseable path template ${openapiPath}`)
|
|
140
|
+
let cursor = 0
|
|
141
|
+
let converted = ""
|
|
142
|
+
for (const match of matches) {
|
|
143
|
+
if (match.index === undefined)
|
|
144
|
+
throw new OpenAPIImportError(`unparseable path template ${openapiPath}`)
|
|
145
|
+
const start = match.index
|
|
146
|
+
const end = start + match[0].length
|
|
147
|
+
const name = match[1] ?? ""
|
|
148
|
+
const previous = segment[start - 1]
|
|
149
|
+
const next = segment[end]
|
|
150
|
+
// Nifra's mixed grammar needs a non-identifier delimiter after a parameter. A terminal
|
|
151
|
+
// `foo{bar}` or adjacent `{a}{b}` cannot be represented without changing the capture.
|
|
152
|
+
if (
|
|
153
|
+
next === "{" ||
|
|
154
|
+
(next !== undefined && /[A-Za-z0-9_]/.test(next)) ||
|
|
155
|
+
(next === undefined && previous !== undefined && /[A-Za-z0-9_]/.test(previous))
|
|
156
|
+
)
|
|
157
|
+
throw new OpenAPIImportError(`path template ${openapiPath} is not representable in Nifra`)
|
|
158
|
+
if (!NIFRA_PARAM.test(name) || RESERVED_PARAM_NAMES.has(name))
|
|
159
|
+
throw new OpenAPIImportError(`invalid path parameter ${name}`)
|
|
160
|
+
if (pathParams.includes(name))
|
|
161
|
+
throw new OpenAPIImportError(`duplicate path parameter ${name} on ${openapiPath}`)
|
|
162
|
+
pathParams.push(name)
|
|
163
|
+
converted += `${segment.slice(cursor, start)}:${name}`
|
|
164
|
+
cursor = end
|
|
165
|
+
templated = true
|
|
166
|
+
}
|
|
167
|
+
const tail = segment.slice(cursor)
|
|
168
|
+
if (tail.includes("{") || tail.includes("}"))
|
|
169
|
+
throw new OpenAPIImportError(`unparseable path template ${openapiPath}`)
|
|
170
|
+
return converted + tail
|
|
171
|
+
})
|
|
172
|
+
return { path: out.join("/"), templated, pathParams: Object.freeze(pathParams) }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface MergedParameter {
|
|
176
|
+
readonly name: string
|
|
177
|
+
readonly location: string
|
|
178
|
+
readonly required: boolean
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function mergedParameters(
|
|
182
|
+
pathItem: Record<string, unknown>,
|
|
183
|
+
operation: Record<string, unknown>,
|
|
184
|
+
max: number,
|
|
185
|
+
): MergedParameter[] {
|
|
186
|
+
const merged = new Map<string, MergedParameter>()
|
|
187
|
+
for (const source of [pathItem.parameters, operation.parameters]) {
|
|
188
|
+
if (source === undefined) continue
|
|
189
|
+
if (!Array.isArray(source)) throw new OpenAPIImportError("parameters must be an array")
|
|
190
|
+
assertLimit(source.length, max, "parameters")
|
|
191
|
+
for (const entry of source) {
|
|
192
|
+
if (!isRecord(entry)) throw new OpenAPIImportError("parameter entries must be objects")
|
|
193
|
+
if ("$ref" in entry)
|
|
194
|
+
throw new OpenAPIImportError(
|
|
195
|
+
"parameter $refs must be inlined before import - refs are never resolved",
|
|
196
|
+
)
|
|
197
|
+
const {
|
|
198
|
+
name,
|
|
199
|
+
in: location,
|
|
200
|
+
required,
|
|
201
|
+
} = entry as {
|
|
202
|
+
name?: unknown
|
|
203
|
+
in?: unknown
|
|
204
|
+
required?: unknown
|
|
205
|
+
}
|
|
206
|
+
if (typeof name !== "string" || name === "")
|
|
207
|
+
throw new OpenAPIImportError("parameters need a non-empty name")
|
|
208
|
+
if (typeof location !== "string" || location === "")
|
|
209
|
+
throw new OpenAPIImportError(`parameter ${name} needs an 'in' location`)
|
|
210
|
+
merged.set(`${location} ${name}`, {
|
|
211
|
+
name,
|
|
212
|
+
location,
|
|
213
|
+
required: required === true,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
assertLimit(merged.size, max, "merged parameters")
|
|
218
|
+
return [...merged.values()]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Parse an OpenAPI 3.0/3.1 document into a canonical route inventory. Throws on invalid input. */
|
|
222
|
+
export function importOpenAPI(
|
|
223
|
+
document: unknown,
|
|
224
|
+
options: ImportOpenAPIOptions = {},
|
|
225
|
+
): ImportedApiInventory {
|
|
226
|
+
const maxPaths = normalizedLimit(options.maxPaths, 5000, "maxPaths")
|
|
227
|
+
const maxOperations = normalizedLimit(options.maxOperationsPerPath, 8, "maxOperationsPerPath")
|
|
228
|
+
const maxParameters = normalizedLimit(options.maxParameters, 100, "maxParameters")
|
|
229
|
+
if (!isRecord(document)) throw new OpenAPIImportError("document must be an object")
|
|
230
|
+
const { openapi, info, paths } = document
|
|
231
|
+
if (typeof openapi !== "string" || !/^3\.(0|1)\.\d+$/.test(openapi))
|
|
232
|
+
throw new OpenAPIImportError(
|
|
233
|
+
`unsupported OpenAPI version ${JSON.stringify(openapi)} - 3.0.x and 3.1.x only`,
|
|
234
|
+
)
|
|
235
|
+
if (!isRecord(info)) throw new OpenAPIImportError("document.info must be an object")
|
|
236
|
+
const title = typeof info.title === "string" ? info.title : ""
|
|
237
|
+
const version = typeof info.version === "string" ? info.version : ""
|
|
238
|
+
if (paths === undefined)
|
|
239
|
+
return { title, version, routes: Object.freeze([]), warnings: Object.freeze([]) }
|
|
240
|
+
if (!isRecord(paths)) throw new OpenAPIImportError("document.paths must be an object")
|
|
241
|
+
const pathKeys = ownKeysWithinLimit(paths, maxPaths, "paths")
|
|
242
|
+
const warnings: string[] = []
|
|
243
|
+
const routes: ImportedRoute[] = []
|
|
244
|
+
for (const openapiPath of pathKeys) {
|
|
245
|
+
if (!openapiPath.startsWith("/"))
|
|
246
|
+
throw new OpenAPIImportError(`path ${openapiPath} must start with /`)
|
|
247
|
+
const pathItem = paths[openapiPath]
|
|
248
|
+
if (!isRecord(pathItem))
|
|
249
|
+
throw new OpenAPIImportError(`path item ${openapiPath} must be an object`)
|
|
250
|
+
if (Object.hasOwn(pathItem, "$ref"))
|
|
251
|
+
throw new OpenAPIImportError(
|
|
252
|
+
`path item $refs must be inlined before import - refs are never resolved`,
|
|
253
|
+
)
|
|
254
|
+
const operationKeys = ownKeysWithinLimit(
|
|
255
|
+
pathItem,
|
|
256
|
+
maxOperations,
|
|
257
|
+
`operations on ${openapiPath}`,
|
|
258
|
+
(key) => key !== "parameters" && !PATH_ITEM_METADATA.has(key) && !key.startsWith("x-"),
|
|
259
|
+
)
|
|
260
|
+
const { path, templated, pathParams } = toNifraPath(openapiPath)
|
|
261
|
+
const seenMethods = new Set<string>()
|
|
262
|
+
for (const rawMethod of operationKeys) {
|
|
263
|
+
const method = rawMethod.toLowerCase()
|
|
264
|
+
if (!SUPPORTED_METHODS.has(method))
|
|
265
|
+
throw new OpenAPIImportError(`unsupported method ${rawMethod} on ${openapiPath}`)
|
|
266
|
+
if (seenMethods.has(method))
|
|
267
|
+
throw new OpenAPIImportError(`duplicate method ${rawMethod} on ${openapiPath}`)
|
|
268
|
+
seenMethods.add(method)
|
|
269
|
+
const rawOperation = pathItem[rawMethod]
|
|
270
|
+
if (!isRecord(rawOperation))
|
|
271
|
+
throw new OpenAPIImportError(`operation ${rawMethod} ${openapiPath} must be an object`)
|
|
272
|
+
const params = mergedParameters(pathItem, rawOperation, maxParameters)
|
|
273
|
+
const query: ImportedRouteQuery[] = []
|
|
274
|
+
for (const param of params) {
|
|
275
|
+
if (param.location === "query") query.push({ name: param.name, required: param.required })
|
|
276
|
+
else if (param.location !== "path" && param.location !== "header")
|
|
277
|
+
warnings.push(
|
|
278
|
+
`${method.toUpperCase()} ${path}: '${param.location}' parameter '${param.name}' skipped`,
|
|
279
|
+
)
|
|
280
|
+
else if (param.location === "header")
|
|
281
|
+
warnings.push(`${method.toUpperCase()} ${path}: header parameters are not inventoried`)
|
|
282
|
+
}
|
|
283
|
+
const declaredPathParams = new Set(
|
|
284
|
+
params.filter((param) => param.location === "path").map((param) => param.name),
|
|
285
|
+
)
|
|
286
|
+
for (const name of pathParams) {
|
|
287
|
+
if (!declaredPathParams.has(name))
|
|
288
|
+
throw new OpenAPIImportError(
|
|
289
|
+
`path parameter ${name} is not declared on ${method.toUpperCase()} ${path}`,
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
for (const name of declaredPathParams) {
|
|
293
|
+
if (!pathParams.includes(name))
|
|
294
|
+
throw new OpenAPIImportError(`declared path parameter ${name} is not in ${path}`)
|
|
295
|
+
}
|
|
296
|
+
const rawBody = rawOperation.requestBody
|
|
297
|
+
const hasBody = rawBody !== undefined
|
|
298
|
+
if (hasBody && !isRecord(rawBody))
|
|
299
|
+
throw new OpenAPIImportError(
|
|
300
|
+
`requestBody on ${method.toUpperCase()} ${path} must be an object`,
|
|
301
|
+
)
|
|
302
|
+
const rawResponses = rawOperation.responses
|
|
303
|
+
if (rawResponses === undefined)
|
|
304
|
+
throw new OpenAPIImportError(`responses are required on ${method.toUpperCase()} ${path}`)
|
|
305
|
+
if (!isRecord(rawResponses))
|
|
306
|
+
throw new OpenAPIImportError(
|
|
307
|
+
`responses on ${method.toUpperCase()} ${path} must be an object`,
|
|
308
|
+
)
|
|
309
|
+
const responseStatuses: string[] = []
|
|
310
|
+
for (const status of Object.keys(rawResponses)) {
|
|
311
|
+
if (!RESPONSE_KEY.test(status))
|
|
312
|
+
throw new OpenAPIImportError(
|
|
313
|
+
`invalid response status ${status} on ${method.toUpperCase()} ${path}`,
|
|
314
|
+
)
|
|
315
|
+
if (!isRecord(rawResponses[status]))
|
|
316
|
+
throw new OpenAPIImportError(
|
|
317
|
+
`response ${status} on ${method.toUpperCase()} ${path} must be an object`,
|
|
318
|
+
)
|
|
319
|
+
responseStatuses.push(status)
|
|
320
|
+
}
|
|
321
|
+
const notes = templated
|
|
322
|
+
? [
|
|
323
|
+
"OpenAPI path templates import as single-segment :params - greedy remainders (*rest) cannot be distinguished and need manual rewrites",
|
|
324
|
+
]
|
|
325
|
+
: []
|
|
326
|
+
const operationId = rawOperation.operationId
|
|
327
|
+
routes.push(
|
|
328
|
+
Object.freeze({
|
|
329
|
+
method,
|
|
330
|
+
path,
|
|
331
|
+
...(typeof operationId === "string" && operationId !== "" ? { operationId } : {}),
|
|
332
|
+
pathParams,
|
|
333
|
+
query: Object.freeze(query),
|
|
334
|
+
hasBody,
|
|
335
|
+
requestBodyRequired: hasBody && (rawBody as Record<string, unknown>).required === true,
|
|
336
|
+
responseStatuses: Object.freeze(responseStatuses),
|
|
337
|
+
notes: Object.freeze(notes),
|
|
338
|
+
}),
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (routes.some((route) => route.pathParams.length > 0))
|
|
343
|
+
warnings.push(
|
|
344
|
+
"OpenAPI path templates import as single-segment :params - greedy remainders (*rest) cannot be distinguished and need manual rewrites; see route notes",
|
|
345
|
+
)
|
|
346
|
+
return {
|
|
347
|
+
title,
|
|
348
|
+
version,
|
|
349
|
+
routes: Object.freeze(routes),
|
|
350
|
+
warnings: Object.freeze(warnings),
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function routeKey(method: string, path: string): string {
|
|
355
|
+
return `${method.toUpperCase()} ${path}`
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Compare two inventories. Empty means the export/import cycle preserved the route table. */
|
|
359
|
+
export function diffOpenApiInventory(
|
|
360
|
+
expected: ImportedApiInventory,
|
|
361
|
+
actual: ImportedApiInventory,
|
|
362
|
+
): readonly OpenApiInventoryDrift[] {
|
|
363
|
+
const drift: OpenApiInventoryDrift[] = []
|
|
364
|
+
const actualByKey = new Map(
|
|
365
|
+
actual.routes.map((route) => [routeKey(route.method, route.path), route]),
|
|
366
|
+
)
|
|
367
|
+
const expectedKeys = new Set<string>()
|
|
368
|
+
for (const want of expected.routes) {
|
|
369
|
+
const key = routeKey(want.method, want.path)
|
|
370
|
+
expectedKeys.add(key)
|
|
371
|
+
const got = actualByKey.get(key)
|
|
372
|
+
if (got === undefined) {
|
|
373
|
+
drift.push({ route: key, message: "route missing after import" })
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
const wantQuery = new Map(want.query.map((param) => [param.name, param.required]))
|
|
377
|
+
const gotQuery = new Map(got.query.map((param) => [param.name, param.required]))
|
|
378
|
+
for (const [name, required] of wantQuery) {
|
|
379
|
+
if (!gotQuery.has(name)) drift.push({ route: key, message: `query parameter '${name}' lost` })
|
|
380
|
+
else if (gotQuery.get(name) !== required)
|
|
381
|
+
drift.push({ route: key, message: `query parameter '${name}' required flag flipped` })
|
|
382
|
+
}
|
|
383
|
+
for (const name of gotQuery.keys()) {
|
|
384
|
+
if (!wantQuery.has(name))
|
|
385
|
+
drift.push({ route: key, message: `unexpected query parameter '${name}'` })
|
|
386
|
+
}
|
|
387
|
+
if (want.hasBody !== got.hasBody)
|
|
388
|
+
drift.push({
|
|
389
|
+
route: key,
|
|
390
|
+
message: want.hasBody ? "request body lost" : "unexpected request body",
|
|
391
|
+
})
|
|
392
|
+
else if (want.hasBody && want.requestBodyRequired !== got.requestBodyRequired)
|
|
393
|
+
drift.push({ route: key, message: "request body required flag flipped" })
|
|
394
|
+
const wantStatuses = new Set(want.responseStatuses)
|
|
395
|
+
const gotStatuses = new Set(got.responseStatuses)
|
|
396
|
+
for (const status of wantStatuses) {
|
|
397
|
+
if (!gotStatuses.has(status))
|
|
398
|
+
drift.push({ route: key, message: `response status ${status} lost` })
|
|
399
|
+
}
|
|
400
|
+
for (const status of gotStatuses) {
|
|
401
|
+
if (!wantStatuses.has(status))
|
|
402
|
+
drift.push({ route: key, message: `unexpected response status ${status}` })
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
for (const got of actual.routes) {
|
|
406
|
+
const key = routeKey(got.method, got.path)
|
|
407
|
+
if (!expectedKeys.has(key)) drift.push({ route: key, message: "unexpected route after import" })
|
|
408
|
+
}
|
|
409
|
+
return Object.freeze(drift)
|
|
410
|
+
}
|