@scalar/postman-to-openapi 0.6.2 → 0.7.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +15 -0
  3. package/dist/convert.d.ts +14 -0
  4. package/dist/convert.d.ts.map +1 -1
  5. package/dist/convert.js +392 -44
  6. package/dist/helpers/header-utils.d.ts +21 -0
  7. package/dist/helpers/header-utils.d.ts.map +1 -0
  8. package/dist/helpers/header-utils.js +42 -0
  9. package/dist/helpers/merge-operation.d.ts.map +1 -1
  10. package/dist/helpers/merge-operation.js +120 -5
  11. package/dist/helpers/merge-path-item.d.ts.map +1 -1
  12. package/dist/helpers/merge-path-item.js +28 -4
  13. package/dist/helpers/parameters.d.ts +4 -1
  14. package/dist/helpers/parameters.d.ts.map +1 -1
  15. package/dist/helpers/parameters.js +40 -1
  16. package/dist/helpers/path-items.d.ts +11 -1
  17. package/dist/helpers/path-items.d.ts.map +1 -1
  18. package/dist/helpers/path-items.js +111 -11
  19. package/dist/helpers/request-body.d.ts +5 -1
  20. package/dist/helpers/request-body.d.ts.map +1 -1
  21. package/dist/helpers/request-body.js +21 -28
  22. package/dist/helpers/responses.d.ts +8 -1
  23. package/dist/helpers/responses.d.ts.map +1 -1
  24. package/dist/helpers/responses.js +99 -11
  25. package/dist/helpers/servers.d.ts.map +1 -1
  26. package/dist/helpers/servers.js +10 -6
  27. package/dist/helpers/urls.d.ts +20 -0
  28. package/dist/helpers/urls.d.ts.map +1 -1
  29. package/dist/helpers/urls.js +101 -3
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -0
  33. package/dist/is-postman-collection.d.ts +9 -0
  34. package/dist/is-postman-collection.d.ts.map +1 -0
  35. package/dist/is-postman-collection.js +23 -0
  36. package/dist/types.d.ts +1 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +6 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # @scalar/postman-to-openapi
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#8904](https://github.com/scalar/scalar/pull/8904): feat: don't keep all headers
8
+
9
+ ### Patch Changes
10
+
11
+ - [#8901](https://github.com/scalar/scalar/pull/8901): fix fixture generation formatting by writing output textures with 2-space indented JSON so regeneration does not produce noisy diffs when data is unchanged
12
+ - [#8901](https://github.com/scalar/scalar/pull/8901): Move Postman conversion fixtures into the repository and update tests to read local fixture files instead of downloading from cloud storage.
13
+ - [#8899](https://github.com/scalar/scalar/pull/8899): resolve Postman `{{variables}}` in server URLs by substituting collection variable values and emitting OpenAPI server variables when values are unresolved or recursive
14
+ - [#8900](https://github.com/scalar/scalar/pull/8900): fix(postman-to-openapi): preserve tag context when folder description is an empty string
15
+ - [#8900](https://github.com/scalar/scalar/pull/8900): feat(postman-to-openapi): default to leaf-based tag naming with chain fallback option
16
+ - [#8895](https://github.com/scalar/scalar/pull/8895): fix: omit response content for no-body status codes by default
17
+
18
+ ## 0.6.3
19
+
20
+ ### Patch Changes
21
+
22
+ - [#8903](https://github.com/scalar/scalar/pull/8903): fix: share Postman collection detection from postman-to-openapi
23
+ - [#8902](https://github.com/scalar/scalar/pull/8902): Preserve merged Postman request variants by carrying request-specific examples for parameters and request bodies, unioning status-code responses derived from request names, and concatenating pre-request and post-response script extensions when operations collapse into one path/method.
24
+ - [#8898](https://github.com/scalar/scalar/pull/8898): merge structurally equivalent paths that only differ by path parameter names and align path parameter definitions with the canonical path
25
+ - [#8887](https://github.com/scalar/scalar/pull/8887): fix: invalid json body handling and add root document for bulk operation selection
26
+ - [#8897](https://github.com/scalar/scalar/pull/8897): fix postman-to-openapi response descriptions to use status-aware defaults and parse named examples
27
+
3
28
  ## 0.6.2
4
29
 
5
30
  ## 0.6.1
package/README.md CHANGED
@@ -24,6 +24,21 @@ const result = await convert(myPostmanCollection)
24
24
  console.log(result)
25
25
  ```
26
26
 
27
+ ### Detect Postman collections
28
+
29
+ Use `isPostmanCollection` to decide whether an input string should be parsed as a Postman collection before converting.
30
+
31
+ ```ts
32
+ import { convert, isPostmanCollection } from '@scalar/postman-to-openapi'
33
+
34
+ if (isPostmanCollection(input)) {
35
+ const openApiDocument = convert(input)
36
+ console.log(openApiDocument)
37
+ }
38
+ ```
39
+
40
+ `isPostmanCollection` accepts exported collections that do not include `info._postman_id` as long as they contain a valid Postman schema URL and an `item` tree.
41
+
27
42
  ## Community
28
43
 
29
44
  We are API nerds. You too? Let's chat on Discord: <https://discord.gg/scalar>
package/dist/convert.d.ts CHANGED
@@ -5,6 +5,7 @@ import type { PostmanCollection } from './types.js';
5
5
  * Example: `[0, 2, 1]` → `collection.item[0].item[2].item[1]`.
6
6
  */
7
7
  export type PostmanRequestIndexPath = readonly number[];
8
+ export type TagNamingStrategy = 'leaf' | 'chain';
8
9
  export type ConvertOptions = {
9
10
  /**
10
11
  * Whether to merge operations with the same path and method.
@@ -21,12 +22,25 @@ export type ConvertOptions = {
21
22
  * When omitted, the whole collection is converted (existing behavior).
22
23
  */
23
24
  requestIndexPaths?: readonly PostmanRequestIndexPath[];
25
+ /**
26
+ * Strategy for generating OpenAPI tag names from nested Postman folders.
27
+ * - `leaf` (default): use the folder name only; duplicate leaves fallback to `parent / leaf`.
28
+ * - `chain`: keep the legacy full folder chain joined by ` > `.
29
+ */
30
+ tagNamingStrategy?: TagNamingStrategy;
24
31
  /**
25
32
  * Existing OpenAPI document to merge into. The input is is updated with Postman paths,
26
33
  * tags (union by name), security schemes, and servers.
27
34
  * Root `info` and existing paths are preserved unless Postman adds or merges operations.
28
35
  */
29
36
  document?: OpenAPIV3_1.Document;
37
+ /**
38
+ * Header keys (case-insensitive) to preserve as `parameters[in=header]` even
39
+ * if they match the built-in block-list of transport, content-negotiation, or
40
+ * auth headers (Accept, Content-Type, Authorization, Host, ...). Rarely needed —
41
+ * useful when an API intentionally documents a non-standard use of these names.
42
+ */
43
+ keepHeaders?: readonly string[];
30
44
  };
31
45
  /**
32
46
  * Converts a Postman Collection to an OpenAPI 3.1.0 document.
@@ -1 +1 @@
1
- {"version":3,"file":"convert.d.ts","sourceRoot":"","sources":["../src/convert.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAaxD,OAAO,KAAK,EAAgC,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAE9E;;;GAGG;AACH,MAAM,MAAM,uBAAuB,GAAG,SAAS,MAAM,EAAE,CAAA;AA4SvD,MAAM,MAAM,cAAc,GAAG;IAC3B;;;;;OAKG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB;;;;;;OAMG;IACH,iBAAiB,CAAC,EAAE,SAAS,uBAAuB,EAAE,CAAA;IACtD;;;;OAIG;IACH,QAAQ,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAA;CAChC,CAAA;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CACrB,iBAAiB,EAAE,iBAAiB,GAAG,MAAM,EAC7C,OAAO,GAAE,cAA0C,GAClD,WAAW,CAAC,QAAQ,CAwLtB"}
1
+ {"version":3,"file":"convert.d.ts","sourceRoot":"","sources":["../src/convert.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAoBxD,OAAO,KAAK,EAAgC,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAE9E;;;GAGG;AACH,MAAM,MAAM,uBAAuB,GAAG,SAAS,MAAM,EAAE,CAAA;AA6DvD,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,OAAO,CAAA;AA6rBhD,MAAM,MAAM,cAAc,GAAG;IAC3B;;;;;OAKG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB;;;;;;OAMG;IACH,iBAAiB,CAAC,EAAE,SAAS,uBAAuB,EAAE,CAAA;IACtD;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAA;IAC/B;;;;;OAKG;IACH,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAChC,CAAA;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CACrB,iBAAiB,EAAE,iBAAiB,GAAG,MAAM,EAC7C,OAAO,GAAE,cAA0C,GAClD,WAAW,CAAC,QAAQ,CAkOtB"}
package/dist/convert.js CHANGED
@@ -4,10 +4,10 @@ import { processExternalDocs } from './helpers/external-docs.js';
4
4
  import { processLicense } from './helpers/license.js';
5
5
  import { processLogo } from './helpers/logo.js';
6
6
  import { DEFAULT_EXAMPLE_NAME, OPERATION_KEYS, mergePathItem } from './helpers/merge-path-item.js';
7
- import { processItem } from './helpers/path-items.js';
7
+ import { POSTMAN_EXAMPLE_NAME_EXTENSION, POSTMAN_FOLDER_SEGMENTS_EXTENSION, POSTMAN_POST_RESPONSE_SCRIPTS_EXTENSION, POSTMAN_PRE_REQUEST_SCRIPTS_EXTENSION, processItem, } from './helpers/path-items.js';
8
8
  import { pruneDocument } from './helpers/prune-document.js';
9
9
  import { analyzeServerDistribution } from './helpers/servers.js';
10
- import { normalizePath } from './helpers/urls.js';
10
+ import { createCollectionVariableLookup, getPathStructuralSignature, normalizePath } from './helpers/urls.js';
11
11
  const normalizeDescription = (description) => {
12
12
  if (typeof description === 'string') {
13
13
  return description;
@@ -53,35 +53,173 @@ const validateCollectionShape = (collection) => {
53
53
  }
54
54
  return candidate;
55
55
  };
56
- /**
57
- * Extracts tags from Postman collection folders.
58
- * We keep folder nesting using " > " so tag names stay readable while preserving hierarchy.
59
- * Requests do not produce tags; only folders are reflected as tags.
60
- */
61
56
  const isItemGroup = (item) => 'item' in item && Array.isArray(item.item);
62
- const extractTags = (items) => {
63
- const collectTags = (item, parentPath = '') => {
64
- if (!isItemGroup(item)) {
65
- return [];
57
+ const TAG_CHAIN_SEPARATOR = ' > ';
58
+ const TAG_DUPLICATE_SEPARATOR = ' / ';
59
+ const getTagContextKey = (segments) => JSON.stringify(segments);
60
+ const getChainTagName = (segments) => segments.join(TAG_CHAIN_SEPARATOR);
61
+ const isPathParameterSegment = (segment) => (segment.startsWith('{') && segment.endsWith('}')) || segment.startsWith(':');
62
+ const normalizeLeafTagSegment = (segment) => {
63
+ const trimmed = segment.trim();
64
+ if (!trimmed) {
65
+ return trimmed;
66
+ }
67
+ // Postman folders are sometimes literal URL templates (`/languages/{languageCode}`).
68
+ // Keep tag names short by turning those into a readable leaf identifier.
69
+ const looksLikePathTemplate = trimmed.startsWith('/') || (trimmed.includes('/') && !trimmed.includes(' '));
70
+ if (!looksLikePathTemplate) {
71
+ return trimmed;
72
+ }
73
+ const segments = trimmed
74
+ .split('/')
75
+ .map((part) => part.trim())
76
+ .filter(Boolean);
77
+ if (segments.length === 0) {
78
+ return trimmed;
79
+ }
80
+ const preferredSegment = [...segments].reverse().find((part) => !isPathParameterSegment(part));
81
+ const fallbackSegment = preferredSegment ?? segments[segments.length - 1] ?? trimmed;
82
+ if (fallbackSegment.startsWith('{') && fallbackSegment.endsWith('}')) {
83
+ return fallbackSegment.slice(1, -1);
84
+ }
85
+ if (fallbackSegment.startsWith(':')) {
86
+ return fallbackSegment.slice(1);
87
+ }
88
+ return fallbackSegment;
89
+ };
90
+ const getLeafTagName = (segments) => normalizeLeafTagSegment(segments[segments.length - 1] ?? '');
91
+ const getLeafDuplicateFallback = (segments, leafTagName) => {
92
+ const parentSegment = segments[segments.length - 2];
93
+ if (!parentSegment) {
94
+ return getChainTagName(segments);
95
+ }
96
+ const normalizedParent = normalizeLeafTagSegment(parentSegment);
97
+ if (!normalizedParent) {
98
+ return getChainTagName(segments);
99
+ }
100
+ return `${normalizedParent}${TAG_DUPLICATE_SEPARATOR}${leafTagName}`;
101
+ };
102
+ const buildLeafTagContextDescription = (segments) => {
103
+ if (segments.length <= 1) {
104
+ return undefined;
105
+ }
106
+ return `Part of ${segments.slice(0, -1).join(' -> ')}`;
107
+ };
108
+ const mergeTagDescriptions = (description, contextDescription) => {
109
+ if (description && contextDescription) {
110
+ return `${description}\n\n${contextDescription}`;
111
+ }
112
+ return description || contextDescription;
113
+ };
114
+ const dedupeTagContexts = (contexts) => {
115
+ const contextMap = new Map();
116
+ contexts.forEach((context) => {
117
+ const key = getTagContextKey(context.segments);
118
+ const existing = contextMap.get(key);
119
+ if (!existing) {
120
+ contextMap.set(key, context);
121
+ return;
66
122
  }
67
- const nextPath = item.name ? (parentPath ? `${parentPath} > ${item.name}` : item.name) : parentPath;
68
- const description = normalizeDescription(item.description);
69
- const currentTag = item.name?.length
70
- ? [
71
- {
72
- name: nextPath,
73
- ...(description && { description }),
74
- },
75
- ]
76
- : [];
77
- return [...currentTag, ...item.item.flatMap((subItem) => collectTags(subItem, nextPath))];
123
+ if (!existing.description && context.description) {
124
+ contextMap.set(key, context);
125
+ }
126
+ });
127
+ return [...contextMap.values()];
128
+ };
129
+ const resolveTagNameMap = (contexts, strategy) => {
130
+ const nameMap = new Map();
131
+ contexts.forEach((context) => {
132
+ const key = getTagContextKey(context.segments);
133
+ const initialName = strategy === 'chain' ? getChainTagName(context.segments) : getLeafTagName(context.segments);
134
+ nameMap.set(key, initialName || getChainTagName(context.segments));
135
+ });
136
+ if (strategy === 'chain') {
137
+ return nameMap;
138
+ }
139
+ const initialCounts = new Map();
140
+ nameMap.forEach((name) => {
141
+ initialCounts.set(name, (initialCounts.get(name) ?? 0) + 1);
142
+ });
143
+ contexts.forEach((context) => {
144
+ const key = getTagContextKey(context.segments);
145
+ const currentName = nameMap.get(key);
146
+ if (!currentName) {
147
+ return;
148
+ }
149
+ if ((initialCounts.get(currentName) ?? 0) > 1) {
150
+ nameMap.set(key, getLeafDuplicateFallback(context.segments, currentName));
151
+ }
152
+ });
153
+ const fallbackCounts = new Map();
154
+ nameMap.forEach((name) => {
155
+ fallbackCounts.set(name, (fallbackCounts.get(name) ?? 0) + 1);
156
+ });
157
+ contexts.forEach((context) => {
158
+ const key = getTagContextKey(context.segments);
159
+ const currentName = nameMap.get(key);
160
+ if (!currentName) {
161
+ return;
162
+ }
163
+ if ((fallbackCounts.get(currentName) ?? 0) > 1) {
164
+ nameMap.set(key, getChainTagName(context.segments));
165
+ }
166
+ });
167
+ return nameMap;
168
+ };
169
+ const buildTagMetadata = (contexts, strategy) => {
170
+ const uniqueContexts = dedupeTagContexts(contexts);
171
+ const resolvedNameMap = resolveTagNameMap(uniqueContexts, strategy);
172
+ const tags = [];
173
+ const seenNames = new Set();
174
+ uniqueContexts.forEach((context) => {
175
+ const key = getTagContextKey(context.segments);
176
+ const name = resolvedNameMap.get(key);
177
+ if (!name || seenNames.has(name)) {
178
+ return;
179
+ }
180
+ seenNames.add(name);
181
+ const contextDescription = strategy === 'leaf' ? buildLeafTagContextDescription(context.segments) : undefined;
182
+ const description = mergeTagDescriptions(context.description, contextDescription);
183
+ tags.push({
184
+ name,
185
+ ...(description && { description }),
186
+ });
187
+ });
188
+ const resolveTagName = (segments) => {
189
+ if (segments.length === 0) {
190
+ return undefined;
191
+ }
192
+ const fromMap = resolvedNameMap.get(getTagContextKey(segments));
193
+ if (fromMap) {
194
+ return fromMap;
195
+ }
196
+ if (strategy === 'chain') {
197
+ return getChainTagName(segments);
198
+ }
199
+ return getLeafTagName(segments) || getChainTagName(segments);
78
200
  };
79
- return items.flatMap((item) => collectTags(item));
201
+ return { tags, resolveTagName };
80
202
  };
203
+ const collectTagContexts = (items, parentSegments = []) => items.flatMap((item) => {
204
+ if (!isItemGroup(item)) {
205
+ return [];
206
+ }
207
+ const nextSegments = item.name ? [...parentSegments, item.name] : parentSegments;
208
+ const currentContext = item.name?.length
209
+ ? [
210
+ {
211
+ segments: nextSegments,
212
+ description: normalizeDescription(item.description),
213
+ },
214
+ ]
215
+ : [];
216
+ return [...currentContext, ...collectTagContexts(item.item, nextSegments)];
217
+ });
218
+ const extractTags = (items, strategy) => buildTagMetadata(collectTagContexts(items), strategy);
81
219
  /**
82
- * Folder tags for ancestors of each selected path only (same shape as {@link extractTags}).
220
+ * Folder tags for ancestors of each selected path only (same shape as full extraction).
83
221
  */
84
- const extractTagsForSelectedPaths = (items, paths) => {
222
+ const extractTagContextsForSelectedPaths = (items, paths) => {
85
223
  const seen = new Set();
86
224
  const result = [];
87
225
  for (const path of paths) {
@@ -89,7 +227,7 @@ const extractTagsForSelectedPaths = (items, paths) => {
89
227
  continue;
90
228
  }
91
229
  let list = items;
92
- let parentPath = '';
230
+ const segments = [];
93
231
  for (let i = 0; i < path.length - 1; i++) {
94
232
  const idx = path[i];
95
233
  if (idx === undefined || idx < 0 || idx >= list.length) {
@@ -99,21 +237,23 @@ const extractTagsForSelectedPaths = (items, paths) => {
99
237
  if (node === undefined || !isItemGroup(node)) {
100
238
  break;
101
239
  }
102
- const nextPath = node.name ? (parentPath ? `${parentPath} > ${node.name}` : node.name) : parentPath;
103
- const description = normalizeDescription(node.description);
104
- if (node.name?.length && !seen.has(nextPath)) {
105
- seen.add(nextPath);
106
- result.push({
107
- name: nextPath,
108
- ...(description && { description }),
109
- });
240
+ if (node.name?.length) {
241
+ segments.push(node.name);
242
+ const key = getTagContextKey(segments);
243
+ if (!seen.has(key)) {
244
+ seen.add(key);
245
+ result.push({
246
+ segments: [...segments],
247
+ description: normalizeDescription(node.description),
248
+ });
249
+ }
110
250
  }
111
- parentPath = nextPath;
112
251
  list = node.item;
113
252
  }
114
253
  }
115
254
  return result;
116
255
  };
256
+ const extractTagsForSelectedPaths = (items, paths, strategy) => buildTagMetadata(extractTagContextsForSelectedPaths(items, paths), strategy);
117
257
  const getNodeAtPath = (items, path) => {
118
258
  if (path.length === 0) {
119
259
  return undefined;
@@ -178,11 +318,13 @@ const mergeSecuritySchemes = (openapi, securitySchemes) => {
178
318
  };
179
319
  };
180
320
  const mergeServerLists = (existing, incoming) => {
181
- const seen = new Set((existing ?? []).map((s) => s.url));
321
+ const createServerKey = (server) => JSON.stringify(server);
322
+ const seen = new Set((existing ?? []).map(createServerKey));
182
323
  const out = [...(existing ?? [])];
183
324
  for (const server of incoming) {
184
- if (!seen.has(server.url)) {
185
- seen.add(server.url);
325
+ const serverKey = createServerKey(server);
326
+ if (!seen.has(serverKey)) {
327
+ seen.add(serverKey);
186
328
  out.push(server);
187
329
  }
188
330
  }
@@ -237,8 +379,199 @@ const cleanupOperations = (paths) => {
237
379
  if (!operation.description) {
238
380
  delete operation.description;
239
381
  }
382
+ // Internal merge bookkeeping should not leak in final OpenAPI output.
383
+ delete operation[POSTMAN_EXAMPLE_NAME_EXTENSION];
384
+ delete operation[POSTMAN_PRE_REQUEST_SCRIPTS_EXTENSION];
385
+ delete operation[POSTMAN_POST_RESPONSE_SCRIPTS_EXTENSION];
386
+ delete operation[POSTMAN_FOLDER_SEGMENTS_EXTENSION];
387
+ });
388
+ });
389
+ };
390
+ const getOrderedPathParameterNames = (path) => normalizePath(path)
391
+ .split('/')
392
+ .flatMap((segment) => {
393
+ const match = segment.match(/^\{([^{}]+)\}$/);
394
+ return match?.[1] ? [match[1]] : [];
395
+ });
396
+ const rewritePathParameterNames = (path, parameterNames) => {
397
+ const segments = normalizePath(path).split('/');
398
+ let parameterIndex = 0;
399
+ const rewrittenSegments = segments.map((segment) => {
400
+ if (!/^\{[^{}]+\}$/.test(segment)) {
401
+ return segment;
402
+ }
403
+ const canonicalName = parameterNames[parameterIndex];
404
+ parameterIndex += 1;
405
+ return canonicalName ? `{${canonicalName}}` : segment;
406
+ });
407
+ return rewrittenSegments.join('/');
408
+ };
409
+ const chooseMostCommonName = (names) => {
410
+ if (names.length === 0) {
411
+ return undefined;
412
+ }
413
+ const counts = new Map();
414
+ const firstIndex = new Map();
415
+ names.forEach((name, index) => {
416
+ counts.set(name, (counts.get(name) ?? 0) + 1);
417
+ if (!firstIndex.has(name)) {
418
+ firstIndex.set(name, index);
419
+ }
420
+ });
421
+ return [...counts.entries()].sort((a, b) => {
422
+ if (a[1] !== b[1]) {
423
+ return b[1] - a[1];
424
+ }
425
+ return (firstIndex.get(a[0]) ?? Number.POSITIVE_INFINITY) - (firstIndex.get(b[0]) ?? Number.POSITIVE_INFINITY);
426
+ })[0]?.[0];
427
+ };
428
+ const renameParameters = (parameters, renameMap) => {
429
+ if (!parameters || renameMap.size === 0) {
430
+ return parameters;
431
+ }
432
+ const mergedParameters = new Map();
433
+ parameters.forEach((parameter, index) => {
434
+ if (!parameter || '$ref' in parameter) {
435
+ mergedParameters.set(`$ref/${index}`, parameter);
436
+ return;
437
+ }
438
+ const nextName = parameter.in === 'path' ? (renameMap.get(parameter.name) ?? parameter.name) : parameter.name;
439
+ const renamedParameter = nextName === parameter.name ? parameter : { ...parameter, name: nextName };
440
+ const parameterKey = `${renamedParameter.name}/${renamedParameter.in}`;
441
+ const existingParameter = mergedParameters.get(parameterKey);
442
+ if (!existingParameter || '$ref' in existingParameter || '$ref' in renamedParameter) {
443
+ mergedParameters.set(parameterKey, renamedParameter);
444
+ return;
445
+ }
446
+ mergedParameters.set(parameterKey, {
447
+ ...existingParameter,
448
+ ...renamedParameter,
449
+ examples: {
450
+ ...(existingParameter.examples ?? {}),
451
+ ...(renamedParameter.examples ?? {}),
452
+ },
453
+ });
454
+ });
455
+ return [...mergedParameters.values()];
456
+ };
457
+ const renamePathParametersForOperation = (operation, renameMap) => {
458
+ if (!operation.parameters || renameMap.size === 0) {
459
+ return operation;
460
+ }
461
+ return {
462
+ ...operation,
463
+ parameters: renameParameters(operation.parameters, renameMap),
464
+ };
465
+ };
466
+ const renamePathItemParameterNames = (pathItem, sourceNames, targetNames) => {
467
+ const renameMap = new Map();
468
+ sourceNames.forEach((sourceName, index) => {
469
+ const targetName = targetNames[index];
470
+ if (sourceName && targetName && sourceName !== targetName) {
471
+ renameMap.set(sourceName, targetName);
472
+ }
473
+ });
474
+ if (renameMap.size === 0) {
475
+ return pathItem;
476
+ }
477
+ const renamedPathItem = {
478
+ ...pathItem,
479
+ parameters: renameParameters(pathItem.parameters, renameMap),
480
+ };
481
+ OPERATION_KEYS.forEach((operationKey) => {
482
+ const operation = pathItem[operationKey];
483
+ if (!operation) {
484
+ return;
485
+ }
486
+ renamedPathItem[operationKey] = renamePathParametersForOperation(operation, renameMap);
487
+ });
488
+ return renamedPathItem;
489
+ };
490
+ const findFolderTemplateHint = (pathItemGroup, signature) => {
491
+ for (const { pathItem, parameterNames } of pathItemGroup) {
492
+ for (const operationKey of OPERATION_KEYS) {
493
+ const operation = pathItem[operationKey];
494
+ if (!operation) {
495
+ continue;
496
+ }
497
+ // Use the raw Postman folder chain if available; otherwise fall back to
498
+ // splitting tag strings (supports the legacy chain tag naming strategy).
499
+ const rawSegments = operation[POSTMAN_FOLDER_SEGMENTS_EXTENSION];
500
+ const folderNameCandidates = rawSegments ? [...rawSegments] : [];
501
+ for (const tag of operation.tags ?? []) {
502
+ folderNameCandidates.push(...tag.split(' > ').map((segment) => segment.trim()));
503
+ }
504
+ for (const folderName of folderNameCandidates) {
505
+ if (!folderName.startsWith('/')) {
506
+ continue;
507
+ }
508
+ const normalizedFolderName = normalizePath(folderName);
509
+ if (getPathStructuralSignature(normalizedFolderName) !== signature) {
510
+ continue;
511
+ }
512
+ const folderParameterNames = getOrderedPathParameterNames(normalizedFolderName);
513
+ if (folderParameterNames.length === parameterNames.length) {
514
+ return folderParameterNames;
515
+ }
516
+ }
517
+ }
518
+ }
519
+ return undefined;
520
+ };
521
+ const unifyEquivalentPathParameters = (paths) => {
522
+ const pathEntries = Object.entries(paths).filter((entry) => Boolean(entry[1]));
523
+ const groups = new Map();
524
+ pathEntries.forEach(([pathKey, pathItem]) => {
525
+ const signature = getPathStructuralSignature(pathKey);
526
+ if (!groups.has(signature)) {
527
+ groups.set(signature, []);
528
+ }
529
+ groups.get(signature)?.push({
530
+ pathKey,
531
+ pathItem,
532
+ parameterNames: getOrderedPathParameterNames(pathKey),
533
+ });
534
+ });
535
+ const unifiedPaths = {};
536
+ const canonicalPathByPath = new Map();
537
+ const processedPathKeys = new Set();
538
+ pathEntries.forEach(([pathKey]) => {
539
+ if (processedPathKeys.has(pathKey)) {
540
+ return;
541
+ }
542
+ const signature = getPathStructuralSignature(pathKey);
543
+ const group = groups.get(signature) ?? [];
544
+ if (group.length < 2) {
545
+ const pathItem = paths[pathKey];
546
+ if (pathItem) {
547
+ unifiedPaths[pathKey] = pathItem;
548
+ canonicalPathByPath.set(pathKey, pathKey);
549
+ }
550
+ processedPathKeys.add(pathKey);
551
+ return;
552
+ }
553
+ const firstGroupEntry = group[0];
554
+ if (!firstGroupEntry || firstGroupEntry.pathKey !== pathKey) {
555
+ return;
556
+ }
557
+ const parameterCount = firstGroupEntry.parameterNames.length;
558
+ const folderTemplateHint = findFolderTemplateHint(group, signature);
559
+ const canonicalParameterNames = folderTemplateHint ??
560
+ Array.from({ length: parameterCount }, (_, parameterIndex) => {
561
+ const namesInOrder = group
562
+ .map((entry) => entry.parameterNames[parameterIndex])
563
+ .filter((name) => Boolean(name));
564
+ return chooseMostCommonName(namesInOrder) ?? namesInOrder[0] ?? '';
565
+ });
566
+ const canonicalPath = rewritePathParameterNames(firstGroupEntry.pathKey, canonicalParameterNames);
567
+ group.forEach(({ pathKey: groupedPathKey, pathItem, parameterNames }) => {
568
+ const normalizedPathItem = renamePathItemParameterNames(pathItem, parameterNames, canonicalParameterNames);
569
+ mergePathItem(unifiedPaths, canonicalPath, normalizedPathItem, true);
570
+ canonicalPathByPath.set(groupedPathKey, canonicalPath);
571
+ processedPathKeys.add(groupedPathKey);
240
572
  });
241
573
  });
574
+ return { paths: unifiedPaths, canonicalPathByPath };
242
575
  };
243
576
  /**
244
577
  * Converts a Postman Collection to an OpenAPI 3.1.0 document.
@@ -246,7 +579,7 @@ const cleanupOperations = (paths) => {
246
579
  * and items to create a corresponding OpenAPI structure.
247
580
  */
248
581
  export function convert(postmanCollection, options = { mergeOperation: false }) {
249
- const { requestIndexPaths, mergeOperation = false, document: baseDocument } = options;
582
+ const { requestIndexPaths, mergeOperation = false, tagNamingStrategy = 'leaf', document: baseDocument, keepHeaders, } = options;
250
583
  const isMergingIntoBase = baseDocument !== undefined;
251
584
  const collection = validateCollectionShape(parseCollectionInput(postmanCollection));
252
585
  // Extract title from collection info, fallback to 'API' if not provided
@@ -294,11 +627,12 @@ export function convert(postmanCollection, options = { mergeOperation: false })
294
627
  }
295
628
  // Process each item in the collection and merge into OpenAPI spec
296
629
  const allServerUsage = [];
630
+ const collectionVariableLookup = createCollectionVariableLookup(collection.variable);
297
631
  if (collection.item) {
298
632
  const usePathFilter = requestIndexPaths !== undefined;
299
633
  if (usePathFilter) {
300
634
  const uniquePaths = dedupeIndexPaths(requestIndexPaths);
301
- const tags = extractTagsForSelectedPaths(collection.item, uniquePaths);
635
+ const { tags, resolveTagName } = extractTagsForSelectedPaths(collection.item, uniquePaths, tagNamingStrategy);
302
636
  assignTagsFromPostman(openapi, tags, isMergingIntoBase);
303
637
  for (const path of uniquePaths) {
304
638
  const node = getNodeAtPath(collection.item, path);
@@ -306,7 +640,7 @@ export function convert(postmanCollection, options = { mergeOperation: false })
306
640
  continue;
307
641
  }
308
642
  const parentTags = collectParentTagSegments(collection.item, path);
309
- const { paths: itemPaths, components: itemComponents, serverUsage, } = processItem(node, DEFAULT_EXAMPLE_NAME, parentTags, '');
643
+ const { paths: itemPaths, components: itemComponents, serverUsage, } = processItem(node, DEFAULT_EXAMPLE_NAME, parentTags, '', mergeOperation, resolveTagName, collectionVariableLookup, keepHeaders);
310
644
  allServerUsage.push(...serverUsage);
311
645
  for (const [pathKey, pathItem] of Object.entries(itemPaths)) {
312
646
  const normalizedPathKey = normalizePath(pathKey);
@@ -321,10 +655,10 @@ export function convert(postmanCollection, options = { mergeOperation: false })
321
655
  }
322
656
  }
323
657
  else {
324
- const tags = extractTags(collection.item);
658
+ const { tags, resolveTagName } = extractTags(collection.item, tagNamingStrategy);
325
659
  assignTagsFromPostman(openapi, tags, isMergingIntoBase);
326
660
  collection.item.forEach((item) => {
327
- const { paths: itemPaths, components: itemComponents, serverUsage } = processItem(item, DEFAULT_EXAMPLE_NAME);
661
+ const { paths: itemPaths, components: itemComponents, serverUsage, } = processItem(item, DEFAULT_EXAMPLE_NAME, [], '', mergeOperation, resolveTagName, collectionVariableLookup, keepHeaders);
328
662
  allServerUsage.push(...serverUsage);
329
663
  openapi.paths = openapi.paths || {};
330
664
  for (const [pathKey, pathItem] of Object.entries(itemPaths)) {
@@ -341,6 +675,20 @@ export function convert(postmanCollection, options = { mergeOperation: false })
341
675
  }
342
676
  }
343
677
  // Extract all unique paths from the document
678
+ let canonicalPathByPath = new Map();
679
+ if (openapi.paths) {
680
+ const unificationResult = unifyEquivalentPathParameters(openapi.paths);
681
+ openapi.paths = unificationResult.paths;
682
+ canonicalPathByPath = unificationResult.canonicalPathByPath;
683
+ }
684
+ const normalizedServerUsage = allServerUsage.map((usage) => {
685
+ const normalizedUsagePath = normalizePath(usage.path);
686
+ return {
687
+ ...usage,
688
+ path: canonicalPathByPath.get(normalizedUsagePath) ?? normalizedUsagePath,
689
+ };
690
+ });
691
+ // Extract all unique paths from the document
344
692
  const allUniquePaths = new Set();
345
693
  if (openapi.paths) {
346
694
  for (const pathKey of Object.keys(openapi.paths)) {
@@ -348,7 +696,7 @@ export function convert(postmanCollection, options = { mergeOperation: false })
348
696
  }
349
697
  }
350
698
  // Analyze server distribution and place servers at appropriate levels
351
- const serverPlacement = analyzeServerDistribution(allServerUsage, allUniquePaths);
699
+ const serverPlacement = analyzeServerDistribution(normalizedServerUsage, allUniquePaths);
352
700
  // Add servers to document level
353
701
  if (serverPlacement.document.length > 0) {
354
702
  openapi.servers = isMergingIntoBase
@@ -0,0 +1,21 @@
1
+ import type { HeaderList } from '../types.js';
2
+ type RequestHeaders = HeaderList | string | null | undefined;
3
+ /**
4
+ * Looks up a header value case-insensitively. Returns the value of the first
5
+ * non-disabled match, or undefined. Silently ignores string-typed headers
6
+ * (Postman occasionally stores them that way).
7
+ */
8
+ export declare function readHeader(headers: RequestHeaders, name: string): string | undefined;
9
+ /**
10
+ * Normalises a raw Content-Type header value (`application/json; charset=utf-8`)
11
+ * to just the media type (`application/json`). Returns undefined for empty input.
12
+ */
13
+ export declare function parseMediaType(value: string | undefined): string | undefined;
14
+ /**
15
+ * Picks a single media type from an Accept header value, preferring
16
+ * `application/json` if present, else the first non-wildcard type. Returns
17
+ * undefined for `*​/*` alone, empty input, or only wildcard types.
18
+ */
19
+ export declare function pickAcceptMediaType(value: string | undefined): string | undefined;
20
+ export {};
21
+ //# sourceMappingURL=header-utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"header-utils.d.ts","sourceRoot":"","sources":["../../src/helpers/header-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAU,UAAU,EAAE,MAAM,SAAS,CAAA;AAEjD,KAAK,cAAc,GAAG,UAAU,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;AAE5D;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,cAAc,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAOpF;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAM5E;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAYjF"}