@scalar/oas-utils 0.10.7 → 0.10.9
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 +15 -0
- package/dist/entities/cookie/cookie.js +11 -15
- package/dist/entities/cookie/index.js +1 -5
- package/dist/entities/environment/environment.js +7 -11
- package/dist/entities/environment/index.js +1 -5
- package/dist/entities/hotkeys/hotkeys.js +116 -111
- package/dist/entities/hotkeys/index.js +1 -6
- package/dist/entities/shared/index.js +1 -7
- package/dist/entities/shared/utility.js +9 -9
- package/dist/entities/spec/collection.js +91 -89
- package/dist/entities/spec/index.js +10 -59
- package/dist/entities/spec/operation.js +6 -6
- package/dist/entities/spec/parameters.js +38 -38
- package/dist/entities/spec/request-examples.js +421 -331
- package/dist/entities/spec/requests.js +102 -84
- package/dist/entities/spec/server.js +61 -46
- package/dist/entities/spec/spec-objects.js +121 -76
- package/dist/entities/spec/x-scalar-environments.js +18 -20
- package/dist/entities/spec/x-scalar-secrets.js +6 -8
- package/dist/entities/workspace/index.js +1 -7
- package/dist/entities/workspace/workspace.js +47 -46
- package/dist/helpers/client-plugins.js +13 -13
- package/dist/helpers/fetch-document.js +30 -25
- package/dist/helpers/fetch-with-proxy-fallback.js +26 -21
- package/dist/helpers/index.d.ts +1 -2
- package/dist/helpers/index.d.ts.map +1 -1
- package/dist/helpers/index.js +80 -119
- package/dist/helpers/normalize-mime-type-object.js +19 -18
- package/dist/helpers/normalize-mime-type.js +11 -9
- package/dist/helpers/operation-stability.js +25 -20
- package/dist/helpers/parse.d.ts +0 -4
- package/dist/helpers/parse.d.ts.map +1 -1
- package/dist/helpers/parse.js +77 -77
- package/dist/helpers/schema-model.js +13 -16
- package/dist/helpers/security/get-schemes.js +7 -8
- package/dist/helpers/security/has-token.js +18 -19
- package/dist/helpers/security/index.js +2 -7
- package/dist/helpers/servers.js +128 -79
- package/dist/helpers/should-ignore-entity.js +4 -5
- package/dist/migrations/data-version.js +15 -7
- package/dist/migrations/generate-types.js +34 -37
- package/dist/migrations/index.js +4 -18
- package/dist/migrations/local-storage.js +31 -29
- package/dist/migrations/migrate-to-indexdb.js +706 -529
- package/dist/migrations/migrator.js +58 -54
- package/dist/migrations/semver.js +24 -24
- package/dist/migrations/v-0.0.0/types.generated.js +1 -1
- package/dist/migrations/v-2.1.0/migration.js +272 -258
- package/dist/migrations/v-2.1.0/types.generated.js +1 -1
- package/dist/migrations/v-2.2.0/migration.js +99 -96
- package/dist/migrations/v-2.2.0/types.generated.js +1 -1
- package/dist/migrations/v-2.3.0/migration.js +45 -48
- package/dist/migrations/v-2.3.0/types.generated.js +1 -1
- package/dist/migrations/v-2.4.0/migration.js +25 -25
- package/dist/migrations/v-2.4.0/types.generated.js +1 -1
- package/dist/migrations/v-2.5.0/migration.js +122 -139
- package/dist/migrations/v-2.5.0/types.generated.js +1 -1
- package/dist/spec-getters/get-example-from-schema.js +489 -385
- package/dist/spec-getters/get-parameters-from-operation.js +39 -23
- package/dist/spec-getters/get-request-body-from-operation.d.ts.map +1 -1
- package/dist/spec-getters/get-request-body-from-operation.js +168 -126
- package/dist/spec-getters/get-server-variable-examples.js +10 -13
- package/dist/spec-getters/index.js +4 -11
- package/dist/transforms/import-spec.js +381 -291
- package/dist/transforms/index.js +1 -11
- package/package.json +15 -19
- package/dist/entities/cookie/cookie.js.map +0 -7
- package/dist/entities/cookie/index.js.map +0 -7
- package/dist/entities/environment/environment.js.map +0 -7
- package/dist/entities/environment/index.js.map +0 -7
- package/dist/entities/hotkeys/hotkeys.js.map +0 -7
- package/dist/entities/hotkeys/index.js.map +0 -7
- package/dist/entities/shared/index.js.map +0 -7
- package/dist/entities/shared/utility.js.map +0 -7
- package/dist/entities/spec/collection.js.map +0 -7
- package/dist/entities/spec/index.js.map +0 -7
- package/dist/entities/spec/operation.js.map +0 -7
- package/dist/entities/spec/parameters.js.map +0 -7
- package/dist/entities/spec/request-examples.js.map +0 -7
- package/dist/entities/spec/requests.js.map +0 -7
- package/dist/entities/spec/server.js.map +0 -7
- package/dist/entities/spec/spec-objects.js.map +0 -7
- package/dist/entities/spec/x-scalar-environments.js.map +0 -7
- package/dist/entities/spec/x-scalar-secrets.js.map +0 -7
- package/dist/entities/workspace/index.js.map +0 -7
- package/dist/entities/workspace/workspace.js.map +0 -7
- package/dist/helpers/client-plugins.js.map +0 -7
- package/dist/helpers/fetch-document.js.map +0 -7
- package/dist/helpers/fetch-with-proxy-fallback.js.map +0 -7
- package/dist/helpers/index.js.map +0 -7
- package/dist/helpers/normalize-mime-type-object.js.map +0 -7
- package/dist/helpers/normalize-mime-type.js.map +0 -7
- package/dist/helpers/operation-stability.js.map +0 -7
- package/dist/helpers/parse.js.map +0 -7
- package/dist/helpers/pretty-print-json.d.ts +0 -9
- package/dist/helpers/pretty-print-json.d.ts.map +0 -1
- package/dist/helpers/pretty-print-json.js +0 -38
- package/dist/helpers/pretty-print-json.js.map +0 -7
- package/dist/helpers/schema-model.js.map +0 -7
- package/dist/helpers/security/get-schemes.js.map +0 -7
- package/dist/helpers/security/has-token.js.map +0 -7
- package/dist/helpers/security/index.js.map +0 -7
- package/dist/helpers/servers.js.map +0 -7
- package/dist/helpers/should-ignore-entity.js.map +0 -7
- package/dist/migrations/data-version.js.map +0 -7
- package/dist/migrations/generate-types.js.map +0 -7
- package/dist/migrations/index.js.map +0 -7
- package/dist/migrations/local-storage.js.map +0 -7
- package/dist/migrations/migrate-to-indexdb.js.map +0 -7
- package/dist/migrations/migrator.js.map +0 -7
- package/dist/migrations/semver.js.map +0 -7
- package/dist/migrations/v-0.0.0/types.generated.js.map +0 -7
- package/dist/migrations/v-2.1.0/migration.js.map +0 -7
- package/dist/migrations/v-2.1.0/types.generated.js.map +0 -7
- package/dist/migrations/v-2.2.0/migration.js.map +0 -7
- package/dist/migrations/v-2.2.0/types.generated.js.map +0 -7
- package/dist/migrations/v-2.3.0/migration.js.map +0 -7
- package/dist/migrations/v-2.3.0/types.generated.js.map +0 -7
- package/dist/migrations/v-2.4.0/migration.js.map +0 -7
- package/dist/migrations/v-2.4.0/types.generated.js.map +0 -7
- package/dist/migrations/v-2.5.0/migration.js.map +0 -7
- package/dist/migrations/v-2.5.0/types.generated.js.map +0 -7
- package/dist/spec-getters/get-example-from-schema.js.map +0 -7
- package/dist/spec-getters/get-parameters-from-operation.js.map +0 -7
- package/dist/spec-getters/get-request-body-from-operation.js.map +0 -7
- package/dist/spec-getters/get-server-variable-examples.js.map +0 -7
- package/dist/spec-getters/index.js.map +0 -7
- package/dist/transforms/import-spec.js.map +0 -7
- package/dist/transforms/index.js.map +0 -7
|
@@ -1,607 +1,784 @@
|
|
|
1
|
-
import { CONTENT_TYPES } from
|
|
2
|
-
import { createLimiter } from
|
|
3
|
-
import { extractConfigSecrets, removeSecretFields } from
|
|
4
|
-
import { objectEntries } from
|
|
5
|
-
import { toJsonCompatible } from
|
|
6
|
-
import { extractServerFromPath } from
|
|
7
|
-
import { presets } from
|
|
8
|
-
import { createWorkspaceStore } from
|
|
9
|
-
import { AuthSchema } from
|
|
10
|
-
import { createWorkspaceStorePersistence } from
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
} from
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
import { migrator } from "../migrations/migrator.js";
|
|
19
|
-
const DRAFTS_DOCUMENT_NAME = "drafts";
|
|
1
|
+
import { CONTENT_TYPES } from '@scalar/helpers/consts/content-types';
|
|
2
|
+
import { createLimiter } from '@scalar/helpers/general/create-limiter';
|
|
3
|
+
import { extractConfigSecrets, removeSecretFields } from '@scalar/helpers/general/extract-config-secrets';
|
|
4
|
+
import { objectEntries } from '@scalar/helpers/object/object-entries';
|
|
5
|
+
import { toJsonCompatible } from '@scalar/helpers/object/to-json-compatible';
|
|
6
|
+
import { extractServerFromPath } from '@scalar/helpers/url/extract-server-from-path';
|
|
7
|
+
import { presets } from '@scalar/themes';
|
|
8
|
+
import { createWorkspaceStore } from '@scalar/workspace-store/client';
|
|
9
|
+
import { AuthSchema } from '@scalar/workspace-store/entities/auth';
|
|
10
|
+
import { createWorkspaceStorePersistence } from '@scalar/workspace-store/persistence';
|
|
11
|
+
import { xScalarEnvironmentSchema, } from '@scalar/workspace-store/schemas/extensions/document/x-scalar-environments';
|
|
12
|
+
import { xScalarCookieSchema } from '@scalar/workspace-store/schemas/extensions/general/x-scalar-cookies';
|
|
13
|
+
import { coerceValue } from '@scalar/workspace-store/schemas/typebox-coerce';
|
|
14
|
+
import { ColorModeSchema } from '@scalar/workspace-store/schemas/workspace';
|
|
15
|
+
import GithubSlugger from 'github-slugger';
|
|
16
|
+
import { migrator } from '../migrations/migrator.js';
|
|
17
|
+
const DRAFTS_DOCUMENT_NAME = 'drafts';
|
|
20
18
|
const MAX_CONCURRENT_DB_WRITES = 100;
|
|
21
19
|
const MAX_CONCURRENT_DATA_TRANSFORMATIONS = 5;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Migrates localStorage data to IndexedDB workspace structure.
|
|
22
|
+
*
|
|
23
|
+
* Called early in app initialization (app-state.ts) before workspace data loads.
|
|
24
|
+
* Idempotent and non-destructive - runs when legacy data exists but IndexedDB is empty.
|
|
25
|
+
*
|
|
26
|
+
* Flow:
|
|
27
|
+
* 1. Check if migration needed (has legacy data + IndexedDB is empty)
|
|
28
|
+
* 2. Run existing migrations to get latest data structure
|
|
29
|
+
* 3. Transform to new workspace format
|
|
30
|
+
* 4. Save to IndexedDB
|
|
31
|
+
*
|
|
32
|
+
* Old data is preserved for rollback. Typically completes in < 1 second.
|
|
33
|
+
*/
|
|
34
|
+
export const migrateLocalStorageToIndexDb = async () => {
|
|
35
|
+
const { close, workspace: workspacePersistence } = await createWorkspaceStorePersistence();
|
|
36
|
+
try {
|
|
37
|
+
const shouldMigrate = await shouldMigrateToIndexDb(workspacePersistence);
|
|
38
|
+
if (!shouldMigrate) {
|
|
39
|
+
console.info('ℹ️ No migration needed - IndexedDB already has workspaces or no legacy data exists');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
console.info('🚀 Starting migration from localStorage to IndexedDB...');
|
|
43
|
+
// Step 1: Run existing migrations to get latest data structure
|
|
44
|
+
const legacyData = migrator();
|
|
45
|
+
console.info(`📦 Found legacy data: ${legacyData.arrays.workspaces.length} workspace(s), ${legacyData.arrays.collections.length} collection(s)`);
|
|
46
|
+
// Step 2: Transform to new workspace structure
|
|
47
|
+
const workspaces = await transformLegacyDataToWorkspace(legacyData);
|
|
48
|
+
const limit = createLimiter(MAX_CONCURRENT_DB_WRITES);
|
|
49
|
+
// Step 3: Save to IndexedDB
|
|
50
|
+
await Promise.all(workspaces.map((workspace) => limit(() => workspacePersistence.setItem({ namespace: 'local', slug: workspace.slug }, {
|
|
51
|
+
name: workspace.name,
|
|
52
|
+
workspace: workspace.workspace,
|
|
53
|
+
teamUid: 'local',
|
|
54
|
+
}))));
|
|
55
|
+
console.info(`✅ Successfully migrated ${workspaces.length} workspace(s) to IndexedDB`);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
console.error('❌ Migration failed:', error);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
close();
|
|
29
62
|
}
|
|
30
|
-
console.info("\u{1F680} Starting migration from localStorage to IndexedDB...");
|
|
31
|
-
const legacyData = migrator();
|
|
32
|
-
console.info(
|
|
33
|
-
`\u{1F4E6} Found legacy data: ${legacyData.arrays.workspaces.length} workspace(s), ${legacyData.arrays.collections.length} collection(s)`
|
|
34
|
-
);
|
|
35
|
-
const workspaces = await transformLegacyDataToWorkspace(legacyData);
|
|
36
|
-
const limit = createLimiter(MAX_CONCURRENT_DB_WRITES);
|
|
37
|
-
await Promise.all(
|
|
38
|
-
workspaces.map(
|
|
39
|
-
(workspace) => limit(
|
|
40
|
-
() => workspacePersistence.setItem(
|
|
41
|
-
{ namespace: "local", slug: workspace.slug },
|
|
42
|
-
{
|
|
43
|
-
name: workspace.name,
|
|
44
|
-
workspace: workspace.workspace,
|
|
45
|
-
teamUid: "local"
|
|
46
|
-
}
|
|
47
|
-
)
|
|
48
|
-
)
|
|
49
|
-
)
|
|
50
|
-
);
|
|
51
|
-
console.info(`\u2705 Successfully migrated ${workspaces.length} workspace(s) to IndexedDB`);
|
|
52
|
-
} catch (error) {
|
|
53
|
-
console.error("\u274C Migration failed:", error);
|
|
54
|
-
} finally {
|
|
55
|
-
close();
|
|
56
|
-
}
|
|
57
63
|
};
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Checks if migration is needed by verifying IndexedDB state and presence of legacy data.
|
|
66
|
+
*
|
|
67
|
+
* Migration is needed when:
|
|
68
|
+
* 1. Legacy data exists in localStorage (workspace, collection, or request keys)
|
|
69
|
+
* 2. AND IndexedDB has no workspaces yet
|
|
70
|
+
*
|
|
71
|
+
* This approach is more reliable than using a flag because:
|
|
72
|
+
* - If IndexedDB is cleared, migration will run again automatically
|
|
73
|
+
* - No risk of flag getting out of sync with actual data state
|
|
74
|
+
* - Handles edge cases like partial migrations or database corruption
|
|
75
|
+
*/
|
|
76
|
+
export const shouldMigrateToIndexDb = async (workspacePersistence) => {
|
|
77
|
+
// Check if there is any old data in localStorage
|
|
78
|
+
const hasLegacyData = localStorage.getItem('workspace') !== null ||
|
|
79
|
+
localStorage.getItem('collection') !== null ||
|
|
80
|
+
localStorage.getItem('request') !== null;
|
|
81
|
+
if (!hasLegacyData) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
// Check if IndexedDB already has workspaces
|
|
85
|
+
const existingWorkspaces = await workspacePersistence.getAll();
|
|
86
|
+
const hasIndexDbData = existingWorkspaces.length > 0;
|
|
87
|
+
// Only migrate if we have legacy data but no IndexedDB data
|
|
88
|
+
return !hasIndexDbData;
|
|
66
89
|
};
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Transforms legacy localStorage data into IndexedDB workspace structure.
|
|
92
|
+
*
|
|
93
|
+
* Transformations:
|
|
94
|
+
* - Collections → Documents (collections were OpenAPI specs)
|
|
95
|
+
* - Environments → x-scalar-environments in meta
|
|
96
|
+
* - Cookies → x-scalar-cookies in meta
|
|
97
|
+
* - Workspace properties → meta extensions (activeEnvironmentId, proxyUrl, themeId)
|
|
98
|
+
*
|
|
99
|
+
* Creates a default workspace if none exist. Falls back to collection uid if info.title is missing.
|
|
100
|
+
*/
|
|
101
|
+
export const transformLegacyDataToWorkspace = async (legacyData) => {
|
|
102
|
+
const limitWorkspaceTransform = createLimiter(MAX_CONCURRENT_DATA_TRANSFORMATIONS);
|
|
103
|
+
return await Promise.all(legacyData.arrays.workspaces.map((workspace) => limitWorkspaceTransform(async () => {
|
|
104
|
+
/** Grab auth from the collections */
|
|
72
105
|
const workspaceAuth = {};
|
|
106
|
+
/** Create a slugger instance per workspace to handle duplicate document names */
|
|
73
107
|
const documentSlugger = new GithubSlugger();
|
|
74
|
-
|
|
75
|
-
|
|
108
|
+
/** Each collection becomes a document in the new system and grab the auth as well */
|
|
109
|
+
const documents = workspace.collections.flatMap((uid) => {
|
|
76
110
|
const collection = legacyData.records.collections[uid];
|
|
77
111
|
if (!collection) {
|
|
78
|
-
|
|
112
|
+
return [];
|
|
79
113
|
}
|
|
80
|
-
const documentName = collection.info?.title ||
|
|
114
|
+
const documentName = collection.info?.title || 'api';
|
|
81
115
|
const { document, auth } = transformCollectionToDocument(documentName, collection, legacyData.records);
|
|
82
|
-
|
|
116
|
+
// Normalize document name to match the store (lowercase "Drafts" → "drafts")
|
|
117
|
+
const normalizedName = documentName === 'Drafts' ? 'drafts' : documentName;
|
|
118
|
+
// Use GitHubSlugger to ensure unique document names
|
|
83
119
|
const uniqueName = documentSlugger.slug(normalizedName, false);
|
|
84
120
|
workspaceAuth[uniqueName] = auth;
|
|
85
121
|
return { name: uniqueName, document };
|
|
86
|
-
|
|
87
|
-
);
|
|
122
|
+
});
|
|
88
123
|
const meta = {};
|
|
89
124
|
const extensions = {};
|
|
125
|
+
// Add environment
|
|
90
126
|
const environmentEntries = Object.entries(workspace.environments);
|
|
91
127
|
if (environmentEntries.length > 0) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
128
|
+
extensions['x-scalar-environments'] = {
|
|
129
|
+
default: coerceValue(xScalarEnvironmentSchema, {
|
|
130
|
+
variables: environmentEntries.map(([name, value]) => ({
|
|
131
|
+
name,
|
|
132
|
+
value,
|
|
133
|
+
})),
|
|
134
|
+
}),
|
|
135
|
+
};
|
|
100
136
|
}
|
|
137
|
+
// Add cookies to meta
|
|
101
138
|
if (workspace.cookies.length > 0) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
139
|
+
extensions['x-scalar-cookies'] = workspace.cookies.flatMap((uid) => {
|
|
140
|
+
const cookie = legacyData.records.cookies[uid];
|
|
141
|
+
return cookie ? coerceValue(xScalarCookieSchema, cookie) : [];
|
|
142
|
+
});
|
|
106
143
|
}
|
|
144
|
+
// Add proxy URL if present
|
|
107
145
|
if (workspace.proxyUrl) {
|
|
108
|
-
|
|
146
|
+
meta['x-scalar-active-proxy'] = workspace.proxyUrl;
|
|
109
147
|
}
|
|
148
|
+
// Add theme if present
|
|
110
149
|
if (workspace.themeId) {
|
|
111
|
-
|
|
150
|
+
// We use theme slugs on the new system so we need to transform the id to the slug
|
|
151
|
+
meta['x-scalar-theme'] = transformThemeIdToSlug(workspace.themeId);
|
|
112
152
|
}
|
|
113
|
-
|
|
114
|
-
|
|
153
|
+
// Set color mode
|
|
154
|
+
if (localStorage.getItem('colorMode')) {
|
|
155
|
+
meta['x-scalar-color-mode'] = coerceValue(ColorModeSchema, localStorage.getItem('colorMode'));
|
|
115
156
|
}
|
|
116
157
|
const store = createWorkspaceStore({
|
|
117
|
-
|
|
158
|
+
meta,
|
|
118
159
|
});
|
|
119
160
|
const limitDocumentAdd = createLimiter(MAX_CONCURRENT_DATA_TRANSFORMATIONS);
|
|
120
|
-
await Promise.all(
|
|
121
|
-
|
|
122
|
-
({ name, document }) => limitDocumentAdd(async () => {
|
|
123
|
-
await store.addDocument({
|
|
161
|
+
await Promise.all(documents.map(({ name, document }) => limitDocumentAdd(async () => {
|
|
162
|
+
await store.addDocument({
|
|
124
163
|
name,
|
|
125
|
-
document
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
164
|
+
document,
|
|
165
|
+
});
|
|
166
|
+
// Note: we are breaking the relationship between the document and the originial source url
|
|
167
|
+
})));
|
|
168
|
+
// Try to always set the drafts / route
|
|
130
169
|
if (!(DRAFTS_DOCUMENT_NAME in store.workspace.documents)) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
170
|
+
await store.addDocument({
|
|
171
|
+
name: DRAFTS_DOCUMENT_NAME,
|
|
172
|
+
document: {
|
|
173
|
+
openapi: '3.1.0',
|
|
174
|
+
info: {
|
|
175
|
+
title: 'Drafts',
|
|
176
|
+
version: '1.0.0',
|
|
177
|
+
},
|
|
178
|
+
paths: {
|
|
179
|
+
'/': {
|
|
180
|
+
get: {},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
'x-scalar-icon': 'interface-edit-tool-pencil',
|
|
184
|
+
},
|
|
185
|
+
});
|
|
147
186
|
}
|
|
148
187
|
const drafts = store.workspace.documents[DRAFTS_DOCUMENT_NAME];
|
|
149
188
|
if (drafts) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
189
|
+
// Make sure the drafts document has a GET / route cuz that's the first route we navigate the user to
|
|
190
|
+
drafts.paths ??= {};
|
|
191
|
+
drafts.paths['/'] ??= {};
|
|
192
|
+
drafts.paths['/']['get'] ??= {};
|
|
153
193
|
}
|
|
154
194
|
store.buildSidebar(DRAFTS_DOCUMENT_NAME);
|
|
195
|
+
// save the document to the store so we don't see the document as dirty
|
|
155
196
|
await store.saveDocument(DRAFTS_DOCUMENT_NAME);
|
|
197
|
+
// Load the auth into the store
|
|
156
198
|
store.auth.load(workspaceAuth);
|
|
199
|
+
// Load the extensions into the store
|
|
157
200
|
objectEntries(extensions).forEach(([key, value]) => {
|
|
158
|
-
|
|
201
|
+
store.update(key, value);
|
|
159
202
|
});
|
|
160
203
|
return {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
workspace: store.exportWorkspace()
|
|
204
|
+
slug: workspace.uid.toString(), // Convert to string to convert it to a simple string type
|
|
205
|
+
name: workspace.name || 'Untitled Workspace',
|
|
206
|
+
workspace: store.exportWorkspace(),
|
|
165
207
|
};
|
|
166
|
-
|
|
167
|
-
)
|
|
168
|
-
);
|
|
208
|
+
})));
|
|
169
209
|
};
|
|
210
|
+
/**
|
|
211
|
+
* Converts a ThemeId to its corresponding theme slug.
|
|
212
|
+
* If the themeId is 'none', return it as is.
|
|
213
|
+
* Otherwise, look up the slug in the presets object.
|
|
214
|
+
*/
|
|
170
215
|
const transformThemeIdToSlug = (themeId) => {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
216
|
+
if (themeId === 'none') {
|
|
217
|
+
return themeId;
|
|
218
|
+
}
|
|
219
|
+
return presets[themeId]?.slug ?? 'default';
|
|
175
220
|
};
|
|
221
|
+
/**
|
|
222
|
+
* Converts legacy environment variables from record format to the new array format.
|
|
223
|
+
*
|
|
224
|
+
* Legacy format: { variables: { API_URL: 'https://...', API_KEY: 'secret' } }
|
|
225
|
+
* New format: { variables: [{ name: 'API_URL', value: 'https://...' }, { name: 'API_KEY', value: 'secret' }] }
|
|
226
|
+
*/
|
|
176
227
|
const transformLegacyEnvironments = (environments) => {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
])
|
|
192
|
-
);
|
|
228
|
+
const entries = Object.entries(environments || {});
|
|
229
|
+
if (entries.length === 0) {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
return Object.fromEntries(entries.map(([envName, env]) => [
|
|
233
|
+
envName,
|
|
234
|
+
coerceValue(xScalarEnvironmentSchema, {
|
|
235
|
+
color: env.color,
|
|
236
|
+
variables: Object.entries(env.variables || {}).map(([name, value]) => ({
|
|
237
|
+
name,
|
|
238
|
+
value: typeof value === 'string' ? value : value.default || '',
|
|
239
|
+
})),
|
|
240
|
+
}),
|
|
241
|
+
]));
|
|
193
242
|
};
|
|
243
|
+
/**
|
|
244
|
+
* Transforms legacy requests and request examples into OpenAPI paths.
|
|
245
|
+
*
|
|
246
|
+
* Each request becomes an operation in the paths object.
|
|
247
|
+
* Request examples are merged into parameter examples and request body examples.
|
|
248
|
+
*
|
|
249
|
+
* Also extracts servers from paths that contain full URLs (e.g., "https://api.example.com/users")
|
|
250
|
+
* and returns them separately for deduplication at the document level.
|
|
251
|
+
*/
|
|
194
252
|
const transformRequestsToPaths = (collection, dataRecords) => {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
+
const paths = Object.create(null);
|
|
254
|
+
const extractedServers = [];
|
|
255
|
+
for (const requestUid of collection.requests || []) {
|
|
256
|
+
const request = dataRecords.requests[requestUid];
|
|
257
|
+
if (!request) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const { path, method, uid: _uid, type: _type, selectedServerUid: _selectedServerUid, examples, servers, selectedSecuritySchemeUids: _selectedSecuritySchemeUids, parameters = [], requestBody, ...rest } = request;
|
|
261
|
+
let normalizedPath = path || '/';
|
|
262
|
+
/**
|
|
263
|
+
* Extract server from path if it contains a full URL.
|
|
264
|
+
* This handles legacy data where users may have entered full URLs as paths.
|
|
265
|
+
*/
|
|
266
|
+
const extractedServerUrl = extractServerFromPath(normalizedPath);
|
|
267
|
+
if (extractedServerUrl?.length === 2) {
|
|
268
|
+
const [serverUrl, remainingPath] = extractedServerUrl;
|
|
269
|
+
extractedServers.push({ url: serverUrl });
|
|
270
|
+
normalizedPath = remainingPath;
|
|
271
|
+
/**
|
|
272
|
+
* Handle edge case where the path after server is empty or just "/"
|
|
273
|
+
* Example: "https://api.example.com" → "" → "/"
|
|
274
|
+
*/
|
|
275
|
+
if (!normalizedPath) {
|
|
276
|
+
normalizedPath = '/';
|
|
277
|
+
}
|
|
278
|
+
// Handle double slashes from malformed URLs like "https://api.example.com//users"
|
|
279
|
+
else if (normalizedPath.startsWith('//')) {
|
|
280
|
+
normalizedPath = normalizedPath.slice(1);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Normalize relative paths to start with a leading slash. OpenAPI paths must start with "/" per the spec
|
|
284
|
+
if (!normalizedPath.startsWith('/')) {
|
|
285
|
+
normalizedPath = `/${normalizedPath}`;
|
|
286
|
+
}
|
|
287
|
+
// Initialize path object if it doesn't exist
|
|
288
|
+
if (!paths[normalizedPath]) {
|
|
289
|
+
paths[normalizedPath] = {};
|
|
290
|
+
}
|
|
291
|
+
/** Start building the OAS operation object */
|
|
292
|
+
const partialOperation = {
|
|
293
|
+
...rest,
|
|
294
|
+
};
|
|
295
|
+
// Get request examples for this request
|
|
296
|
+
const requestExamples = (examples || []).flatMap((exampleUid) => {
|
|
297
|
+
const example = dataRecords.requestExamples[exampleUid];
|
|
298
|
+
return example ? [example] : [];
|
|
299
|
+
});
|
|
300
|
+
// Merge examples into parameters
|
|
301
|
+
const mergedParameters = mergeExamplesIntoParameters(parameters, requestExamples);
|
|
302
|
+
if (mergedParameters.length > 0) {
|
|
303
|
+
partialOperation.parameters = mergedParameters;
|
|
304
|
+
}
|
|
305
|
+
// Merge examples into request body
|
|
306
|
+
const mergedRequestBody = mergeExamplesIntoRequestBody(requestBody, requestExamples);
|
|
307
|
+
if (mergedRequestBody) {
|
|
308
|
+
partialOperation.requestBody = mergedRequestBody;
|
|
309
|
+
}
|
|
310
|
+
// Add server overrides if present
|
|
311
|
+
if (servers && servers.length > 0) {
|
|
312
|
+
partialOperation.servers = servers.flatMap((serverUid) => {
|
|
313
|
+
const server = dataRecords.servers[serverUid];
|
|
314
|
+
if (!server) {
|
|
315
|
+
return [];
|
|
316
|
+
}
|
|
317
|
+
const { uid: _, ...rest } = server;
|
|
318
|
+
return [rest];
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
const pathItem = paths[normalizedPath];
|
|
322
|
+
if (pathItem) {
|
|
323
|
+
pathItem[method] = partialOperation;
|
|
253
324
|
}
|
|
254
|
-
const { uid: _, ...rest2 } = server;
|
|
255
|
-
return [rest2];
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
const pathItem = paths[normalizedPath];
|
|
259
|
-
if (pathItem) {
|
|
260
|
-
pathItem[method] = partialOperation;
|
|
261
325
|
}
|
|
262
|
-
|
|
263
|
-
return { paths, extractedServers };
|
|
326
|
+
return { paths, extractedServers };
|
|
264
327
|
};
|
|
328
|
+
/**
|
|
329
|
+
* The legacy data model uses plural "headers"/"cookies" for parameter categories,
|
|
330
|
+
* but OpenAPI uses singular "header"/"cookie" for the `in` field. This mapping
|
|
331
|
+
* normalizes the legacy names to their OpenAPI equivalents.
|
|
332
|
+
*/
|
|
265
333
|
const PARAM_TYPE_TO_IN = {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
334
|
+
path: 'path',
|
|
335
|
+
query: 'query',
|
|
336
|
+
headers: 'header',
|
|
337
|
+
cookies: 'cookie',
|
|
270
338
|
};
|
|
339
|
+
/**
|
|
340
|
+
* Ensures unique example names by appending #2, #3, etc. when duplicates are found.
|
|
341
|
+
* Does not use slugification - preserves the original name with a numeric suffix.
|
|
342
|
+
*/
|
|
271
343
|
const ensureUniqueExampleName = (baseName, usedNames) => {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
344
|
+
let uniqueName = baseName;
|
|
345
|
+
let counter = 2;
|
|
346
|
+
while (usedNames.has(uniqueName)) {
|
|
347
|
+
uniqueName = `${baseName} #${counter}`;
|
|
348
|
+
counter++;
|
|
349
|
+
}
|
|
350
|
+
usedNames.add(uniqueName);
|
|
351
|
+
return uniqueName;
|
|
280
352
|
};
|
|
353
|
+
/**
|
|
354
|
+
* Merges request example values into OpenAPI parameter objects.
|
|
355
|
+
*
|
|
356
|
+
* In the legacy data model, parameter values live on individual RequestExample
|
|
357
|
+
* objects (one per "example" tab in the UI). OpenAPI instead stores examples
|
|
358
|
+
* directly on each Parameter object via the `examples` map.
|
|
359
|
+
*/
|
|
281
360
|
const mergeExamplesIntoParameters = (parameters, requestExamples) => {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
paramEntries.set(`${param.in}:${param.name}`, {
|
|
311
|
-
param: paramObject,
|
|
312
|
-
examples: {}
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
const paramTypes = Object.keys(PARAM_TYPE_TO_IN);
|
|
316
|
-
const usedExampleNames = /* @__PURE__ */ new Set();
|
|
317
|
-
for (const requestExample of requestExamples) {
|
|
318
|
-
const baseName = requestExample.name || "Example";
|
|
319
|
-
const exampleName = ensureUniqueExampleName(baseName, usedExampleNames);
|
|
320
|
-
for (const paramType of paramTypes) {
|
|
321
|
-
const inValue = PARAM_TYPE_TO_IN[paramType];
|
|
322
|
-
const items = requestExample.parameters?.[paramType] || [];
|
|
323
|
-
for (const item of items) {
|
|
324
|
-
const key = `${inValue}:${item.key}`;
|
|
325
|
-
if (!item.key) {
|
|
326
|
-
continue;
|
|
327
|
-
}
|
|
328
|
-
const lowerKey = item.key.toLowerCase();
|
|
329
|
-
if (!paramEntries.has(key) && (lowerKey !== "content-type" || !CONTENT_TYPES[item.value]) && (lowerKey !== "accept" || item.value !== "*/*")) {
|
|
330
|
-
paramEntries.set(key, {
|
|
331
|
-
param: {
|
|
332
|
-
name: item.key,
|
|
333
|
-
in: inValue ?? "query",
|
|
334
|
-
required: inValue === "path",
|
|
335
|
-
deprecated: false,
|
|
336
|
-
schema: { type: "string" }
|
|
337
|
-
},
|
|
338
|
-
examples: {}
|
|
339
|
-
});
|
|
361
|
+
/**
|
|
362
|
+
* We track parameters and their collected examples together in a single map
|
|
363
|
+
* keyed by `{in}:{name}` (e.g. "query:page") to avoid a second lookup pass.
|
|
364
|
+
*/
|
|
365
|
+
const paramEntries = new Map();
|
|
366
|
+
// Seed with the operation's existing parameters so they are preserved even if
|
|
367
|
+
// no request example references them.
|
|
368
|
+
for (const param of parameters) {
|
|
369
|
+
// Build a type-safe ParameterObject by explicitly mapping properties
|
|
370
|
+
// The old RequestParameter type uses z.unknown() for schema/content/examples,
|
|
371
|
+
// but these values come from validated OpenAPI documents and are already in the correct format.
|
|
372
|
+
// We use type assertions (via unknown) to bridge from the old loose types to the new strict types.
|
|
373
|
+
// This is safe because the data has already been validated by the Zod schema.
|
|
374
|
+
// Build either ParameterWithSchemaObject or ParameterWithContentObject
|
|
375
|
+
let paramObject;
|
|
376
|
+
// Param with Content Type
|
|
377
|
+
if (param.content && typeof param.content === 'object') {
|
|
378
|
+
paramObject = {
|
|
379
|
+
name: param.name,
|
|
380
|
+
in: param.in,
|
|
381
|
+
required: param.required ?? param.in === 'path',
|
|
382
|
+
deprecated: param.deprecated ?? false,
|
|
383
|
+
content: param.content,
|
|
384
|
+
...(param.description && { description: param.description }),
|
|
385
|
+
};
|
|
340
386
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
387
|
+
// Param with Schema Type
|
|
388
|
+
else {
|
|
389
|
+
paramObject = {
|
|
390
|
+
name: param.name,
|
|
391
|
+
in: param.in,
|
|
392
|
+
required: param.required ?? param.in === 'path',
|
|
393
|
+
deprecated: param.deprecated ?? false,
|
|
394
|
+
...(param.description && { description: param.description }),
|
|
395
|
+
...(param.schema ? { schema: param.schema } : {}),
|
|
396
|
+
...(param.style && { style: param.style }),
|
|
397
|
+
...(param.explode !== undefined && { explode: param.explode }),
|
|
398
|
+
...(param.example !== undefined && { example: param.example }),
|
|
399
|
+
...(param.examples &&
|
|
400
|
+
typeof param.examples === 'object' && {
|
|
401
|
+
examples: param.examples,
|
|
402
|
+
}),
|
|
403
|
+
};
|
|
344
404
|
}
|
|
345
|
-
param.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
};
|
|
349
|
-
}
|
|
405
|
+
paramEntries.set(`${param.in}:${param.name}`, {
|
|
406
|
+
param: paramObject,
|
|
407
|
+
examples: {},
|
|
408
|
+
});
|
|
350
409
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
410
|
+
const paramTypes = Object.keys(PARAM_TYPE_TO_IN);
|
|
411
|
+
const usedExampleNames = new Set();
|
|
412
|
+
for (const requestExample of requestExamples) {
|
|
413
|
+
const baseName = requestExample.name || 'Example';
|
|
414
|
+
const exampleName = ensureUniqueExampleName(baseName, usedExampleNames);
|
|
415
|
+
for (const paramType of paramTypes) {
|
|
416
|
+
const inValue = PARAM_TYPE_TO_IN[paramType];
|
|
417
|
+
const items = requestExample.parameters?.[paramType] || [];
|
|
418
|
+
for (const item of items) {
|
|
419
|
+
const key = `${inValue}:${item.key}`;
|
|
420
|
+
// Lets not save any params without a key
|
|
421
|
+
if (!item.key) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const lowerKey = item.key.toLowerCase();
|
|
425
|
+
/**
|
|
426
|
+
* Lazily create a parameter stub when one does not already exist
|
|
427
|
+
* Path parameters are always required per the OpenAPI spec
|
|
428
|
+
*
|
|
429
|
+
* We do not add Accept: *\/*
|
|
430
|
+
* We do not add any Content-Type headers that are auto added in the client
|
|
431
|
+
*/
|
|
432
|
+
if (!paramEntries.has(key) &&
|
|
433
|
+
(lowerKey !== 'content-type' || !CONTENT_TYPES[item.value]) &&
|
|
434
|
+
(lowerKey !== 'accept' || item.value !== '*/*')) {
|
|
435
|
+
paramEntries.set(key, {
|
|
436
|
+
param: {
|
|
437
|
+
name: item.key,
|
|
438
|
+
in: inValue ?? 'query',
|
|
439
|
+
required: inValue === 'path',
|
|
440
|
+
deprecated: false,
|
|
441
|
+
schema: { type: 'string' },
|
|
442
|
+
},
|
|
443
|
+
examples: {},
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
// We have skipped the content-type or accept headers above
|
|
447
|
+
const param = paramEntries.get(key);
|
|
448
|
+
if (!param) {
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
param.examples[exampleName] = {
|
|
452
|
+
value: item.value,
|
|
453
|
+
'x-disabled': !item.enabled,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
356
457
|
}
|
|
357
|
-
|
|
358
|
-
|
|
458
|
+
// Build the final parameter list, only attaching `examples` when there are any
|
|
459
|
+
return Array.from(paramEntries.values()).map(({ param, examples }) => {
|
|
460
|
+
if (Object.keys(examples).length > 0) {
|
|
461
|
+
;
|
|
462
|
+
param.examples = examples;
|
|
463
|
+
}
|
|
464
|
+
return param;
|
|
465
|
+
});
|
|
359
466
|
};
|
|
467
|
+
/** Maps legacy raw body encoding names (e.g. "json", "xml") to their corresponding MIME content types */
|
|
360
468
|
const RAW_ENCODING_TO_CONTENT_TYPE = {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
469
|
+
json: 'application/json',
|
|
470
|
+
xml: 'application/xml',
|
|
471
|
+
yaml: 'application/yaml',
|
|
472
|
+
edn: 'application/edn',
|
|
473
|
+
text: 'text/plain',
|
|
474
|
+
html: 'text/html',
|
|
475
|
+
javascript: 'application/javascript',
|
|
368
476
|
};
|
|
477
|
+
/**
|
|
478
|
+
* Extracts the content type and example value from a single request example body.
|
|
479
|
+
*
|
|
480
|
+
* The legacy data model stored body content in one of three shapes:
|
|
481
|
+
* - `raw` — text-based body with an encoding hint (json, xml, etc.)
|
|
482
|
+
* - `formData` — key/value pairs with either multipart or URL-encoded encoding
|
|
483
|
+
* - `binary` — file upload with no inline content
|
|
484
|
+
*/
|
|
369
485
|
const extractBodyExample = (body) => {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
if (body.activeBody === "raw" && body.raw) {
|
|
374
|
-
return {
|
|
375
|
-
contentType: RAW_ENCODING_TO_CONTENT_TYPE[body.raw.encoding] || "text/plain",
|
|
376
|
-
value: body.raw.value
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
|
-
if (body.activeBody === "formData" && body.formData) {
|
|
380
|
-
return {
|
|
381
|
-
contentType: body.formData.encoding === "form-data" ? "multipart/form-data" : "application/x-www-form-urlencoded",
|
|
382
|
-
value: body.formData.value.flatMap(
|
|
383
|
-
(param) => param.key ? {
|
|
384
|
-
name: param.key,
|
|
385
|
-
value: param.value,
|
|
386
|
-
isDisabled: !param.enabled
|
|
387
|
-
} : []
|
|
388
|
-
)
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
if (body.activeBody === "binary") {
|
|
392
|
-
return { contentType: "binary", value: {} };
|
|
393
|
-
}
|
|
394
|
-
return void 0;
|
|
395
|
-
};
|
|
396
|
-
const mergeExamplesIntoRequestBody = (requestBody, requestExamples) => {
|
|
397
|
-
const groupedByContentType = /* @__PURE__ */ new Map();
|
|
398
|
-
const selectedContentTypes = {};
|
|
399
|
-
const usedExampleNames = /* @__PURE__ */ new Set();
|
|
400
|
-
for (const example of requestExamples) {
|
|
401
|
-
const extracted = extractBodyExample(example.body);
|
|
402
|
-
if (!extracted) {
|
|
403
|
-
continue;
|
|
486
|
+
if (!body?.activeBody) {
|
|
487
|
+
return undefined;
|
|
404
488
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
groupedByContentType.set(extracted.contentType, { [name]: { value: extracted.value } });
|
|
489
|
+
// Raw text body — resolve the short encoding name to a full MIME type
|
|
490
|
+
if (body.activeBody === 'raw' && body.raw) {
|
|
491
|
+
return {
|
|
492
|
+
contentType: RAW_ENCODING_TO_CONTENT_TYPE[body.raw.encoding] || 'text/plain',
|
|
493
|
+
value: body.raw.value,
|
|
494
|
+
};
|
|
412
495
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
result["x-scalar-selected-content-type"] = selectedContentTypes;
|
|
426
|
-
}
|
|
427
|
-
return result;
|
|
428
|
-
};
|
|
429
|
-
const transformLegacyTags = (collection, dataRecords) => {
|
|
430
|
-
const tags = [];
|
|
431
|
-
const tagGroups = [];
|
|
432
|
-
const topLevelTagUids = new Set(collection.children.filter((uid) => dataRecords.tags[uid] !== void 0));
|
|
433
|
-
const parentTagUids = new Set(
|
|
434
|
-
collection.tags.filter((uid) => {
|
|
435
|
-
const tag = dataRecords.tags[uid];
|
|
436
|
-
return tag?.children && tag.children.length > 0;
|
|
437
|
-
})
|
|
438
|
-
);
|
|
439
|
-
for (const tagUid of collection.tags) {
|
|
440
|
-
const tag = dataRecords.tags[tagUid];
|
|
441
|
-
if (!tag) {
|
|
442
|
-
continue;
|
|
496
|
+
// Form data — distinguish between multipart (file uploads) and URL-encoded
|
|
497
|
+
if (body.activeBody === 'formData' && body.formData) {
|
|
498
|
+
return {
|
|
499
|
+
contentType: body.formData.encoding === 'form-data' ? 'multipart/form-data' : 'application/x-www-form-urlencoded',
|
|
500
|
+
value: body.formData.value.flatMap((param) => param.key
|
|
501
|
+
? {
|
|
502
|
+
name: param.key,
|
|
503
|
+
value: param.value,
|
|
504
|
+
isDisabled: !param.enabled,
|
|
505
|
+
}
|
|
506
|
+
: []),
|
|
507
|
+
};
|
|
443
508
|
}
|
|
444
|
-
|
|
445
|
-
if (
|
|
446
|
-
|
|
447
|
-
if (childTagNames.length > 0) {
|
|
448
|
-
tagGroups.push({
|
|
449
|
-
name: tag.name,
|
|
450
|
-
tags: childTagNames
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
} else {
|
|
454
|
-
const tagObject = { name: tag.name };
|
|
455
|
-
if (tag.description) {
|
|
456
|
-
tagObject.description = tag.description;
|
|
457
|
-
}
|
|
458
|
-
if (tag.externalDocs) {
|
|
459
|
-
tagObject.externalDocs = tag.externalDocs;
|
|
460
|
-
}
|
|
461
|
-
tags.push(tagObject);
|
|
509
|
+
// Binary uploads have no inline content to migrate
|
|
510
|
+
if (body.activeBody === 'binary') {
|
|
511
|
+
return { contentType: 'binary', value: {} };
|
|
462
512
|
}
|
|
463
|
-
|
|
464
|
-
return { tags, tagGroups };
|
|
513
|
+
return undefined;
|
|
465
514
|
};
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
515
|
+
/**
|
|
516
|
+
* Merges request examples into request body examples.
|
|
517
|
+
*
|
|
518
|
+
* The v2.5.0 data model stored request examples separately from the
|
|
519
|
+
* operation's requestBody. In the new model, examples live directly inside
|
|
520
|
+
* `requestBody.content[contentType].examples`. This function bridges the two
|
|
521
|
+
* by grouping examples by content type in a single pass and writing them into
|
|
522
|
+
* the requestBody structure.
|
|
523
|
+
*
|
|
524
|
+
* Returns the original requestBody unchanged when no examples have body content.
|
|
525
|
+
*/
|
|
526
|
+
const mergeExamplesIntoRequestBody = (requestBody, requestExamples) => {
|
|
527
|
+
/**
|
|
528
|
+
* Single pass: extract each example body and bucket it by content type.
|
|
529
|
+
* Using a plain object as the inner value (instead of a nested Map) avoids
|
|
530
|
+
* a second conversion step when assigning to the result.
|
|
531
|
+
*/
|
|
532
|
+
const groupedByContentType = new Map();
|
|
533
|
+
/** We track the selected content type for each example */
|
|
534
|
+
const selectedContentTypes = {};
|
|
535
|
+
const usedExampleNames = new Set();
|
|
536
|
+
for (const example of requestExamples) {
|
|
537
|
+
const extracted = extractBodyExample(example.body);
|
|
538
|
+
if (!extracted) {
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
const baseName = example.name || 'Example';
|
|
542
|
+
const name = ensureUniqueExampleName(baseName, usedExampleNames);
|
|
543
|
+
const group = groupedByContentType.get(extracted.contentType);
|
|
544
|
+
if (group) {
|
|
545
|
+
group[name] = { value: extracted.value };
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
groupedByContentType.set(extracted.contentType, { [name]: { value: extracted.value } });
|
|
549
|
+
}
|
|
550
|
+
selectedContentTypes[name] = extracted.contentType;
|
|
474
551
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const allServers = [...existingServers, ...extractedServers];
|
|
479
|
-
const seenUrls = /* @__PURE__ */ new Set();
|
|
480
|
-
const deduplicatedServers = allServers.filter((server) => {
|
|
481
|
-
if (seenUrls.has(server.url)) {
|
|
482
|
-
return false;
|
|
552
|
+
// Nothing to merge — return early so we do not mutate the requestBody
|
|
553
|
+
if (groupedByContentType.size === 0) {
|
|
554
|
+
return requestBody;
|
|
483
555
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
556
|
+
// Ensure the requestBody and its content map exist before writing
|
|
557
|
+
const result = requestBody ?? {};
|
|
558
|
+
result.content ??= {};
|
|
559
|
+
for (const [contentType, examples] of groupedByContentType) {
|
|
560
|
+
result.content[contentType] ??= {};
|
|
561
|
+
result.content[contentType].examples = examples;
|
|
562
|
+
}
|
|
563
|
+
// Add the x-scalar-selected-content-type mapping
|
|
564
|
+
if (Object.keys(selectedContentTypes).length > 0) {
|
|
565
|
+
result['x-scalar-selected-content-type'] = selectedContentTypes;
|
|
566
|
+
}
|
|
567
|
+
return result;
|
|
568
|
+
};
|
|
569
|
+
/**
|
|
570
|
+
* Transforms legacy tags into OpenAPI tags and tag groups.
|
|
571
|
+
*
|
|
572
|
+
* Legacy structure:
|
|
573
|
+
* - Tags can have children (nested tags)
|
|
574
|
+
* - Top-level parent tags become tag groups
|
|
575
|
+
* - Child tags and standalone tags become regular tags
|
|
576
|
+
*/
|
|
577
|
+
const transformLegacyTags = (collection, dataRecords) => {
|
|
578
|
+
const tags = [];
|
|
579
|
+
const tagGroups = [];
|
|
495
580
|
/**
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
* requestBodies, headers, securitySchemes, links, callbacks, pathItems
|
|
581
|
+
* Identifies which tags are top-level (appear in collection.children).
|
|
582
|
+
* Top-level parent tags become tag groups, others become regular tags.
|
|
499
583
|
*/
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
[securityScheme.nameKey]: {
|
|
517
|
-
...publicSecurityScheme,
|
|
518
|
-
flows: objectEntries(securityScheme.flows).reduce(
|
|
519
|
-
(acc2, [key, flow]) => {
|
|
520
|
-
if (!flow) {
|
|
521
|
-
return acc2;
|
|
522
|
-
}
|
|
523
|
-
if ("selectedScopes" in flow && Array.isArray(flow.selectedScopes)) {
|
|
524
|
-
flow.selectedScopes?.forEach((scope) => selectedScopes.add(scope));
|
|
525
|
-
}
|
|
526
|
-
acc2[key] = removeSecretFields(flow);
|
|
527
|
-
return acc2;
|
|
528
|
-
},
|
|
529
|
-
{}
|
|
530
|
-
),
|
|
531
|
-
"x-default-scopes": Array.from(selectedScopes)
|
|
532
|
-
}
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
return {
|
|
536
|
-
...acc,
|
|
537
|
-
[securityScheme.nameKey]: publicSecurityScheme
|
|
538
|
-
};
|
|
539
|
-
}, {})
|
|
540
|
-
}
|
|
541
|
-
},
|
|
542
|
-
security: collection.security || [],
|
|
543
|
-
tags,
|
|
544
|
-
webhooks: collection.webhooks,
|
|
545
|
-
externalDocs: collection.externalDocs,
|
|
546
|
-
// Preserve scalar extensions
|
|
547
|
-
"x-scalar-icon": collection["x-scalar-icon"],
|
|
548
|
-
// Convert legacy record-based environment variables to the new array format
|
|
549
|
-
"x-scalar-environments": transformLegacyEnvironments(collection["x-scalar-environments"])
|
|
550
|
-
};
|
|
551
|
-
if (tagGroups.length > 0) {
|
|
552
|
-
document["x-tagGroups"] = tagGroups;
|
|
553
|
-
}
|
|
554
|
-
if (collection["x-scalar-active-environment"]) {
|
|
555
|
-
document["x-scalar-active-environment"] = collection["x-scalar-active-environment"];
|
|
556
|
-
}
|
|
557
|
-
if (selectedServerUrl) {
|
|
558
|
-
document["x-scalar-selected-server"] = selectedServerUrl;
|
|
559
|
-
}
|
|
560
|
-
if (collection.documentUrl) {
|
|
561
|
-
document["x-scalar-original-source-url"] = collection.documentUrl;
|
|
562
|
-
}
|
|
563
|
-
const safeDocument = toJsonCompatible(document);
|
|
564
|
-
return {
|
|
565
|
-
document: safeDocument,
|
|
566
|
-
auth: coerceValue(AuthSchema, {
|
|
567
|
-
secrets: collection.securitySchemes.reduce((acc, uid) => {
|
|
568
|
-
const securityScheme = dataRecords.securitySchemes[uid];
|
|
569
|
-
if (!securityScheme) {
|
|
570
|
-
return acc;
|
|
584
|
+
const topLevelTagUids = new Set(collection.children.filter((uid) => dataRecords.tags[uid] !== undefined));
|
|
585
|
+
/**
|
|
586
|
+
* Identifies which tags have children.
|
|
587
|
+
* Only top-level parent tags become tag groups.
|
|
588
|
+
*/
|
|
589
|
+
const parentTagUids = new Set(collection.tags.filter((uid) => {
|
|
590
|
+
const tag = dataRecords.tags[uid];
|
|
591
|
+
return tag?.children && tag.children.length > 0;
|
|
592
|
+
}));
|
|
593
|
+
/**
|
|
594
|
+
* Process each tag to create either a tag group or a regular tag.
|
|
595
|
+
*/
|
|
596
|
+
for (const tagUid of collection.tags) {
|
|
597
|
+
const tag = dataRecords.tags[tagUid];
|
|
598
|
+
if (!tag) {
|
|
599
|
+
continue;
|
|
571
600
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
)
|
|
601
|
+
const isTopLevelParent = topLevelTagUids.has(tagUid) && parentTagUids.has(tagUid);
|
|
602
|
+
if (isTopLevelParent) {
|
|
603
|
+
/**
|
|
604
|
+
* Top-level parent tags become tag groups.
|
|
605
|
+
* Resolve child tag names, filtering out any missing children.
|
|
606
|
+
*/
|
|
607
|
+
const childTagNames = tag.children
|
|
608
|
+
.map((childUid) => dataRecords.tags[childUid]?.name)
|
|
609
|
+
.filter((name) => name !== undefined);
|
|
610
|
+
if (childTagNames.length > 0) {
|
|
611
|
+
tagGroups.push({
|
|
612
|
+
name: tag.name,
|
|
613
|
+
tags: childTagNames,
|
|
614
|
+
});
|
|
587
615
|
}
|
|
588
|
-
};
|
|
589
616
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
617
|
+
else {
|
|
618
|
+
/**
|
|
619
|
+
* All other tags (child tags and standalone tags) become regular tags.
|
|
620
|
+
* Preserve optional fields from the legacy tag.
|
|
621
|
+
*/
|
|
622
|
+
const tagObject = { name: tag.name };
|
|
623
|
+
if (tag.description) {
|
|
624
|
+
tagObject.description = tag.description;
|
|
625
|
+
}
|
|
626
|
+
if (tag.externalDocs) {
|
|
627
|
+
tagObject.externalDocs = tag.externalDocs;
|
|
628
|
+
}
|
|
629
|
+
tags.push(tagObject);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return { tags, tagGroups };
|
|
601
633
|
};
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
634
|
+
/** Transforms a collection and everything it includes into a WorkspaceDocument + auth */
|
|
635
|
+
const transformCollectionToDocument = (documentName, collection, dataRecords) => {
|
|
636
|
+
// Resolve selectedServerUid → server URL for x-scalar-selected-server
|
|
637
|
+
const selectedServerUrl = collection.selectedServerUid && dataRecords.servers[collection.selectedServerUid]
|
|
638
|
+
? dataRecords.servers[collection.selectedServerUid]?.url
|
|
639
|
+
: undefined;
|
|
640
|
+
// Transform tags: separate parent tags (groups) from child tags
|
|
641
|
+
const { tags, tagGroups } = transformLegacyTags(collection, dataRecords);
|
|
642
|
+
// Transform requests into paths and extract servers from full URLs
|
|
643
|
+
const { paths, extractedServers } = transformRequestsToPaths(collection, dataRecords);
|
|
644
|
+
/**
|
|
645
|
+
* Merge and deduplicate servers:
|
|
646
|
+
* 1. Start with existing collection servers
|
|
647
|
+
* 2. Add extracted servers from paths
|
|
648
|
+
* 3. Deduplicate by URL (keep first occurrence)
|
|
649
|
+
*/
|
|
650
|
+
const existingServers = collection.servers.flatMap((uid) => {
|
|
651
|
+
const server = dataRecords.servers[uid];
|
|
652
|
+
if (!server) {
|
|
653
|
+
return [];
|
|
654
|
+
}
|
|
655
|
+
const { uid: _, ...rest } = server;
|
|
656
|
+
return [rest];
|
|
657
|
+
});
|
|
658
|
+
const allServers = [...existingServers, ...extractedServers];
|
|
659
|
+
const seenUrls = new Set();
|
|
660
|
+
const deduplicatedServers = allServers.filter((server) => {
|
|
661
|
+
if (seenUrls.has(server.url)) {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
seenUrls.add(server.url);
|
|
665
|
+
return true;
|
|
666
|
+
});
|
|
667
|
+
const document = {
|
|
668
|
+
openapi: collection.openapi || '3.1.0',
|
|
669
|
+
info: collection.info || {
|
|
670
|
+
title: documentName,
|
|
671
|
+
version: '1.0',
|
|
672
|
+
},
|
|
673
|
+
servers: deduplicatedServers,
|
|
674
|
+
paths,
|
|
675
|
+
/**
|
|
676
|
+
* Preserve all component types from the collection and merge with transformed security schemes.
|
|
677
|
+
* OpenAPI components object supports: schemas, responses, parameters, examples,
|
|
678
|
+
* requestBodies, headers, securitySchemes, links, callbacks, pathItems
|
|
679
|
+
*/
|
|
680
|
+
components: {
|
|
681
|
+
// Preserve existing components from the collection (schemas, responses, parameters, etc.)
|
|
682
|
+
...(collection.components || {}),
|
|
683
|
+
// Merge security schemes (transformed from UIDs) with any existing security schemes
|
|
684
|
+
securitySchemes: {
|
|
685
|
+
...(collection.components?.securitySchemes || {}),
|
|
686
|
+
...collection.securitySchemes.reduce((acc, uid) => {
|
|
687
|
+
const securityScheme = dataRecords.securitySchemes[uid];
|
|
688
|
+
if (!securityScheme) {
|
|
689
|
+
return acc;
|
|
690
|
+
}
|
|
691
|
+
const { uid: _uid, nameKey: _nameKey, ...publicSecurityScheme } = removeSecretFields(securityScheme);
|
|
692
|
+
// Clean the flows
|
|
693
|
+
if (securityScheme.type === 'oauth2') {
|
|
694
|
+
const selectedScopes = new Set();
|
|
695
|
+
return {
|
|
696
|
+
...acc,
|
|
697
|
+
[securityScheme.nameKey]: {
|
|
698
|
+
...publicSecurityScheme,
|
|
699
|
+
flows: objectEntries(securityScheme.flows).reduce((acc, [key, flow]) => {
|
|
700
|
+
if (!flow) {
|
|
701
|
+
return acc;
|
|
702
|
+
}
|
|
703
|
+
// Store any selected scopes from the config
|
|
704
|
+
if ('selectedScopes' in flow && Array.isArray(flow.selectedScopes)) {
|
|
705
|
+
flow.selectedScopes?.forEach((scope) => selectedScopes.add(scope));
|
|
706
|
+
}
|
|
707
|
+
acc[key] = removeSecretFields(flow);
|
|
708
|
+
return acc;
|
|
709
|
+
}, {}),
|
|
710
|
+
'x-default-scopes': Array.from(selectedScopes),
|
|
711
|
+
},
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
...acc,
|
|
716
|
+
[securityScheme.nameKey]: publicSecurityScheme,
|
|
717
|
+
};
|
|
718
|
+
}, {}),
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
security: collection.security || [],
|
|
722
|
+
tags,
|
|
723
|
+
webhooks: collection.webhooks,
|
|
724
|
+
externalDocs: collection.externalDocs,
|
|
725
|
+
// Preserve scalar extensions
|
|
726
|
+
'x-scalar-icon': collection['x-scalar-icon'],
|
|
727
|
+
// Convert legacy record-based environment variables to the new array format
|
|
728
|
+
'x-scalar-environments': transformLegacyEnvironments(collection['x-scalar-environments']),
|
|
729
|
+
};
|
|
730
|
+
// Add x-tagGroups if there are any parent tags
|
|
731
|
+
if (tagGroups.length > 0) {
|
|
732
|
+
document['x-tagGroups'] = tagGroups;
|
|
733
|
+
}
|
|
734
|
+
// x-scalar-active-environment
|
|
735
|
+
if (collection['x-scalar-active-environment']) {
|
|
736
|
+
document['x-scalar-active-environment'] = collection['x-scalar-active-environment'];
|
|
737
|
+
}
|
|
738
|
+
// selectedServerUid → x-scalar-selected-server (resolved to URL)
|
|
739
|
+
if (selectedServerUrl) {
|
|
740
|
+
document['x-scalar-selected-server'] = selectedServerUrl;
|
|
741
|
+
}
|
|
742
|
+
// documentUrl → x-scalar-original-source-url
|
|
743
|
+
if (collection.documentUrl) {
|
|
744
|
+
document['x-scalar-original-source-url'] = collection.documentUrl;
|
|
745
|
+
}
|
|
746
|
+
// Convert circular references to $ref pointers which is safe for JSON serialization
|
|
747
|
+
const safeDocument = toJsonCompatible(document);
|
|
748
|
+
return {
|
|
749
|
+
document: safeDocument,
|
|
750
|
+
auth: coerceValue(AuthSchema, {
|
|
751
|
+
secrets: collection.securitySchemes.reduce((acc, uid) => {
|
|
752
|
+
const securityScheme = dataRecords.securitySchemes[uid];
|
|
753
|
+
if (!securityScheme) {
|
|
754
|
+
return acc;
|
|
755
|
+
}
|
|
756
|
+
// Oauth 2
|
|
757
|
+
if (securityScheme.type === 'oauth2') {
|
|
758
|
+
return {
|
|
759
|
+
...acc,
|
|
760
|
+
[securityScheme.nameKey]: {
|
|
761
|
+
type: securityScheme.type,
|
|
762
|
+
...objectEntries(securityScheme.flows).reduce((acc, [key, flow]) => {
|
|
763
|
+
if (!flow) {
|
|
764
|
+
return acc;
|
|
765
|
+
}
|
|
766
|
+
acc[key] = extractConfigSecrets(flow);
|
|
767
|
+
return acc;
|
|
768
|
+
}, {}),
|
|
769
|
+
},
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
// The rest
|
|
773
|
+
return {
|
|
774
|
+
...acc,
|
|
775
|
+
[securityScheme.nameKey]: {
|
|
776
|
+
type: securityScheme.type,
|
|
777
|
+
...extractConfigSecrets(securityScheme),
|
|
778
|
+
},
|
|
779
|
+
};
|
|
780
|
+
}, {}),
|
|
781
|
+
selected: {},
|
|
782
|
+
}),
|
|
783
|
+
};
|
|
606
784
|
};
|
|
607
|
-
//# sourceMappingURL=migrate-to-indexdb.js.map
|