-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathopenapi-parser.ts
More file actions
208 lines (165 loc) · 6.03 KB
/
Copy pathopenapi-parser.ts
File metadata and controls
208 lines (165 loc) · 6.03 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
/**
* openapi-parser.ts — parse JSON OpenAPI v3 specs into normalized BackendRoute[].
*
* Parsing only — no matching against ApiCall[], no ScanConfig wiring.
* YAML specs are explicitly rejected.
*/
import { readFile } from 'node:fs/promises'
import { extname } from 'node:path'
import type { Result } from '../model/result.js'
import { err, ok } from '../model/result.js'
import type { BackendRoute, BodyShape, ContractHttpMethod } from './model.js'
import { OpenApiParseError } from './model.js'
import { resolveBodyShape } from './schema-shape.js'
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'] as const
type OpenApiHttpMethod = (typeof HTTP_METHODS)[number]
const METHOD_TO_CONTRACT: Record<OpenApiHttpMethod, ContractHttpMethod> = {
get: 'GET',
post: 'POST',
put: 'PUT',
patch: 'PATCH',
delete: 'DELETE',
head: 'HEAD',
options: 'OPTIONS',
}
/**
* Parse a JSON OpenAPI v3 document from disk into normalized backend routes.
*/
export async function parseOpenApiSpec(
filePath: string,
): Promise<Result<BackendRoute[], OpenApiParseError>> {
const extension = extname(filePath).toLowerCase()
if (extension === '.yaml' || extension === '.yml') {
return err(new OpenApiParseError('YAML OpenAPI specs are not supported yet', undefined))
}
let raw: string
try {
raw = await readFile(filePath, 'utf-8')
} catch (cause) {
return err(new OpenApiParseError(`Could not read OpenAPI spec: ${filePath}`, cause))
}
let parsed: unknown
try {
parsed = JSON.parse(raw) as unknown
} catch (cause) {
return err(new OpenApiParseError(`Invalid JSON in OpenAPI spec: ${filePath}`, cause))
}
const specResult = validateOpenApiSpec(parsed)
if (!specResult.ok) {
return specResult
}
const routes = extractRoutes(specResult.value)
return ok(routes)
}
function validateOpenApiSpec(parsed: unknown): Result<Record<string, unknown>, OpenApiParseError> {
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return err(new OpenApiParseError('OpenAPI spec root must be a JSON object', undefined))
}
const spec = parsed as Record<string, unknown>
const openapiVersion = spec.openapi
if (typeof openapiVersion !== 'string' || !openapiVersion.startsWith('3.')) {
return err(new OpenApiParseError('OpenAPI spec must declare openapi version 3.x.x', undefined))
}
const paths = spec.paths
if (paths === null || typeof paths !== 'object' || Array.isArray(paths)) {
return err(new OpenApiParseError('OpenAPI spec must include a paths object', undefined))
}
return ok(spec)
}
function extractRoutes(spec: Record<string, unknown>): BackendRoute[] {
const paths = spec.paths as Record<string, unknown>
const routes: BackendRoute[] = []
for (const [path, pathItem] of Object.entries(paths)) {
if (pathItem === null || typeof pathItem !== 'object' || Array.isArray(pathItem)) {
continue
}
const pathItemObj = pathItem as Record<string, unknown>
if ('$ref' in pathItemObj) {
continue
}
for (const method of HTTP_METHODS) {
const operation = pathItemObj[method]
if (operation === null || typeof operation !== 'object' || Array.isArray(operation)) {
continue
}
routes.push(extractRoute(path, method, operation as Record<string, unknown>))
}
}
return routes
}
function extractRoute(
path: string,
method: OpenApiHttpMethod,
operation: Record<string, unknown>,
): BackendRoute {
const operationId = typeof operation.operationId === 'string' ? operation.operationId : undefined
const requestBody = extractRequestBody(operation.requestBody)
const responseBody = extractResponseBody(operation.responses)
return {
path,
method: METHOD_TO_CONTRACT[method],
operationId,
requestBody,
responseBody,
}
}
function extractRequestBody(requestBody: unknown): BodyShape | undefined {
if (requestBody === undefined) {
return undefined
}
if (requestBody === null || typeof requestBody !== 'object' || Array.isArray(requestBody)) {
return { kind: 'unresolvable', reason: 'requestBody is not a valid object' }
}
const schema = extractSchemaFromContent((requestBody as Record<string, unknown>).content)
if (schema === undefined) {
return { kind: 'unresolvable', reason: 'requestBody has no JSON schema' }
}
return resolveBodyShape(schema)
}
function extractResponseBody(responses: unknown): BodyShape | undefined {
if (responses === null || typeof responses !== 'object' || Array.isArray(responses)) {
return undefined
}
const responsesObj = responses as Record<string, unknown>
const statusCode = findFirst2xxStatus(responsesObj)
if (statusCode === undefined) {
return undefined
}
const response = responsesObj[statusCode]
if (response === null || typeof response !== 'object' || Array.isArray(response)) {
return undefined
}
const schema = extractSchemaFromContent((response as Record<string, unknown>).content)
if (schema === undefined) {
return undefined
}
return resolveBodyShape(schema)
}
function findFirst2xxStatus(responses: Record<string, unknown>): string | undefined {
const keys = Object.keys(responses).sort()
return keys.find((key) => /^2\d{2}$/.test(key))
}
function extractSchemaFromContent(content: unknown): unknown {
if (content === null || typeof content !== 'object' || Array.isArray(content)) {
return undefined
}
const contentObj = content as Record<string, unknown>
const jsonEntry = contentObj['application/json']
if (jsonEntry !== undefined) {
return extractSchemaFromMediaType(jsonEntry)
}
for (const mediaType of Object.values(contentObj)) {
const schema = extractSchemaFromMediaType(mediaType)
if (schema !== undefined) {
return schema
}
}
return undefined
}
function extractSchemaFromMediaType(mediaType: unknown): unknown {
if (mediaType === null || typeof mediaType !== 'object' || Array.isArray(mediaType)) {
return undefined
}
const schema = (mediaType as Record<string, unknown>).schema
return schema !== undefined ? schema : undefined
}