@pikku/openapi-parser 0.12.21 → 0.12.23

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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { parseOpenAPISpec, computeContractHash, type ParsedSpec, } from './parse-openapi.js';
1
+ export { parseOpenAPISpec, computeContractHash, filterOperations, specCoverageWarning, detectLoginOperation, type ParsedSpec, type ParseOptions, type AuthType, type OperationFilter, type LoginOperation, } from './parse-openapi.js';
2
2
  export { generateAddonFromOpenAPI } from './codegen.js';
3
3
  export { loadAuthConfig, type AuthConfig, type DelegatedLoginConfig, } from './auth-config.js';
4
4
  export { type NamedOperation } from './naming.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { parseOpenAPISpec, computeContractHash, } from './parse-openapi.js';
1
+ export { parseOpenAPISpec, computeContractHash, filterOperations, specCoverageWarning, detectLoginOperation, } from './parse-openapi.js';
2
2
  export { generateAddonFromOpenAPI } from './codegen.js';
3
3
  export { loadAuthConfig, } from './auth-config.js';
4
4
  export { createContext, schemaToZod, schemaVarName, sanitizeTypeName, } from './openapi-to-zod-schema.js';
@@ -4,7 +4,7 @@ export interface ErrorResponse {
4
4
  description: string;
5
5
  }
6
6
  export interface SecuritySchemeInfo {
7
- type: 'oauth2' | 'http' | 'apiKey';
7
+ type: 'oauth2' | 'http' | 'apiKey' | 'openIdConnect';
8
8
  scheme?: string;
9
9
  bearerFormat?: string;
10
10
  name?: string;
@@ -23,12 +23,17 @@ export interface ParsedSpec {
23
23
  };
24
24
  baseUrl: string;
25
25
  serverUrls: string[];
26
- authType: 'bearer' | 'oauth2' | 'apiKey' | 'none';
26
+ authType: AuthType;
27
27
  operations: ParsedOperation[];
28
28
  componentSchemas: Record<string, OpenAPISchema>;
29
29
  securitySchemes: Record<string, SecuritySchemeInfo>;
30
30
  tagDescriptions: Record<string, string>;
31
31
  }
32
+ export type AuthType = 'bearer' | 'oauth2' | 'apiKey' | 'basic' | 'none';
33
+ export interface ParseOptions {
34
+ /** Sent with the request when the spec is fetched from a URL. */
35
+ headers?: Record<string, string>;
36
+ }
32
37
  export interface ParsedOperation {
33
38
  operationId?: string;
34
39
  method: string;
@@ -42,7 +47,9 @@ export interface ParsedOperation {
42
47
  requestBody?: OpenAPISchema;
43
48
  requestBodyDescription?: string;
44
49
  requestBodyRequired?: boolean;
50
+ requestBodyMediaType?: string;
45
51
  responseSchema?: OpenAPISchema;
52
+ responseMediaType?: string;
46
53
  responseDescription?: string;
47
54
  errorResponses: ErrorResponse[];
48
55
  deprecated: boolean;
@@ -55,13 +62,32 @@ export interface ParsedParam {
55
62
  example?: unknown;
56
63
  }
57
64
  /**
58
- * Read and parse an OpenAPI spec from a file path.
59
- * Supports both YAML (.yaml, .yml) and JSON (.json) files.
65
+ * Read and parse an OpenAPI spec from a file path or an http(s) URL.
66
+ * JSON and YAML are both accepted, whatever the extension.
60
67
  */
61
- export declare function parseOpenAPISpec(filePath: string): Promise<ParsedSpec>;
68
+ export declare function parseOpenAPISpec(source: string, options?: ParseOptions): Promise<ParsedSpec>;
62
69
  /**
63
70
  * Compute a SHA-256 hash of the contract-relevant parts of a parsed spec.
64
71
  * Excludes presentation fields (summary, description, tags, operationId)
65
72
  * so that only structural API changes produce a different hash.
66
73
  */
67
74
  export declare function computeContractHash(spec: ParsedSpec): string;
75
+ export interface OperationFilter {
76
+ tags?: string[];
77
+ include?: string[];
78
+ exclude?: string[];
79
+ }
80
+ export declare function filterOperations(spec: ParsedSpec, filter: OperationFilter): ParsedSpec;
81
+ /**
82
+ * Specs served by API explorers often hide every authenticated route until
83
+ * the request carries a key, so a spec that is nearly empty — or holds only
84
+ * its own login route — is a sign the real one was never fetched.
85
+ */
86
+ export declare function specCoverageWarning(spec: ParsedSpec): string | undefined;
87
+ export interface LoginOperation {
88
+ method: string;
89
+ path: string;
90
+ tokenPath?: string;
91
+ }
92
+ /** A login route that trades credentials for a token, if the spec has one. */
93
+ export declare function detectLoginOperation(spec: ParsedSpec): LoginOperation | undefined;
@@ -5,18 +5,17 @@ import { createHash } from 'crypto';
5
5
  import { readFile } from 'fs/promises';
6
6
  import { parse as parseYAML } from 'yaml';
7
7
  /**
8
- * Read and parse an OpenAPI spec from a file path.
9
- * Supports both YAML (.yaml, .yml) and JSON (.json) files.
8
+ * Read and parse an OpenAPI spec from a file path or an http(s) URL.
9
+ * JSON and YAML are both accepted, whatever the extension.
10
10
  */
11
- export async function parseOpenAPISpec(filePath) {
12
- const content = await readFile(filePath, 'utf-8');
13
- let doc;
14
- if (filePath.endsWith('.json')) {
15
- doc = JSON.parse(content);
16
- }
17
- else {
18
- doc = parseYAML(content);
19
- }
11
+ export async function parseOpenAPISpec(source, options = {}) {
12
+ const isUrl = /^https?:\/\//i.test(source);
13
+ const content = isUrl
14
+ ? await fetchSpec(source, options.headers ?? {})
15
+ : await readFile(source, 'utf-8');
16
+ const doc = content.trimStart().startsWith('{')
17
+ ? JSON.parse(content)
18
+ : parseYAML(content);
20
19
  // Validate spec version
21
20
  const specVersion = doc.openapi ?? doc.swagger;
22
21
  if (!specVersion) {
@@ -33,7 +32,7 @@ export async function parseOpenAPISpec(filePath) {
33
32
  version: doc.info?.version ?? '1.0.0',
34
33
  description: doc.info?.description,
35
34
  };
36
- const serverUrls = extractServerUrls(doc);
35
+ const serverUrls = extractServerUrls(doc, isUrl ? source : undefined);
37
36
  const baseUrl = serverUrls[0] ?? '';
38
37
  const authType = detectAuthType(doc);
39
38
  const securitySchemes = extractSecuritySchemes(doc);
@@ -61,6 +60,8 @@ export async function parseOpenAPISpec(filePath) {
61
60
  // OpenAPI 3.x: op.requestBody, Swagger 2.x: parameters[].in === 'body'
62
61
  const body3 = op.requestBody;
63
62
  const body2 = allParams.find((p) => p.in === 'body');
63
+ const body = extractRequestBody(op, allParams, doc);
64
+ const response = extractResponse(op, doc);
64
65
  operations.push({
65
66
  operationId: op.operationId,
66
67
  method,
@@ -71,10 +72,12 @@ export async function parseOpenAPISpec(filePath) {
71
72
  pathParams: extractParams(allParams, 'path'),
72
73
  queryParams: extractParams(allParams, 'query'),
73
74
  headerParams: extractParams(allParams, 'header'),
74
- requestBody: extractRequestBody(op),
75
+ requestBody: body?.schema,
76
+ requestBodyMediaType: body?.mediaType,
75
77
  requestBodyDescription: body3?.description ?? body2?.description,
76
- requestBodyRequired: body3?.required ?? body2?.required,
77
- responseSchema: extractResponseSchema(op),
78
+ requestBodyRequired: body3?.required ?? body2?.required ?? body?.required,
79
+ responseSchema: response?.schema,
80
+ responseMediaType: response?.mediaType,
78
81
  responseDescription: extractResponseDescription(op),
79
82
  errorResponses: extractErrorResponses(op),
80
83
  deprecated: false,
@@ -137,16 +140,44 @@ function resolveRefPath(ref, root) {
137
140
  }
138
141
  return current;
139
142
  }
140
- function extractServerUrls(doc) {
143
+ async function fetchSpec(url, headers) {
144
+ const response = await fetch(url, { headers });
145
+ if (!response.ok) {
146
+ const hint = response.status === 401 || response.status === 403
147
+ ? ' — the spec needs credentials; pass them with --openapi-header "NAME: value"'
148
+ : '';
149
+ throw new Error(`Could not fetch the spec from ${url}: HTTP ${response.status}${hint}`);
150
+ }
151
+ return response.text();
152
+ }
153
+ const trimSlash = (url) => url.replace(/\/+$/, '');
154
+ function resolveServerUrl(server, specUrl) {
155
+ if (typeof server?.url !== 'string' || !server.url)
156
+ return undefined;
157
+ const url = server.url.replace(/\{([^}]+)\}/g, (match, name) => server.variables?.[name]?.default ?? match);
158
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url))
159
+ return trimSlash(url);
160
+ if (specUrl)
161
+ return trimSlash(new URL(url, specUrl).toString());
162
+ return trimSlash(url);
163
+ }
164
+ function extractServerUrls(doc, specUrl) {
141
165
  // OpenAPI 3.x
142
166
  if (doc.servers && doc.servers.length > 0) {
143
- return doc.servers.map((s) => s.url).filter((url) => !!url);
167
+ return doc.servers
168
+ .map((server) => resolveServerUrl(server, specUrl))
169
+ .filter((url) => !!url);
144
170
  }
145
- // Swagger 2.x
146
- if (doc.host) {
147
- const schemes = doc.schemes?.length ? doc.schemes : ['https'];
171
+ // Swagger 2.x: host defaults to the one serving the document
172
+ const host = doc.host ?? (specUrl ? new URL(specUrl).host : undefined);
173
+ if (host) {
174
+ const schemes = doc.schemes?.length
175
+ ? doc.schemes
176
+ : specUrl
177
+ ? [new URL(specUrl).protocol.replace(':', '')]
178
+ : ['https'];
148
179
  const basePath = doc.basePath ?? '';
149
- return schemes.map((scheme) => `${scheme}://${doc.host}${basePath}`);
180
+ return schemes.map((scheme) => trimSlash(`${scheme}://${host}${basePath}`));
150
181
  }
151
182
  return [];
152
183
  }
@@ -155,10 +186,13 @@ function detectAuthType(doc) {
155
186
  for (const scheme of Object.values(securitySchemes)) {
156
187
  if (scheme.type === 'oauth2')
157
188
  return 'oauth2';
158
- if (scheme.type === 'http' && scheme.scheme === 'bearer')
189
+ if (scheme.type === 'http' && scheme.scheme?.toLowerCase() === 'bearer')
159
190
  return 'bearer';
160
191
  if (scheme.type === 'apiKey')
161
192
  return 'apiKey';
193
+ if (scheme.type === 'basic' ||
194
+ (scheme.type === 'http' && scheme.scheme?.toLowerCase() === 'basic'))
195
+ return 'basic';
162
196
  }
163
197
  return 'none';
164
198
  }
@@ -189,26 +223,49 @@ function extractParams(params, location) {
189
223
  example: p.example,
190
224
  }));
191
225
  }
192
- function extractRequestBody(op) {
193
- // OpenAPI 3.x: op.requestBody.content['application/json'].schema
194
- const body = op.requestBody;
195
- if (body) {
196
- const content = body.content;
197
- if (content) {
198
- const jsonContent = content['application/json'];
199
- if (jsonContent?.schema)
200
- return jsonContent.schema;
201
- const firstKey = Object.keys(content)[0];
202
- if (firstKey && content[firstKey]?.schema) {
203
- return content[firstKey].schema;
204
- }
205
- }
206
- }
226
+ function pickContent(content) {
227
+ if (!content)
228
+ return undefined;
229
+ const mediaTypes = Object.keys(content);
230
+ const mediaType = mediaTypes.find((type) => /[/+]json\b/i.test(type) && content[type]?.schema) ?? mediaTypes.find((type) => content[type]?.schema);
231
+ if (!mediaType)
232
+ return undefined;
233
+ return { schema: content[mediaType].schema, mediaType };
234
+ }
235
+ function extractRequestBody(op, params, doc) {
236
+ // OpenAPI 3.x: op.requestBody.content[<media type>].schema
237
+ const fromContent = pickContent(op.requestBody?.content);
238
+ if (fromContent)
239
+ return fromContent;
207
240
  // Swagger 2.x: parameters[].in === 'body' → schema
208
- if (op.parameters) {
209
- const bodyParam = op.parameters.find((p) => p.in === 'body');
210
- if (bodyParam?.schema)
211
- return bodyParam.schema;
241
+ const consumes = op.consumes ?? doc.consumes ?? [];
242
+ const bodyParam = params.find((p) => p.in === 'body');
243
+ if (bodyParam?.schema) {
244
+ return {
245
+ schema: bodyParam.schema,
246
+ mediaType: consumes[0] ?? 'application/json',
247
+ };
248
+ }
249
+ // Swagger 2.x: parameters[].in === 'formData' → one object body
250
+ const formParams = params.filter((p) => p.in === 'formData');
251
+ if (formParams.length > 0) {
252
+ const properties = {};
253
+ const required = [];
254
+ for (const p of formParams) {
255
+ properties[p.name] = {
256
+ ...paramToSchema(p),
257
+ ...(p.description ? { description: p.description } : {}),
258
+ };
259
+ if (p.required)
260
+ required.push(p.name);
261
+ }
262
+ return {
263
+ schema: { type: 'object', properties, required },
264
+ mediaType: consumes.includes('multipart/form-data')
265
+ ? 'multipart/form-data'
266
+ : 'application/x-www-form-urlencoded',
267
+ required: required.length > 0,
268
+ };
212
269
  }
213
270
  return undefined;
214
271
  }
@@ -223,41 +280,38 @@ function extractResponseDescription(op) {
223
280
  }
224
281
  return undefined;
225
282
  }
226
- function extractResponseSchema(op) {
283
+ function extractResponse(op, doc) {
227
284
  const responses = op.responses;
228
285
  if (!responses)
229
286
  return undefined;
230
- // Look for 2xx responses in order of preference
231
- for (const code of ['200', '201', '202', '204']) {
232
- const resp = responses[code];
287
+ const produces = op.produces ?? doc.produces ?? [];
288
+ const fromResponse = (resp) => {
233
289
  if (!resp)
234
- continue;
235
- // OpenAPI 3.x: resp.content['application/json'].schema
236
- const content = resp.content;
237
- if (content) {
238
- const jsonContent = content['application/json'];
239
- if (jsonContent?.schema)
240
- return jsonContent.schema;
241
- const firstKey = Object.keys(content)[0];
242
- if (firstKey && content[firstKey]?.schema) {
243
- return content[firstKey].schema;
244
- }
290
+ return undefined;
291
+ const fromContent = pickContent(resp.content);
292
+ if (fromContent)
293
+ return fromContent;
294
+ if (resp.schema) {
295
+ return {
296
+ schema: resp.schema,
297
+ mediaType: produces.find((type) => /[/+]json\b/i.test(type)) ??
298
+ produces[0] ??
299
+ 'application/json',
300
+ };
245
301
  }
246
- // Swagger 2.x: resp.schema directly
247
- if (resp.schema)
248
- return resp.schema;
302
+ return undefined;
303
+ };
304
+ for (const code of ['200', '201', '202', '204']) {
305
+ const found = fromResponse(responses[code]);
306
+ if (found)
307
+ return found;
249
308
  }
250
- // Fallback: any 2xx
251
309
  for (const [code, resp] of Object.entries(responses)) {
252
310
  if (!code.startsWith('2'))
253
311
  continue;
254
- if (resp.content) {
255
- const jsonContent = resp.content['application/json'];
256
- if (jsonContent?.schema)
257
- return jsonContent.schema;
258
- }
259
- if (resp.schema)
260
- return resp.schema;
312
+ const found = fromResponse(resp);
313
+ if (found)
314
+ return found;
261
315
  }
262
316
  return undefined;
263
317
  }
@@ -284,8 +338,11 @@ function extractSecuritySchemes(doc) {
284
338
  const result = {};
285
339
  for (const [name, scheme] of Object.entries(raw)) {
286
340
  const info = {
287
- type: scheme.type === 'http' ? 'http' : scheme.type,
341
+ type: scheme.type === 'basic' ? 'http' : scheme.type,
288
342
  };
343
+ if (scheme.type === 'basic') {
344
+ info.scheme = 'basic';
345
+ }
289
346
  if (scheme.type === 'http') {
290
347
  info.scheme = scheme.scheme;
291
348
  info.bearerFormat = scheme.bearerFormat;
@@ -296,10 +353,12 @@ function extractSecuritySchemes(doc) {
296
353
  }
297
354
  if (scheme.type === 'oauth2') {
298
355
  // Extract flows — prefer authorizationCode, then implicit, then clientCredentials
299
- const flows = scheme.flows ?? {};
300
- const flow = flows.authorizationCode ??
356
+ const flows = scheme.flows ?? (scheme.flow ? { [scheme.flow]: scheme } : {});
357
+ const flow = flows.accessCode ??
358
+ flows.authorizationCode ??
301
359
  flows.implicit ??
302
360
  flows.clientCredentials ??
361
+ flows.application ??
303
362
  flows.password;
304
363
  if (flow) {
305
364
  info.flows = {
@@ -371,3 +430,72 @@ function extractTagDescriptions(doc) {
371
430
  }
372
431
  return result;
373
432
  }
433
+ const globToRegExp = (glob) => new RegExp(`^${glob
434
+ .split('*')
435
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
436
+ .join('.*')}$`, 'i');
437
+ /**
438
+ * `include` / `exclude` patterns match an operationId, a path, or
439
+ * `METHOD /path`, with `*` as a wildcard.
440
+ */
441
+ function matchesAny(op, patterns) {
442
+ const candidates = [
443
+ op.operationId ?? '',
444
+ op.path,
445
+ `${op.method.toUpperCase()} ${op.path}`,
446
+ ];
447
+ return patterns
448
+ .map(globToRegExp)
449
+ .some((pattern) => candidates.some((c) => pattern.test(c)));
450
+ }
451
+ export function filterOperations(spec, filter) {
452
+ const tags = filter.tags?.map((tag) => tag.toLowerCase());
453
+ const operations = spec.operations.filter((op) => (!tags?.length ||
454
+ op.tags.some((tag) => tags.includes(tag.toLowerCase()))) &&
455
+ (!filter.include?.length || matchesAny(op, filter.include)) &&
456
+ !(filter.exclude?.length && matchesAny(op, filter.exclude)));
457
+ return { ...spec, operations };
458
+ }
459
+ const AUTH_ROUTE = /(^|\/)(log-?in|log-?out|sign-?in|sign-?out|auth\w*|token|session|oauth2?)(\/|$)/i;
460
+ /**
461
+ * Specs served by API explorers often hide every authenticated route until
462
+ * the request carries a key, so a spec that is nearly empty — or holds only
463
+ * its own login route — is a sign the real one was never fetched.
464
+ */
465
+ export function specCoverageWarning(spec) {
466
+ const count = spec.operations.length;
467
+ const onlyAuth = count > 0 && spec.operations.every((op) => AUTH_ROUTE.test(op.path));
468
+ if (count >= 5 && !onlyAuth)
469
+ return undefined;
470
+ const what = onlyAuth
471
+ ? `only authentication routes (${spec.operations.map((op) => `${op.method.toUpperCase()} ${op.path}`).join(', ')})`
472
+ : `only ${count} operation${count === 1 ? '' : 's'}`;
473
+ return `The spec has ${what}. Many APIs publish their full spec only to an authenticated request — fetch it again with credentials (--openapi-header "NAME: value", or the key in the URL's query string if the API reads it there) before generating, or the addon will be missing most of the API.`;
474
+ }
475
+ const TOKEN_KEY = /^(access_?token|token|id_?token|jwt|api_?key|session_?token)$/i;
476
+ function findTokenPath(schema, prefix = '', depth = 0) {
477
+ if (!schema || typeof schema !== 'object' || depth > 3)
478
+ return undefined;
479
+ for (const [key, prop] of Object.entries(schema.properties ?? {})) {
480
+ const path = prefix ? `${prefix}.${key}` : key;
481
+ if (TOKEN_KEY.test(key))
482
+ return path;
483
+ const nested = findTokenPath(prop, path, depth + 1);
484
+ if (nested)
485
+ return nested;
486
+ }
487
+ return undefined;
488
+ }
489
+ /** A login route that trades credentials for a token, if the spec has one. */
490
+ export function detectLoginOperation(spec) {
491
+ const candidates = spec.operations.filter((op) => op.method === 'post' &&
492
+ /(^|\/)(log-?in|sign-?in|auth(enticate)?|token|sessions?)$/i.test(op.path));
493
+ for (const op of candidates) {
494
+ return {
495
+ method: op.method,
496
+ path: op.path,
497
+ tokenPath: findTokenPath(op.responseSchema),
498
+ };
499
+ }
500
+ return undefined;
501
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/openapi-parser",
3
- "version": "0.12.21",
3
+ "version": "0.12.23",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",