@pikku/openapi-parser 0.12.3 → 0.12.4

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.
@@ -0,0 +1,442 @@
1
+ /**
2
+ * Parses OpenAPI YAML/JSON specs, resolves $ref pointers, and produces a normalized IR.
3
+ */
4
+ import { createHash } from 'crypto'
5
+ import { readFile } from 'fs/promises'
6
+ import { parse as parseYAML } from 'yaml'
7
+
8
+ import type { OpenAPISchema } from './zod-codegen.js'
9
+
10
+ export interface ErrorResponse {
11
+ statusCode: number
12
+ description: string
13
+ }
14
+
15
+ export interface SecuritySchemeInfo {
16
+ type: 'oauth2' | 'http' | 'apiKey'
17
+ scheme?: string
18
+ bearerFormat?: string
19
+ name?: string
20
+ in?: string
21
+ flows?: {
22
+ authorizationUrl?: string
23
+ tokenUrl?: string
24
+ scopes?: Record<string, string>
25
+ }
26
+ }
27
+
28
+ export interface ParsedSpec {
29
+ info: { title: string; version: string; description?: string }
30
+ baseUrl: string
31
+ serverUrls: string[]
32
+ authType: 'bearer' | 'oauth2' | 'apiKey' | 'none'
33
+ operations: ParsedOperation[]
34
+ componentSchemas: Record<string, OpenAPISchema>
35
+ securitySchemes: Record<string, SecuritySchemeInfo>
36
+ tagDescriptions: Record<string, string>
37
+ }
38
+
39
+ export interface ParsedOperation {
40
+ operationId?: string
41
+ method: string
42
+ path: string
43
+ summary?: string
44
+ description?: string
45
+ tags: string[]
46
+ pathParams: ParsedParam[]
47
+ queryParams: ParsedParam[]
48
+ headerParams: ParsedParam[]
49
+ requestBody?: OpenAPISchema
50
+ requestBodyDescription?: string
51
+ requestBodyRequired?: boolean
52
+ responseSchema?: OpenAPISchema
53
+ responseDescription?: string
54
+ errorResponses: ErrorResponse[]
55
+ deprecated: boolean
56
+ }
57
+
58
+ export interface ParsedParam {
59
+ name: string
60
+ required: boolean
61
+ schema: OpenAPISchema
62
+ description?: string
63
+ example?: unknown
64
+ }
65
+
66
+ /**
67
+ * Read and parse an OpenAPI spec from a file path.
68
+ * Supports both YAML (.yaml, .yml) and JSON (.json) files.
69
+ */
70
+ export async function parseOpenAPISpec(filePath: string): Promise<ParsedSpec> {
71
+ const content = await readFile(filePath, 'utf-8')
72
+
73
+ let doc: any
74
+ if (filePath.endsWith('.json')) {
75
+ doc = JSON.parse(content)
76
+ } else {
77
+ doc = parseYAML(content)
78
+ }
79
+
80
+ // Validate spec version
81
+ const specVersion = doc.openapi ?? doc.swagger
82
+ if (!specVersion) {
83
+ throw new Error(
84
+ 'Not a valid OpenAPI/Swagger spec: missing "openapi" or "swagger" version field.'
85
+ )
86
+ }
87
+ const major = String(specVersion).split('.')[0]
88
+ if (major !== '2' && major !== '3') {
89
+ throw new Error(
90
+ `Unsupported spec version "${specVersion}". Only OpenAPI 3.x and Swagger 2.x are supported.`
91
+ )
92
+ }
93
+
94
+ // Resolve all $ref pointers in-place
95
+ resolveRefs(doc, doc)
96
+
97
+ const info = {
98
+ title: doc.info?.title ?? 'Unknown API',
99
+ version: doc.info?.version ?? '1.0.0',
100
+ description: doc.info?.description,
101
+ }
102
+
103
+ const serverUrls = extractServerUrls(doc)
104
+ const baseUrl = serverUrls[0] ?? ''
105
+ const authType = detectAuthType(doc)
106
+ const securitySchemes = extractSecuritySchemes(doc)
107
+ const tagDescriptions = extractTagDescriptions(doc)
108
+
109
+ // Extract component schemas
110
+ const componentSchemas: Record<string, OpenAPISchema> = {}
111
+ if (doc.components?.schemas) {
112
+ for (const [name, schema] of Object.entries(doc.components.schemas)) {
113
+ componentSchemas[name] = schema as OpenAPISchema
114
+ }
115
+ }
116
+
117
+ // Extract operations (skip deprecated)
118
+ const operations: ParsedOperation[] = []
119
+ if (doc.paths) {
120
+ for (const [path, pathItem] of Object.entries(
121
+ doc.paths as Record<string, any>
122
+ )) {
123
+ // Shared parameters at the path level
124
+ const sharedParams: any[] = pathItem.parameters ?? []
125
+
126
+ for (const method of ['get', 'post', 'put', 'patch', 'delete'] as const) {
127
+ const op = pathItem[method]
128
+ if (!op) continue
129
+
130
+ // Skip deprecated operations
131
+ if (op.deprecated === true) continue
132
+
133
+ const allParams: any[] = [...sharedParams, ...(op.parameters ?? [])]
134
+
135
+ const body = op.requestBody
136
+
137
+ operations.push({
138
+ operationId: op.operationId,
139
+ method,
140
+ path,
141
+ summary: op.summary,
142
+ description: op.description,
143
+ tags: op.tags ?? [],
144
+ pathParams: extractParams(allParams, 'path'),
145
+ queryParams: extractParams(allParams, 'query'),
146
+ headerParams: extractParams(allParams, 'header'),
147
+ requestBody: extractRequestBody(op),
148
+ requestBodyDescription: body?.description,
149
+ requestBodyRequired: body?.required,
150
+ responseSchema: extractResponseSchema(op),
151
+ responseDescription: extractResponseDescription(op),
152
+ errorResponses: extractErrorResponses(op),
153
+ deprecated: false,
154
+ })
155
+ }
156
+ }
157
+ }
158
+
159
+ return {
160
+ info,
161
+ baseUrl,
162
+ serverUrls,
163
+ authType,
164
+ operations,
165
+ componentSchemas,
166
+ securitySchemes,
167
+ tagDescriptions,
168
+ }
169
+ }
170
+
171
+ /** Recursively resolve $ref pointers in-place */
172
+ function resolveRefs(node: any, root: any): any {
173
+ if (node === null || typeof node !== 'object') return node
174
+
175
+ if (Array.isArray(node)) {
176
+ for (let i = 0; i < node.length; i++) {
177
+ node[i] = resolveRefs(node[i], root)
178
+ }
179
+ return node
180
+ }
181
+
182
+ if (typeof node.$ref === 'string') {
183
+ const resolved = resolveRefPath(node.$ref, root)
184
+ if (resolved && typeof resolved === 'object') {
185
+ // Merge any sibling properties (like description overrides)
186
+ const { $ref, ...siblings } = node
187
+ const result = { ...resolved, ...siblings }
188
+ // Don't recurse infinitely — mark as resolved
189
+ return result
190
+ }
191
+ return node
192
+ }
193
+
194
+ for (const key of Object.keys(node)) {
195
+ node[key] = resolveRefs(node[key], root)
196
+ }
197
+
198
+ return node
199
+ }
200
+
201
+ function resolveRefPath(ref: string, root: any): any {
202
+ if (!ref.startsWith('#/')) return undefined
203
+ const parts = ref.slice(2).split('/')
204
+ let current = root
205
+ for (const part of parts) {
206
+ const decoded = part.replace(/~1/g, '/').replace(/~0/g, '~')
207
+ if (current == null || typeof current !== 'object') return undefined
208
+ current = current[decoded]
209
+ }
210
+ return current
211
+ }
212
+
213
+ function extractServerUrls(doc: any): string[] {
214
+ // OpenAPI 3.x
215
+ if (doc.servers && doc.servers.length > 0) {
216
+ return doc.servers.map((s: any) => s.url).filter((url: string) => !!url)
217
+ }
218
+ // Swagger 2.x
219
+ if (doc.host) {
220
+ const schemes = doc.schemes?.length ? doc.schemes : ['https']
221
+ const basePath = doc.basePath ?? ''
222
+ return schemes.map((scheme: string) => `${scheme}://${doc.host}${basePath}`)
223
+ }
224
+ return []
225
+ }
226
+
227
+ function detectAuthType(doc: any): 'bearer' | 'oauth2' | 'apiKey' | 'none' {
228
+ const securitySchemes =
229
+ doc.components?.securitySchemes ?? doc.securityDefinitions ?? {}
230
+
231
+ for (const scheme of Object.values(securitySchemes) as any[]) {
232
+ if (scheme.type === 'oauth2') return 'oauth2'
233
+ if (scheme.type === 'http' && scheme.scheme === 'bearer') return 'bearer'
234
+ if (scheme.type === 'apiKey') return 'apiKey'
235
+ }
236
+
237
+ return 'none'
238
+ }
239
+
240
+ /**
241
+ * Build an OpenAPI schema object from v2-style parameter-level properties
242
+ * (type, enum, items, format, default) that aren't nested under `schema`.
243
+ */
244
+ function paramToSchema(p: any): OpenAPISchema {
245
+ const schema: any = { type: p.type ?? 'string' }
246
+ if (p.enum) schema.enum = p.enum
247
+ if (p.items) schema.items = p.items
248
+ if (p.format) schema.format = p.format
249
+ if (p.default !== undefined) schema.default = p.default
250
+ return schema
251
+ }
252
+
253
+ function extractParams(
254
+ params: any[],
255
+ location: 'path' | 'query' | 'header'
256
+ ): ParsedParam[] {
257
+ return params
258
+ .filter((p) => p.in === location)
259
+ .map((p) => ({
260
+ name: p.name,
261
+ required: p.required ?? location === 'path',
262
+ schema: (p.schema ?? paramToSchema(p)) as OpenAPISchema,
263
+ description: p.description,
264
+ example: p.example,
265
+ }))
266
+ }
267
+
268
+ function extractRequestBody(op: any): OpenAPISchema | undefined {
269
+ const body = op.requestBody
270
+ if (!body) return undefined
271
+
272
+ const content = body.content
273
+ if (!content) return undefined
274
+
275
+ // Prefer JSON
276
+ const jsonContent = content['application/json']
277
+ if (jsonContent?.schema) return jsonContent.schema as OpenAPISchema
278
+
279
+ // Fallback to first content type
280
+ const firstKey = Object.keys(content)[0]
281
+ if (firstKey && content[firstKey]?.schema) {
282
+ return content[firstKey].schema as OpenAPISchema
283
+ }
284
+
285
+ return undefined
286
+ }
287
+
288
+ function extractResponseDescription(op: any): string | undefined {
289
+ const responses = op.responses
290
+ if (!responses) return undefined
291
+ for (const code of ['200', '201', '202']) {
292
+ const resp = responses[code]
293
+ if (resp?.description) return resp.description
294
+ }
295
+ return undefined
296
+ }
297
+
298
+ function extractResponseSchema(op: any): OpenAPISchema | undefined {
299
+ const responses = op.responses
300
+ if (!responses) return undefined
301
+
302
+ // Look for 2xx responses in order of preference
303
+ for (const code of ['200', '201', '202', '204']) {
304
+ const resp = responses[code]
305
+ if (!resp) continue
306
+
307
+ const content = resp.content
308
+ if (!content) continue
309
+
310
+ const jsonContent = content['application/json']
311
+ if (jsonContent?.schema) return jsonContent.schema as OpenAPISchema
312
+
313
+ const firstKey = Object.keys(content)[0]
314
+ if (firstKey && content[firstKey]?.schema) {
315
+ return content[firstKey].schema as OpenAPISchema
316
+ }
317
+ }
318
+
319
+ // Fallback: any 2xx
320
+ for (const [code, resp] of Object.entries(responses) as [string, any][]) {
321
+ if (code.startsWith('2') && resp.content) {
322
+ const jsonContent = resp.content['application/json']
323
+ if (jsonContent?.schema) return jsonContent.schema as OpenAPISchema
324
+ }
325
+ }
326
+
327
+ return undefined
328
+ }
329
+
330
+ function extractErrorResponses(op: any): ErrorResponse[] {
331
+ const responses = op.responses
332
+ if (!responses) return []
333
+
334
+ const errors: ErrorResponse[] = []
335
+ for (const [code, resp] of Object.entries(responses) as [string, any][]) {
336
+ const statusCode = parseInt(code, 10)
337
+ if (isNaN(statusCode)) continue
338
+ if (statusCode >= 400) {
339
+ errors.push({
340
+ statusCode,
341
+ description: resp.description ?? `Error ${statusCode}`,
342
+ })
343
+ }
344
+ }
345
+ return errors
346
+ }
347
+
348
+ function extractSecuritySchemes(doc: any): Record<string, SecuritySchemeInfo> {
349
+ const raw = doc.components?.securitySchemes ?? doc.securityDefinitions ?? {}
350
+ const result: Record<string, SecuritySchemeInfo> = {}
351
+
352
+ for (const [name, scheme] of Object.entries(raw) as [string, any][]) {
353
+ const info: SecuritySchemeInfo = {
354
+ type: scheme.type === 'http' ? 'http' : scheme.type,
355
+ }
356
+
357
+ if (scheme.type === 'http') {
358
+ info.scheme = scheme.scheme
359
+ info.bearerFormat = scheme.bearerFormat
360
+ }
361
+
362
+ if (scheme.type === 'apiKey') {
363
+ info.name = scheme.name
364
+ info.in = scheme.in
365
+ }
366
+
367
+ if (scheme.type === 'oauth2') {
368
+ // Extract flows — prefer authorizationCode, then implicit, then clientCredentials
369
+ const flows = scheme.flows ?? {}
370
+ const flow =
371
+ flows.authorizationCode ??
372
+ flows.implicit ??
373
+ flows.clientCredentials ??
374
+ flows.password
375
+
376
+ if (flow) {
377
+ info.flows = {
378
+ authorizationUrl: flow.authorizationUrl,
379
+ tokenUrl: flow.tokenUrl,
380
+ scopes: flow.scopes,
381
+ }
382
+ }
383
+ }
384
+
385
+ result[name] = info
386
+ }
387
+
388
+ return result
389
+ }
390
+
391
+ /**
392
+ * Compute a SHA-256 hash of the contract-relevant parts of a parsed spec.
393
+ * Excludes presentation fields (summary, description, tags, operationId)
394
+ * so that only structural API changes produce a different hash.
395
+ */
396
+ export function computeContractHash(spec: ParsedSpec): string {
397
+ const contract = {
398
+ operations: spec.operations.map((op) => ({
399
+ method: op.method,
400
+ path: op.path,
401
+ pathParams: op.pathParams.map((p) => ({
402
+ name: p.name,
403
+ required: p.required,
404
+ schema: p.schema,
405
+ })),
406
+ queryParams: op.queryParams.map((p) => ({
407
+ name: p.name,
408
+ required: p.required,
409
+ schema: p.schema,
410
+ })),
411
+ headerParams: op.headerParams.map((p) => ({
412
+ name: p.name,
413
+ required: p.required,
414
+ schema: p.schema,
415
+ })),
416
+ requestBody: op.requestBody,
417
+ requestBodyRequired: op.requestBodyRequired,
418
+ responseSchema: op.responseSchema,
419
+ errorResponses: op.errorResponses,
420
+ })),
421
+ componentSchemas: spec.componentSchemas,
422
+ securitySchemes: spec.securitySchemes,
423
+ serverUrls: spec.serverUrls,
424
+ }
425
+
426
+ return createHash('sha256')
427
+ .update(JSON.stringify(contract))
428
+ .digest('hex')
429
+ .slice(0, 16)
430
+ }
431
+
432
+ function extractTagDescriptions(doc: any): Record<string, string> {
433
+ const result: Record<string, string> = {}
434
+ if (Array.isArray(doc.tags)) {
435
+ for (const tag of doc.tags) {
436
+ if (tag.name && tag.description) {
437
+ result[tag.name] = tag.description
438
+ }
439
+ }
440
+ }
441
+ return result
442
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ type OpenAPISchema,
3
+ type ZodCodegenContext,
4
+ createContext,
5
+ schemaToZod,
6
+ schemaVarName,
7
+ sanitizeTypeName,
8
+ } from './openapi-to-zod-schema.js'
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "module": "Node18",
6
+ "outDir": "dist",
7
+ "target": "esnext",
8
+ "declaration": true
9
+ },
10
+ "include": ["src/**/*.ts"],
11
+ "exclude": ["**/*.test.ts", "node_modules"]
12
+ }
@@ -0,0 +1 @@
1
+ {"root":["./src/codegen.ts","./src/index.ts","./src/naming.ts","./src/openapi-to-zod-schema.ts","./src/parse-openapi.ts","./src/zod-codegen.ts"],"version":"5.9.3"}