@pikku/openapi-parser 0.12.5 → 0.12.7
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/CHANGELOG.md +12 -0
- package/dist/codegen.d.ts +1 -0
- package/dist/codegen.js +40 -8
- package/dist/parse-openapi.js +44 -30
- package/package.json +1 -1
- package/src/codegen.ts +44 -8
- package/src/parse-openapi.test.ts +152 -1
- package/src/parse-openapi.ts +39 -27
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @pikku/openapi-parser
|
|
2
2
|
|
|
3
|
+
## 0.12.7
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5dd1996: Fix credentials command crash when state.credentials is undefined, and add --credential flag to `pikku new addon` for per-user credential wiring (apikey, bearer, oauth2).
|
|
8
|
+
|
|
9
|
+
## 0.12.6
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- a57ff11: Add Swagger 2.0 support: extract requestBody from body parameters, responseSchema from direct response schema, and component schemas from definitions. Fix duplicate .describe() on request body properties.
|
|
14
|
+
|
|
3
15
|
## 0.12.5
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/dist/codegen.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ interface AddonVars {
|
|
|
14
14
|
interface CodegenFlags {
|
|
15
15
|
oauth: boolean;
|
|
16
16
|
secret: boolean;
|
|
17
|
+
credential?: 'apikey' | 'bearer' | 'oauth2';
|
|
17
18
|
mcp?: boolean;
|
|
18
19
|
}
|
|
19
20
|
export declare function generateAddonFromOpenAPI(spec: ParsedSpec, vars: AddonVars, flags: CodegenFlags): Record<string, string>;
|
package/dist/codegen.js
CHANGED
|
@@ -119,7 +119,10 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags) {
|
|
|
119
119
|
const outputName = `${pascalName}Output`;
|
|
120
120
|
// Determine error imports needed
|
|
121
121
|
const errorClasses = getErrorClassesForResponses(parsed.errorResponses);
|
|
122
|
-
|
|
122
|
+
const needsZod = hasInput || !!parsed.responseSchema;
|
|
123
|
+
if (needsZod) {
|
|
124
|
+
lines.push("import { z } from 'zod'");
|
|
125
|
+
}
|
|
123
126
|
lines.push("import { pikkuSessionlessFunc } from '#pikku'");
|
|
124
127
|
if (errorClasses.length > 0) {
|
|
125
128
|
lines.push(`import { ${errorClasses.join(', ')} } from '@pikku/core/errors'`);
|
|
@@ -183,10 +186,7 @@ function buildInputSchema(parsed, ctx) {
|
|
|
183
186
|
continue;
|
|
184
187
|
const isOptional = !requiredSet.has(key);
|
|
185
188
|
const zodCode = schemaToZod(propSchema, ctx, { optional: isOptional });
|
|
186
|
-
|
|
187
|
-
? `${zodCode}.describe(${JSON.stringify(propSchema.description)})`
|
|
188
|
-
: zodCode;
|
|
189
|
-
props.push(` ${safeKey(key)}: ${withDesc},`);
|
|
189
|
+
props.push(` ${safeKey(key)}: ${zodCode},`);
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
192
|
else {
|
|
@@ -235,7 +235,10 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
235
235
|
const lines = [];
|
|
236
236
|
// Always import all error classes used in the switch statement
|
|
237
237
|
const allErrorClasses = new Set(Object.values(STATUS_TO_ERROR));
|
|
238
|
-
if (flags.
|
|
238
|
+
if (flags.credential && flags.credential !== 'oauth2') {
|
|
239
|
+
// Per-user credential: no special imports needed, creds passed via constructor
|
|
240
|
+
}
|
|
241
|
+
else if (flags.oauth || flags.credential === 'oauth2') {
|
|
239
242
|
lines.push("import { OAuth2Client } from '@pikku/core/oauth2'");
|
|
240
243
|
lines.push("import type { TypedSecretService } from '#pikku/secrets/pikku-secrets.gen.js'");
|
|
241
244
|
}
|
|
@@ -286,7 +289,14 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
286
289
|
// Class declaration
|
|
287
290
|
lines.push(`export class ${pascalName}Service {`);
|
|
288
291
|
lines.push(' private baseUrl: string');
|
|
289
|
-
if (flags.
|
|
292
|
+
if (flags.credential && flags.credential !== 'oauth2') {
|
|
293
|
+
const credField = flags.credential === 'bearer' ? 'token' : 'apiKey';
|
|
294
|
+
lines.push('');
|
|
295
|
+
lines.push(` constructor(private creds: { ${credField}: string }, variables: TypedVariablesService) {`);
|
|
296
|
+
lines.push(` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`);
|
|
297
|
+
lines.push(' }');
|
|
298
|
+
}
|
|
299
|
+
else if (flags.oauth || flags.credential === 'oauth2') {
|
|
290
300
|
lines.push(' private oauth: OAuth2Client');
|
|
291
301
|
lines.push('');
|
|
292
302
|
lines.push(` constructor(secrets: TypedSecretService, variables: TypedVariablesService) {`);
|
|
@@ -359,7 +369,29 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
359
369
|
lines.push(' url.searchParams.set(key, value)');
|
|
360
370
|
lines.push(' }');
|
|
361
371
|
lines.push('');
|
|
362
|
-
if (flags.
|
|
372
|
+
if (flags.credential && flags.credential !== 'oauth2') {
|
|
373
|
+
// Per-user credential: use creds from wire.getCredentials()
|
|
374
|
+
if (flags.credential === 'bearer') {
|
|
375
|
+
lines.push(' headers.Authorization = `Bearer ${this.creds.token}`');
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
// apikey: check spec for custom header name
|
|
379
|
+
const apiKeyScheme = Object.values(spec.securitySchemes).find((s) => s.type === 'apiKey');
|
|
380
|
+
if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
|
|
381
|
+
lines.push(` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`);
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
lines.push(' headers.Authorization = `Bearer ${this.creds.apiKey}`');
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
lines.push('');
|
|
388
|
+
lines.push(' const response = await fetch(url.toString(), {');
|
|
389
|
+
lines.push(' method,');
|
|
390
|
+
lines.push(' headers,');
|
|
391
|
+
lines.push(' body: body ? JSON.stringify(body) : undefined,');
|
|
392
|
+
lines.push(' })');
|
|
393
|
+
}
|
|
394
|
+
else if (flags.oauth || flags.credential === 'oauth2') {
|
|
363
395
|
lines.push(' const response = await this.oauth.request(url.toString(), {');
|
|
364
396
|
lines.push(' method,');
|
|
365
397
|
lines.push(' headers,');
|
package/dist/parse-openapi.js
CHANGED
|
@@ -38,12 +38,11 @@ export async function parseOpenAPISpec(filePath) {
|
|
|
38
38
|
const authType = detectAuthType(doc);
|
|
39
39
|
const securitySchemes = extractSecuritySchemes(doc);
|
|
40
40
|
const tagDescriptions = extractTagDescriptions(doc);
|
|
41
|
-
// Extract component schemas
|
|
41
|
+
// Extract component schemas (OpenAPI 3.x: components.schemas, Swagger 2.x: definitions)
|
|
42
42
|
const componentSchemas = {};
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
43
|
+
const rawSchemas = doc.components?.schemas ?? doc.definitions ?? {};
|
|
44
|
+
for (const [name, schema] of Object.entries(rawSchemas)) {
|
|
45
|
+
componentSchemas[name] = schema;
|
|
47
46
|
}
|
|
48
47
|
// Extract operations (skip deprecated)
|
|
49
48
|
const operations = [];
|
|
@@ -59,7 +58,9 @@ export async function parseOpenAPISpec(filePath) {
|
|
|
59
58
|
if (op.deprecated === true)
|
|
60
59
|
continue;
|
|
61
60
|
const allParams = [...sharedParams, ...(op.parameters ?? [])];
|
|
62
|
-
|
|
61
|
+
// OpenAPI 3.x: op.requestBody, Swagger 2.x: parameters[].in === 'body'
|
|
62
|
+
const body3 = op.requestBody;
|
|
63
|
+
const body2 = allParams.find((p) => p.in === 'body');
|
|
63
64
|
operations.push({
|
|
64
65
|
operationId: op.operationId,
|
|
65
66
|
method,
|
|
@@ -71,8 +72,8 @@ export async function parseOpenAPISpec(filePath) {
|
|
|
71
72
|
queryParams: extractParams(allParams, 'query'),
|
|
72
73
|
headerParams: extractParams(allParams, 'header'),
|
|
73
74
|
requestBody: extractRequestBody(op),
|
|
74
|
-
requestBodyDescription:
|
|
75
|
-
requestBodyRequired:
|
|
75
|
+
requestBodyDescription: body3?.description ?? body2?.description,
|
|
76
|
+
requestBodyRequired: body3?.required ?? body2?.required,
|
|
76
77
|
responseSchema: extractResponseSchema(op),
|
|
77
78
|
responseDescription: extractResponseDescription(op),
|
|
78
79
|
errorResponses: extractErrorResponses(op),
|
|
@@ -184,20 +185,25 @@ function extractParams(params, location) {
|
|
|
184
185
|
}));
|
|
185
186
|
}
|
|
186
187
|
function extractRequestBody(op) {
|
|
188
|
+
// OpenAPI 3.x: op.requestBody.content['application/json'].schema
|
|
187
189
|
const body = op.requestBody;
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
190
|
+
if (body) {
|
|
191
|
+
const content = body.content;
|
|
192
|
+
if (content) {
|
|
193
|
+
const jsonContent = content['application/json'];
|
|
194
|
+
if (jsonContent?.schema)
|
|
195
|
+
return jsonContent.schema;
|
|
196
|
+
const firstKey = Object.keys(content)[0];
|
|
197
|
+
if (firstKey && content[firstKey]?.schema) {
|
|
198
|
+
return content[firstKey].schema;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Swagger 2.x: parameters[].in === 'body' → schema
|
|
203
|
+
if (op.parameters) {
|
|
204
|
+
const bodyParam = op.parameters.find((p) => p.in === 'body');
|
|
205
|
+
if (bodyParam?.schema)
|
|
206
|
+
return bodyParam.schema;
|
|
201
207
|
}
|
|
202
208
|
return undefined;
|
|
203
209
|
}
|
|
@@ -221,24 +227,32 @@ function extractResponseSchema(op) {
|
|
|
221
227
|
const resp = responses[code];
|
|
222
228
|
if (!resp)
|
|
223
229
|
continue;
|
|
230
|
+
// OpenAPI 3.x: resp.content['application/json'].schema
|
|
224
231
|
const content = resp.content;
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
232
|
+
if (content) {
|
|
233
|
+
const jsonContent = content['application/json'];
|
|
234
|
+
if (jsonContent?.schema)
|
|
235
|
+
return jsonContent.schema;
|
|
236
|
+
const firstKey = Object.keys(content)[0];
|
|
237
|
+
if (firstKey && content[firstKey]?.schema) {
|
|
238
|
+
return content[firstKey].schema;
|
|
239
|
+
}
|
|
233
240
|
}
|
|
241
|
+
// Swagger 2.x: resp.schema directly
|
|
242
|
+
if (resp.schema)
|
|
243
|
+
return resp.schema;
|
|
234
244
|
}
|
|
235
245
|
// Fallback: any 2xx
|
|
236
246
|
for (const [code, resp] of Object.entries(responses)) {
|
|
237
|
-
if (code.startsWith('2')
|
|
247
|
+
if (!code.startsWith('2'))
|
|
248
|
+
continue;
|
|
249
|
+
if (resp.content) {
|
|
238
250
|
const jsonContent = resp.content['application/json'];
|
|
239
251
|
if (jsonContent?.schema)
|
|
240
252
|
return jsonContent.schema;
|
|
241
253
|
}
|
|
254
|
+
if (resp.schema)
|
|
255
|
+
return resp.schema;
|
|
242
256
|
}
|
|
243
257
|
return undefined;
|
|
244
258
|
}
|
package/package.json
CHANGED
package/src/codegen.ts
CHANGED
|
@@ -32,6 +32,7 @@ interface AddonVars {
|
|
|
32
32
|
interface CodegenFlags {
|
|
33
33
|
oauth: boolean
|
|
34
34
|
secret: boolean
|
|
35
|
+
credential?: 'apikey' | 'bearer' | 'oauth2'
|
|
35
36
|
mcp?: boolean
|
|
36
37
|
}
|
|
37
38
|
|
|
@@ -211,7 +212,10 @@ function generateFunctionFile(
|
|
|
211
212
|
// Determine error imports needed
|
|
212
213
|
const errorClasses = getErrorClassesForResponses(parsed.errorResponses)
|
|
213
214
|
|
|
214
|
-
|
|
215
|
+
const needsZod = hasInput || !!parsed.responseSchema
|
|
216
|
+
if (needsZod) {
|
|
217
|
+
lines.push("import { z } from 'zod'")
|
|
218
|
+
}
|
|
215
219
|
lines.push("import { pikkuSessionlessFunc } from '#pikku'")
|
|
216
220
|
|
|
217
221
|
if (errorClasses.length > 0) {
|
|
@@ -301,10 +305,7 @@ function buildInputSchema(
|
|
|
301
305
|
if (propSchema.readOnly) continue
|
|
302
306
|
const isOptional = !requiredSet.has(key)
|
|
303
307
|
const zodCode = schemaToZod(propSchema, ctx, { optional: isOptional })
|
|
304
|
-
|
|
305
|
-
? `${zodCode}.describe(${JSON.stringify(propSchema.description)})`
|
|
306
|
-
: zodCode
|
|
307
|
-
props.push(` ${safeKey(key)}: ${withDesc},`)
|
|
308
|
+
props.push(` ${safeKey(key)}: ${zodCode},`)
|
|
308
309
|
}
|
|
309
310
|
} else {
|
|
310
311
|
const bodyZod = schemaToZod(parsed.requestBody, ctx)
|
|
@@ -386,7 +387,9 @@ function generateServiceFile(
|
|
|
386
387
|
// Always import all error classes used in the switch statement
|
|
387
388
|
const allErrorClasses = new Set<string>(Object.values(STATUS_TO_ERROR))
|
|
388
389
|
|
|
389
|
-
if (flags.
|
|
390
|
+
if (flags.credential && flags.credential !== 'oauth2') {
|
|
391
|
+
// Per-user credential: no special imports needed, creds passed via constructor
|
|
392
|
+
} else if (flags.oauth || flags.credential === 'oauth2') {
|
|
390
393
|
lines.push("import { OAuth2Client } from '@pikku/core/oauth2'")
|
|
391
394
|
lines.push(
|
|
392
395
|
"import type { TypedSecretService } from '#pikku/secrets/pikku-secrets.gen.js'"
|
|
@@ -458,7 +461,17 @@ function generateServiceFile(
|
|
|
458
461
|
lines.push(`export class ${pascalName}Service {`)
|
|
459
462
|
lines.push(' private baseUrl: string')
|
|
460
463
|
|
|
461
|
-
if (flags.
|
|
464
|
+
if (flags.credential && flags.credential !== 'oauth2') {
|
|
465
|
+
const credField = flags.credential === 'bearer' ? 'token' : 'apiKey'
|
|
466
|
+
lines.push('')
|
|
467
|
+
lines.push(
|
|
468
|
+
` constructor(private creds: { ${credField}: string }, variables: TypedVariablesService) {`
|
|
469
|
+
)
|
|
470
|
+
lines.push(
|
|
471
|
+
` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`
|
|
472
|
+
)
|
|
473
|
+
lines.push(' }')
|
|
474
|
+
} else if (flags.oauth || flags.credential === 'oauth2') {
|
|
462
475
|
lines.push(' private oauth: OAuth2Client')
|
|
463
476
|
lines.push('')
|
|
464
477
|
lines.push(
|
|
@@ -548,7 +561,30 @@ function generateServiceFile(
|
|
|
548
561
|
lines.push(' }')
|
|
549
562
|
lines.push('')
|
|
550
563
|
|
|
551
|
-
if (flags.
|
|
564
|
+
if (flags.credential && flags.credential !== 'oauth2') {
|
|
565
|
+
// Per-user credential: use creds from wire.getCredentials()
|
|
566
|
+
if (flags.credential === 'bearer') {
|
|
567
|
+
lines.push(' headers.Authorization = `Bearer ${this.creds.token}`')
|
|
568
|
+
} else {
|
|
569
|
+
// apikey: check spec for custom header name
|
|
570
|
+
const apiKeyScheme = Object.values(spec.securitySchemes).find(
|
|
571
|
+
(s) => s.type === 'apiKey'
|
|
572
|
+
)
|
|
573
|
+
if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
|
|
574
|
+
lines.push(
|
|
575
|
+
` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`
|
|
576
|
+
)
|
|
577
|
+
} else {
|
|
578
|
+
lines.push(' headers.Authorization = `Bearer ${this.creds.apiKey}`')
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
lines.push('')
|
|
582
|
+
lines.push(' const response = await fetch(url.toString(), {')
|
|
583
|
+
lines.push(' method,')
|
|
584
|
+
lines.push(' headers,')
|
|
585
|
+
lines.push(' body: body ? JSON.stringify(body) : undefined,')
|
|
586
|
+
lines.push(' })')
|
|
587
|
+
} else if (flags.oauth || flags.credential === 'oauth2') {
|
|
552
588
|
lines.push(
|
|
553
589
|
' const response = await this.oauth.request(url.toString(), {'
|
|
554
590
|
)
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { strict as assert } from 'assert'
|
|
2
2
|
import { describe, test } from 'node:test'
|
|
3
|
-
import {
|
|
3
|
+
import { writeFile, rm, mkdtemp } from 'fs/promises'
|
|
4
|
+
import { join } from 'path'
|
|
5
|
+
import { tmpdir } from 'os'
|
|
6
|
+
import { computeContractHash, parseOpenAPISpec } from './parse-openapi.js'
|
|
4
7
|
import type { ParsedSpec } from './parse-openapi.js'
|
|
5
8
|
|
|
6
9
|
function makeSpec(overrides: Partial<ParsedSpec> = {}): ParsedSpec {
|
|
@@ -148,3 +151,151 @@ describe('computeContractHash', () => {
|
|
|
148
151
|
)
|
|
149
152
|
})
|
|
150
153
|
})
|
|
154
|
+
|
|
155
|
+
describe('parseOpenAPISpec — Swagger 2.0', () => {
|
|
156
|
+
const swagger2Spec = {
|
|
157
|
+
swagger: '2.0',
|
|
158
|
+
info: { title: 'Test API', version: '1.0.0' },
|
|
159
|
+
host: 'api.example.com',
|
|
160
|
+
basePath: '/v1',
|
|
161
|
+
schemes: ['https'],
|
|
162
|
+
definitions: {
|
|
163
|
+
Pet: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
required: ['name'],
|
|
166
|
+
properties: {
|
|
167
|
+
id: { type: 'integer', format: 'int64' },
|
|
168
|
+
name: { type: 'string' },
|
|
169
|
+
status: { type: 'string', enum: ['available', 'sold'], description: 'pet status' },
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
securityDefinitions: {
|
|
174
|
+
api_key: { type: 'apiKey', name: 'api_key', in: 'header' },
|
|
175
|
+
},
|
|
176
|
+
paths: {
|
|
177
|
+
'/pets': {
|
|
178
|
+
get: {
|
|
179
|
+
operationId: 'listPets',
|
|
180
|
+
summary: 'List all pets',
|
|
181
|
+
parameters: [
|
|
182
|
+
{ in: 'query', name: 'status', type: 'string', description: 'Filter by status' },
|
|
183
|
+
],
|
|
184
|
+
responses: {
|
|
185
|
+
'200': {
|
|
186
|
+
description: 'A list of pets',
|
|
187
|
+
schema: { type: 'array', items: { $ref: '#/definitions/Pet' } },
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
post: {
|
|
192
|
+
operationId: 'createPet',
|
|
193
|
+
summary: 'Create a pet',
|
|
194
|
+
parameters: [
|
|
195
|
+
{
|
|
196
|
+
in: 'body',
|
|
197
|
+
name: 'body',
|
|
198
|
+
description: 'Pet to create',
|
|
199
|
+
required: true,
|
|
200
|
+
schema: { $ref: '#/definitions/Pet' },
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
responses: {
|
|
204
|
+
'201': {
|
|
205
|
+
description: 'Created',
|
|
206
|
+
schema: { $ref: '#/definitions/Pet' },
|
|
207
|
+
},
|
|
208
|
+
'400': { description: 'Bad request' },
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
'/pets/{petId}': {
|
|
213
|
+
get: {
|
|
214
|
+
operationId: 'getPet',
|
|
215
|
+
parameters: [
|
|
216
|
+
{ in: 'path', name: 'petId', type: 'integer', required: true },
|
|
217
|
+
],
|
|
218
|
+
responses: {
|
|
219
|
+
'200': {
|
|
220
|
+
description: 'A pet',
|
|
221
|
+
schema: { $ref: '#/definitions/Pet' },
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let tmpDir: string
|
|
230
|
+
let specPath: string
|
|
231
|
+
|
|
232
|
+
test('setup', async () => {
|
|
233
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'openapi-test-'))
|
|
234
|
+
specPath = join(tmpDir, 'spec.json')
|
|
235
|
+
await writeFile(specPath, JSON.stringify(swagger2Spec))
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('extracts component schemas from definitions', async () => {
|
|
239
|
+
const spec = await parseOpenAPISpec(specPath)
|
|
240
|
+
assert.ok(spec.componentSchemas.Pet)
|
|
241
|
+
assert.equal(spec.componentSchemas.Pet.type, 'object')
|
|
242
|
+
assert.ok(spec.componentSchemas.Pet.properties?.name)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('extracts server URLs from host/basePath/schemes', async () => {
|
|
246
|
+
const spec = await parseOpenAPISpec(specPath)
|
|
247
|
+
assert.deepEqual(spec.serverUrls, ['https://api.example.com/v1'])
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('detects apiKey auth from securityDefinitions', async () => {
|
|
251
|
+
const spec = await parseOpenAPISpec(specPath)
|
|
252
|
+
assert.equal(spec.authType, 'apiKey')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('extracts requestBody from body parameter', async () => {
|
|
256
|
+
const spec = await parseOpenAPISpec(specPath)
|
|
257
|
+
const createPet = spec.operations.find(op => op.operationId === 'createPet')
|
|
258
|
+
assert.ok(createPet?.requestBody)
|
|
259
|
+
assert.equal(createPet.requestBody.type, 'object')
|
|
260
|
+
assert.ok(createPet.requestBody.properties?.name)
|
|
261
|
+
assert.equal(createPet.requestBodyRequired, true)
|
|
262
|
+
assert.equal(createPet.requestBodyDescription, 'Pet to create')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('extracts responseSchema from Swagger 2.0 response', async () => {
|
|
266
|
+
const spec = await parseOpenAPISpec(specPath)
|
|
267
|
+
const createPet = spec.operations.find(op => op.operationId === 'createPet')
|
|
268
|
+
assert.ok(createPet?.responseSchema)
|
|
269
|
+
assert.equal(createPet.responseSchema.type, 'object')
|
|
270
|
+
|
|
271
|
+
const listPets = spec.operations.find(op => op.operationId === 'listPets')
|
|
272
|
+
assert.ok(listPets?.responseSchema)
|
|
273
|
+
assert.equal(listPets.responseSchema.type, 'array')
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('extracts query params', async () => {
|
|
277
|
+
const spec = await parseOpenAPISpec(specPath)
|
|
278
|
+
const listPets = spec.operations.find(op => op.operationId === 'listPets')
|
|
279
|
+
assert.equal(listPets?.queryParams.length, 1)
|
|
280
|
+
assert.equal(listPets?.queryParams[0].name, 'status')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('extracts path params', async () => {
|
|
284
|
+
const spec = await parseOpenAPISpec(specPath)
|
|
285
|
+
const getPet = spec.operations.find(op => op.operationId === 'getPet')
|
|
286
|
+
assert.equal(getPet?.pathParams.length, 1)
|
|
287
|
+
assert.equal(getPet?.pathParams[0].name, 'petId')
|
|
288
|
+
assert.equal(getPet?.pathParams[0].required, true)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('extracts error responses', async () => {
|
|
292
|
+
const spec = await parseOpenAPISpec(specPath)
|
|
293
|
+
const createPet = spec.operations.find(op => op.operationId === 'createPet')
|
|
294
|
+
assert.equal(createPet?.errorResponses.length, 1)
|
|
295
|
+
assert.equal(createPet?.errorResponses[0].statusCode, 400)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('cleanup', async () => {
|
|
299
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
300
|
+
})
|
|
301
|
+
})
|
package/src/parse-openapi.ts
CHANGED
|
@@ -106,12 +106,11 @@ export async function parseOpenAPISpec(filePath: string): Promise<ParsedSpec> {
|
|
|
106
106
|
const securitySchemes = extractSecuritySchemes(doc)
|
|
107
107
|
const tagDescriptions = extractTagDescriptions(doc)
|
|
108
108
|
|
|
109
|
-
// Extract component schemas
|
|
109
|
+
// Extract component schemas (OpenAPI 3.x: components.schemas, Swagger 2.x: definitions)
|
|
110
110
|
const componentSchemas: Record<string, OpenAPISchema> = {}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
111
|
+
const rawSchemas = doc.components?.schemas ?? doc.definitions ?? {}
|
|
112
|
+
for (const [name, schema] of Object.entries(rawSchemas)) {
|
|
113
|
+
componentSchemas[name] = schema as OpenAPISchema
|
|
115
114
|
}
|
|
116
115
|
|
|
117
116
|
// Extract operations (skip deprecated)
|
|
@@ -132,7 +131,9 @@ export async function parseOpenAPISpec(filePath: string): Promise<ParsedSpec> {
|
|
|
132
131
|
|
|
133
132
|
const allParams: any[] = [...sharedParams, ...(op.parameters ?? [])]
|
|
134
133
|
|
|
135
|
-
|
|
134
|
+
// OpenAPI 3.x: op.requestBody, Swagger 2.x: parameters[].in === 'body'
|
|
135
|
+
const body3 = op.requestBody
|
|
136
|
+
const body2 = allParams.find((p: any) => p.in === 'body')
|
|
136
137
|
|
|
137
138
|
operations.push({
|
|
138
139
|
operationId: op.operationId,
|
|
@@ -145,8 +146,8 @@ export async function parseOpenAPISpec(filePath: string): Promise<ParsedSpec> {
|
|
|
145
146
|
queryParams: extractParams(allParams, 'query'),
|
|
146
147
|
headerParams: extractParams(allParams, 'header'),
|
|
147
148
|
requestBody: extractRequestBody(op),
|
|
148
|
-
requestBodyDescription:
|
|
149
|
-
requestBodyRequired:
|
|
149
|
+
requestBodyDescription: body3?.description ?? body2?.description,
|
|
150
|
+
requestBodyRequired: body3?.required ?? body2?.required,
|
|
150
151
|
responseSchema: extractResponseSchema(op),
|
|
151
152
|
responseDescription: extractResponseDescription(op),
|
|
152
153
|
errorResponses: extractErrorResponses(op),
|
|
@@ -266,20 +267,25 @@ function extractParams(
|
|
|
266
267
|
}
|
|
267
268
|
|
|
268
269
|
function extractRequestBody(op: any): OpenAPISchema | undefined {
|
|
270
|
+
// OpenAPI 3.x: op.requestBody.content['application/json'].schema
|
|
269
271
|
const body = op.requestBody
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
272
|
+
if (body) {
|
|
273
|
+
const content = body.content
|
|
274
|
+
if (content) {
|
|
275
|
+
const jsonContent = content['application/json']
|
|
276
|
+
if (jsonContent?.schema) return jsonContent.schema as OpenAPISchema
|
|
274
277
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
+
const firstKey = Object.keys(content)[0]
|
|
279
|
+
if (firstKey && content[firstKey]?.schema) {
|
|
280
|
+
return content[firstKey].schema as OpenAPISchema
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
278
284
|
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
return
|
|
285
|
+
// Swagger 2.x: parameters[].in === 'body' → schema
|
|
286
|
+
if (op.parameters) {
|
|
287
|
+
const bodyParam = op.parameters.find((p: any) => p.in === 'body')
|
|
288
|
+
if (bodyParam?.schema) return bodyParam.schema as OpenAPISchema
|
|
283
289
|
}
|
|
284
290
|
|
|
285
291
|
return undefined
|
|
@@ -304,24 +310,30 @@ function extractResponseSchema(op: any): OpenAPISchema | undefined {
|
|
|
304
310
|
const resp = responses[code]
|
|
305
311
|
if (!resp) continue
|
|
306
312
|
|
|
313
|
+
// OpenAPI 3.x: resp.content['application/json'].schema
|
|
307
314
|
const content = resp.content
|
|
308
|
-
if (
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (jsonContent?.schema) return jsonContent.schema as OpenAPISchema
|
|
315
|
+
if (content) {
|
|
316
|
+
const jsonContent = content['application/json']
|
|
317
|
+
if (jsonContent?.schema) return jsonContent.schema as OpenAPISchema
|
|
312
318
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
319
|
+
const firstKey = Object.keys(content)[0]
|
|
320
|
+
if (firstKey && content[firstKey]?.schema) {
|
|
321
|
+
return content[firstKey].schema as OpenAPISchema
|
|
322
|
+
}
|
|
316
323
|
}
|
|
324
|
+
|
|
325
|
+
// Swagger 2.x: resp.schema directly
|
|
326
|
+
if (resp.schema) return resp.schema as OpenAPISchema
|
|
317
327
|
}
|
|
318
328
|
|
|
319
329
|
// Fallback: any 2xx
|
|
320
330
|
for (const [code, resp] of Object.entries(responses) as [string, any][]) {
|
|
321
|
-
if (code.startsWith('2')
|
|
331
|
+
if (!code.startsWith('2')) continue
|
|
332
|
+
if (resp.content) {
|
|
322
333
|
const jsonContent = resp.content['application/json']
|
|
323
334
|
if (jsonContent?.schema) return jsonContent.schema as OpenAPISchema
|
|
324
335
|
}
|
|
336
|
+
if (resp.schema) return resp.schema as OpenAPISchema
|
|
325
337
|
}
|
|
326
338
|
|
|
327
339
|
return undefined
|