@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.
- package/CHANGELOG.md +25 -0
- package/README.md +15 -0
- package/dist/convert.d.ts +14 -0
- package/dist/convert.d.ts.map +1 -1
- package/dist/convert.js +392 -44
- package/dist/helpers/header-utils.d.ts +21 -0
- package/dist/helpers/header-utils.d.ts.map +1 -0
- package/dist/helpers/header-utils.js +42 -0
- package/dist/helpers/merge-operation.d.ts.map +1 -1
- package/dist/helpers/merge-operation.js +120 -5
- package/dist/helpers/merge-path-item.d.ts.map +1 -1
- package/dist/helpers/merge-path-item.js +28 -4
- package/dist/helpers/parameters.d.ts +4 -1
- package/dist/helpers/parameters.d.ts.map +1 -1
- package/dist/helpers/parameters.js +40 -1
- package/dist/helpers/path-items.d.ts +11 -1
- package/dist/helpers/path-items.d.ts.map +1 -1
- package/dist/helpers/path-items.js +111 -11
- package/dist/helpers/request-body.d.ts +5 -1
- package/dist/helpers/request-body.d.ts.map +1 -1
- package/dist/helpers/request-body.js +21 -28
- package/dist/helpers/responses.d.ts +8 -1
- package/dist/helpers/responses.d.ts.map +1 -1
- package/dist/helpers/responses.js +99 -11
- package/dist/helpers/servers.d.ts.map +1 -1
- package/dist/helpers/servers.js +10 -6
- package/dist/helpers/urls.d.ts +20 -0
- package/dist/helpers/urls.d.ts.map +1 -1
- package/dist/helpers/urls.js +101 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/is-postman-collection.d.ts +9 -0
- package/dist/is-postman-collection.d.ts.map +1 -0
- package/dist/is-postman-collection.js +23 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- 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.
|
package/dist/convert.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
|
220
|
+
* Folder tags for ancestors of each selected path only (same shape as full extraction).
|
|
83
221
|
*/
|
|
84
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
seen.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
-
|
|
185
|
-
|
|
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(
|
|
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"}
|