UNPKG

@scalar/oas-utils

Version:

Open API spec and Yaml handling utilities

796 lines (795 loc) 34.8 kB
import { createLimiter } from '@scalar/helpers/general/create-limiter'; import { extractConfigSecrets, removeSecretFields } from '@scalar/helpers/general/extract-config-secrets'; import { CONTENT_TYPES } from '@scalar/helpers/http/content-types'; import { objectEntries } from '@scalar/helpers/object/object-entries'; import { toJsonCompatible } from '@scalar/helpers/object/to-json-compatible'; import { slugger } from '@scalar/helpers/string/slugger'; import { extractServerFromPath } from '@scalar/helpers/url/extract-server-from-path'; import { presets } from '@scalar/themes'; import { createWorkspaceStore } from '@scalar/workspace-store/client'; import { AuthSchema } from '@scalar/workspace-store/entities/auth'; import { createWorkspaceStorePersistence, generateWorkspaceUid } from '@scalar/workspace-store/persistence'; import { xScalarEnvironmentSchema, } from '@scalar/workspace-store/schemas/extensions/document/x-scalar-environments'; import { xScalarCookieSchema } from '@scalar/workspace-store/schemas/extensions/general/x-scalar-cookies'; import { isOpenApiDocument } from '@scalar/workspace-store/schemas/type-guards'; import { coerceValue } from '@scalar/workspace-store/schemas/typebox-coerce'; import { ColorModeSchema } from '@scalar/workspace-store/schemas/workspace'; import { migrator } from '../migrations/migrator.js'; const DRAFTS_DOCUMENT_NAME = 'drafts'; const MAX_CONCURRENT_DB_WRITES = 100; const MAX_CONCURRENT_DATA_TRANSFORMATIONS = 5; /** * Migrates localStorage data to IndexedDB workspace structure. * * Called early in app initialization (app-state.ts) before workspace data loads. * Idempotent and non-destructive - runs when legacy data exists but IndexedDB is empty. * * Flow: * 1. Check if migration needed (has legacy data + IndexedDB is empty) * 2. Run existing migrations to get latest data structure * 3. Transform to new workspace format * 4. Save to IndexedDB * * Old data is preserved for rollback. Typically completes in < 1 second. */ export const migrateLocalStorageToIndexDb = async () => { const { close, workspace: workspacePersistence } = await createWorkspaceStorePersistence(); try { const shouldMigrate = await shouldMigrateToIndexDb(workspacePersistence); if (!shouldMigrate) { return; } console.info('🚀 Starting migration from localStorage to IndexedDB...'); // Step 1: Run existing migrations to get latest data structure const legacyData = migrator(); console.info(`📦 Found legacy data: ${legacyData.arrays.workspaces.length} workspace(s), ${legacyData.arrays.collections.length} collection(s)`); // Step 2: Transform to new workspace structure const workspaces = await transformLegacyDataToWorkspace(legacyData); const limit = createLimiter(MAX_CONCURRENT_DB_WRITES); // Step 3: Save to IndexedDB. Every migrated workspace is keyed by a // fresh `workspaceUid` and lands under the local team — legacy // installs predate the team concept, so there is no team membership // to preserve. The slug from legacy data becomes the URL-facing slug // and is unique within the local team by construction (it derives // from the legacy workspace UID). await Promise.all(workspaces.map((workspace) => limit(() => workspacePersistence.setItem({ workspaceUid: generateWorkspaceUid(), teamUid: 'local', teamSlug: 'local', slug: workspace.slug, }, { name: workspace.name, workspace: workspace.workspace, })))); console.info(`✅ Successfully migrated ${workspaces.length} workspace(s) to IndexedDB`); } catch (error) { console.error('❌ Migration failed:', error); } finally { close(); } }; /** * Checks if migration is needed by verifying IndexedDB state and presence of legacy data. * * Migration is needed when: * 1. Legacy data exists in localStorage (workspace, collection, or request keys) * 2. AND IndexedDB has no workspaces yet * * This approach is more reliable than using a flag because: * - If IndexedDB is cleared, migration will run again automatically * - No risk of flag getting out of sync with actual data state * - Handles edge cases like partial migrations or database corruption */ export const shouldMigrateToIndexDb = async (workspacePersistence) => { // Check if there is any old data in localStorage const hasLegacyData = localStorage.getItem('workspace') !== null || localStorage.getItem('collection') !== null || localStorage.getItem('request') !== null; if (!hasLegacyData) { return false; } // Check if IndexedDB already has workspaces const existingWorkspaces = await workspacePersistence.getAll(); const hasIndexDbData = existingWorkspaces.length > 0; // Only migrate if we have legacy data but no IndexedDB data return !hasIndexDbData; }; /** * Transforms legacy localStorage data into IndexedDB workspace structure. * * Transformations: * - Collections → Documents (collections were OpenAPI specs) * - Environments → x-scalar-environments in meta * - Cookies → x-scalar-cookies in meta * - Workspace properties → meta extensions (activeEnvironmentId, proxyUrl, themeId) * * Creates a default workspace if none exist. Falls back to collection uid if info.title is missing. */ export const transformLegacyDataToWorkspace = async (legacyData) => { const limitWorkspaceTransform = createLimiter(MAX_CONCURRENT_DATA_TRANSFORMATIONS); return await Promise.all(legacyData.arrays.workspaces.map((workspace) => limitWorkspaceTransform(async () => { /** Grab auth from the collections */ const workspaceAuth = {}; /** Create a slugger instance per workspace to handle duplicate document names */ const { slug } = slugger(); /** Each collection becomes a document in the new system and grab the auth as well */ const documents = workspace.collections.flatMap((uid) => { const collection = legacyData.records.collections[uid]; if (!collection) { return []; } const documentName = collection.info?.title || 'api'; const { document, auth } = transformCollectionToDocument(documentName, collection, legacyData.records); // Normalize document name to match the store (lowercase "Drafts" → "drafts") const normalizedName = documentName === 'Drafts' ? 'drafts' : documentName; // Use GitHubSlugger to ensure unique document names const uniqueName = slug(normalizedName); workspaceAuth[uniqueName] = auth; return { name: uniqueName, document }; }); const meta = {}; const extensions = {}; // Add environment const environmentEntries = Object.entries(workspace.environments); if (environmentEntries.length > 0) { extensions['x-scalar-environments'] = { default: coerceValue(xScalarEnvironmentSchema, { variables: environmentEntries.map(([name, value]) => ({ name, value, })), }), }; } // Add cookies to meta if (workspace.cookies.length > 0) { extensions['x-scalar-cookies'] = workspace.cookies.flatMap((uid) => { const cookie = legacyData.records.cookies[uid]; return cookie ? coerceValue(xScalarCookieSchema, cookie) : []; }); } // Add proxy URL if present if (workspace.proxyUrl) { meta['x-scalar-active-proxy'] = workspace.proxyUrl; } // Add theme if present if (workspace.themeId) { // We use theme slugs on the new system so we need to transform the id to the slug meta['x-scalar-theme'] = transformThemeIdToSlug(workspace.themeId); } // Set color mode if (localStorage.getItem('colorMode')) { meta['x-scalar-color-mode'] = coerceValue(ColorModeSchema, localStorage.getItem('colorMode')); } const store = createWorkspaceStore({ meta, }); const limitDocumentAdd = createLimiter(MAX_CONCURRENT_DATA_TRANSFORMATIONS); await Promise.all(documents.map(({ name, document }) => limitDocumentAdd(async () => { await store.addDocument({ name, document, }); // Note: we are breaking the relationship between the document and the originial source url }))); // Try to always set the drafts / route if (!(DRAFTS_DOCUMENT_NAME in store.workspace.documents)) { await store.addDocument({ name: DRAFTS_DOCUMENT_NAME, document: { openapi: '3.1.0', info: { title: 'Drafts', version: '1.0.0', }, paths: { '/': { get: {}, }, }, 'x-scalar-icon': 'interface-edit-tool-pencil', }, }); } const drafts = store.workspace.documents[DRAFTS_DOCUMENT_NAME]; // The drafts document is always OpenAPI-shaped (constructed above with `paths`); the // guard narrows the WorkspaceDocument union so we can access `.paths` safely. if (isOpenApiDocument(drafts)) { // Make sure the drafts document has a GET / route cuz that's the first route we navigate the user to drafts.paths ??= {}; drafts.paths['/'] ??= {}; drafts.paths['/']['get'] ??= {}; } store.buildSidebar(DRAFTS_DOCUMENT_NAME); // save the document to the store so we don't see the document as dirty await store.saveDocument(DRAFTS_DOCUMENT_NAME); // Load the auth into the store store.auth.load(workspaceAuth); // Load the extensions into the store objectEntries(extensions).forEach(([key, value]) => { store.update(key, value); }); return { slug: workspace.uid.toString(), // Convert to string to convert it to a simple string type name: workspace.name || 'Untitled Workspace', workspace: store.exportWorkspace(), }; }))); }; /** * Converts a ThemeId to its corresponding theme slug. * If the themeId is 'none', return it as is. * Otherwise, look up the slug in the presets object. */ const transformThemeIdToSlug = (themeId) => { if (themeId === 'none') { return themeId; } return presets[themeId]?.slug ?? 'default'; }; /** * Converts legacy environment variables from record format to the new array format. * * Legacy format: { variables: { API_URL: 'https://...', API_KEY: 'secret' } } * New format: { variables: [{ name: 'API_URL', value: 'https://...' }, { name: 'API_KEY', value: 'secret' }] } */ const transformLegacyEnvironments = (environments) => { const entries = Object.entries(environments || {}); if (entries.length === 0) { return undefined; } return Object.fromEntries(entries.map(([envName, env]) => [ envName, coerceValue(xScalarEnvironmentSchema, { color: env.color, variables: Object.entries(env.variables || {}).map(([name, value]) => ({ name, value: typeof value === 'string' ? value : value.default || '', })), }), ])); }; /** * Transforms legacy requests and request examples into OpenAPI paths. * * Each request becomes an operation in the paths object. * Request examples are merged into parameter examples and request body examples. * * Also extracts servers from paths that contain full URLs (e.g., "https://api.example.com/users") * and returns them separately for deduplication at the document level. */ const transformRequestsToPaths = (collection, dataRecords) => { const paths = Object.create(null); const extractedServers = []; for (const requestUid of collection.requests || []) { const request = dataRecords.requests[requestUid]; if (!request) { continue; } const { path, method, uid: _uid, type: _type, selectedServerUid: _selectedServerUid, examples, servers, selectedSecuritySchemeUids: _selectedSecuritySchemeUids, parameters = [], requestBody, ...rest } = request; let normalizedPath = path || '/'; /** * Extract server from path if it contains a full URL. * This handles legacy data where users may have entered full URLs as paths. */ const extractedServerUrl = extractServerFromPath(normalizedPath); if (extractedServerUrl?.length === 2) { const [serverUrl, remainingPath] = extractedServerUrl; extractedServers.push({ url: serverUrl }); normalizedPath = remainingPath; /** * Handle edge case where the path after server is empty or just "/" * Example: "https://api.example.com" → "" → "/" */ if (!normalizedPath) { normalizedPath = '/'; } // Handle double slashes from malformed URLs like "https://api.example.com//users" else if (normalizedPath.startsWith('//')) { normalizedPath = normalizedPath.slice(1); } } // Normalize relative paths to start with a leading slash. OpenAPI paths must start with "/" per the spec if (!normalizedPath.startsWith('/')) { normalizedPath = `/${normalizedPath}`; } // Initialize path object if it doesn't exist if (!paths[normalizedPath]) { paths[normalizedPath] = {}; } /** Start building the OAS operation object */ const partialOperation = { ...rest, }; // Get request examples for this request const requestExamples = (examples || []).flatMap((exampleUid) => { const example = dataRecords.requestExamples[exampleUid]; return example ? [example] : []; }); // Merge examples into parameters const mergedParameters = mergeExamplesIntoParameters(parameters, requestExamples); if (mergedParameters.length > 0) { partialOperation.parameters = mergedParameters; } // Merge examples into request body const mergedRequestBody = mergeExamplesIntoRequestBody(requestBody, requestExamples); if (mergedRequestBody) { partialOperation.requestBody = mergedRequestBody; } // Add server overrides if present if (servers && servers.length > 0) { partialOperation.servers = servers.flatMap((serverUid) => { const server = dataRecords.servers[serverUid]; if (!server) { return []; } const { uid: _, ...rest } = server; return [rest]; }); } const pathItem = paths[normalizedPath]; if (pathItem) { pathItem[method] = partialOperation; } } return { paths, extractedServers }; }; /** * The legacy data model uses plural "headers"/"cookies" for parameter categories, * but OpenAPI uses singular "header"/"cookie" for the `in` field. This mapping * normalizes the legacy names to their OpenAPI equivalents. */ const PARAM_TYPE_TO_IN = { path: 'path', query: 'query', headers: 'header', cookies: 'cookie', }; /** * Ensures unique example names by appending #2, #3, etc. when duplicates are found. * Does not use slugification - preserves the original name with a numeric suffix. */ const ensureUniqueExampleName = (baseName, usedNames) => { let uniqueName = baseName; let counter = 2; while (usedNames.has(uniqueName)) { uniqueName = `${baseName} #${counter}`; counter++; } usedNames.add(uniqueName); return uniqueName; }; /** * Merges request example values into OpenAPI parameter objects. * * In the legacy data model, parameter values live on individual RequestExample * objects (one per "example" tab in the UI). OpenAPI instead stores examples * directly on each Parameter object via the `examples` map. */ const mergeExamplesIntoParameters = (parameters, requestExamples) => { /** * We track parameters and their collected examples together in a single map * keyed by `{in}:{name}` (e.g. "query:page") to avoid a second lookup pass. */ const paramEntries = new Map(); // Seed with the operation's existing parameters so they are preserved even if // no request example references them. for (const param of parameters) { // Build a type-safe ParameterObject by explicitly mapping properties // The old RequestParameter type uses z.unknown() for schema/content/examples, // but these values come from validated OpenAPI documents and are already in the correct format. // We use type assertions (via unknown) to bridge from the old loose types to the new strict types. // This is safe because the data has already been validated by the Zod schema. // Build either ParameterWithSchemaObject or ParameterWithContentObject let paramObject; // Param with Content Type if (param.content && typeof param.content === 'object') { paramObject = { name: param.name, in: param.in, required: param.required ?? param.in === 'path', deprecated: param.deprecated ?? false, content: param.content, ...(param.description && { description: param.description }), }; } // Param with Schema Type else { paramObject = { name: param.name, in: param.in, required: param.required ?? param.in === 'path', deprecated: param.deprecated ?? false, ...(param.description && { description: param.description }), ...(param.schema ? { schema: param.schema } : {}), ...(param.style && { style: param.style }), ...(param.explode !== undefined && { explode: param.explode }), ...(param.example !== undefined && { example: param.example }), ...(param.examples && typeof param.examples === 'object' && { examples: param.examples, }), }; } paramEntries.set(`${param.in}:${param.name}`, { param: paramObject, examples: {}, }); } const paramTypes = Object.keys(PARAM_TYPE_TO_IN); const usedExampleNames = new Set(); for (const requestExample of requestExamples) { const baseName = requestExample.name || 'Example'; const exampleName = ensureUniqueExampleName(baseName, usedExampleNames); for (const paramType of paramTypes) { const inValue = PARAM_TYPE_TO_IN[paramType]; const items = requestExample.parameters?.[paramType] || []; for (const item of items) { const key = `${inValue}:${item.key}`; // Lets not save any params without a key if (!item.key) { continue; } const lowerKey = item.key.toLowerCase(); /** * Lazily create a parameter stub when one does not already exist * Path parameters are always required per the OpenAPI spec * * We do not add Accept: *\/* * We do not add any Content-Type headers that are auto added in the client */ if (!paramEntries.has(key) && (lowerKey !== 'content-type' || !CONTENT_TYPES[item.value]) && (lowerKey !== 'accept' || item.value !== '*/*')) { paramEntries.set(key, { param: { name: item.key, in: inValue ?? 'query', required: inValue === 'path', deprecated: false, schema: { type: 'string' }, }, examples: {}, }); } // We have skipped the content-type or accept headers above const param = paramEntries.get(key); if (!param) { continue; } param.examples[exampleName] = { value: item.value, 'x-disabled': !item.enabled, }; } } } // Build the final parameter list, only attaching `examples` when there are any return Array.from(paramEntries.values()).map(({ param, examples }) => { if (Object.keys(examples).length > 0) { ; param.examples = examples; } return param; }); }; /** Maps legacy raw body encoding names (e.g. "json", "xml") to their corresponding MIME content types */ const RAW_ENCODING_TO_CONTENT_TYPE = { json: 'application/json', xml: 'application/xml', yaml: 'application/yaml', edn: 'application/edn', text: 'text/plain', html: 'text/html', javascript: 'application/javascript', }; /** * Extracts the content type and example value from a single request example body. * * The legacy data model stored body content in one of three shapes: * - `raw` — text-based body with an encoding hint (json, xml, etc.) * - `formData` — key/value pairs with either multipart or URL-encoded encoding * - `binary` — file upload with no inline content */ const extractBodyExample = (body) => { if (!body?.activeBody) { return undefined; } // Raw text body — resolve the short encoding name to a full MIME type if (body.activeBody === 'raw' && body.raw) { return { contentType: RAW_ENCODING_TO_CONTENT_TYPE[body.raw.encoding] || 'text/plain', value: body.raw.value, }; } // Form data — distinguish between multipart (file uploads) and URL-encoded if (body.activeBody === 'formData' && body.formData) { return { contentType: body.formData.encoding === 'form-data' ? 'multipart/form-data' : 'application/x-www-form-urlencoded', value: body.formData.value.flatMap((param) => param.key ? { name: param.key, value: param.value, isDisabled: !param.enabled, } : []), }; } // Binary uploads have no inline content to migrate if (body.activeBody === 'binary') { return { contentType: 'binary', value: {} }; } return undefined; }; /** * Merges request examples into request body examples. * * The v2.5.0 data model stored request examples separately from the * operation's requestBody. In the new model, examples live directly inside * `requestBody.content[contentType].examples`. This function bridges the two * by grouping examples by content type in a single pass and writing them into * the requestBody structure. * * Returns the original requestBody unchanged when no examples have body content. */ const mergeExamplesIntoRequestBody = (requestBody, requestExamples) => { /** * Single pass: extract each example body and bucket it by content type. * Using a plain object as the inner value (instead of a nested Map) avoids * a second conversion step when assigning to the result. */ const groupedByContentType = new Map(); /** We track the selected content type for each example */ const selectedContentTypes = {}; const usedExampleNames = new Set(); for (const example of requestExamples) { const extracted = extractBodyExample(example.body); if (!extracted) { continue; } const baseName = example.name || 'Example'; const name = ensureUniqueExampleName(baseName, usedExampleNames); const group = groupedByContentType.get(extracted.contentType); if (group) { group[name] = { value: extracted.value }; } else { groupedByContentType.set(extracted.contentType, { [name]: { value: extracted.value } }); } selectedContentTypes[name] = extracted.contentType; } // Nothing to merge — return early so we do not mutate the requestBody if (groupedByContentType.size === 0) { return requestBody; } // Ensure the requestBody and its content map exist before writing const result = requestBody ?? {}; result.content ??= {}; for (const [contentType, examples] of groupedByContentType) { result.content[contentType] ??= {}; result.content[contentType].examples = examples; } // Add the x-scalar-selected-content-type mapping if (Object.keys(selectedContentTypes).length > 0) { result['x-scalar-selected-content-type'] = selectedContentTypes; } return result; }; /** * Transforms legacy tags into OpenAPI tags and tag groups. * * Legacy structure: * - Tags can have children (nested tags) * - Top-level parent tags become tag groups * - Child tags and standalone tags become regular tags */ const transformLegacyTags = (collection, dataRecords) => { const tags = []; const tagGroups = []; /** * Identifies which tags are top-level (appear in collection.children). * Top-level parent tags become tag groups, others become regular tags. */ const topLevelTagUids = new Set(collection.children.filter((uid) => dataRecords.tags[uid] !== undefined)); /** * Identifies which tags have children. * Only top-level parent tags become tag groups. */ const parentTagUids = new Set(collection.tags.filter((uid) => { const tag = dataRecords.tags[uid]; return tag?.children && tag.children.length > 0; })); /** * Process each tag to create either a tag group or a regular tag. */ for (const tagUid of collection.tags) { const tag = dataRecords.tags[tagUid]; if (!tag) { continue; } const isTopLevelParent = topLevelTagUids.has(tagUid) && parentTagUids.has(tagUid); if (isTopLevelParent) { /** * Top-level parent tags become tag groups. * Resolve child tag names, filtering out any missing children. */ const childTagNames = tag.children .map((childUid) => dataRecords.tags[childUid]?.name) .filter((name) => name !== undefined); if (childTagNames.length > 0) { tagGroups.push({ name: tag.name, tags: childTagNames, }); } } else { /** * All other tags (child tags and standalone tags) become regular tags. * Preserve optional fields from the legacy tag. */ const tagObject = { name: tag.name }; if (tag.description) { tagObject.description = tag.description; } if (tag.externalDocs) { tagObject.externalDocs = tag.externalDocs; } tags.push(tagObject); } } return { tags, tagGroups }; }; /** Transforms a collection and everything it includes into a WorkspaceDocument + auth */ const transformCollectionToDocument = (documentName, collection, dataRecords) => { // Resolve selectedServerUid → server URL for x-scalar-selected-server const selectedServerUrl = collection.selectedServerUid && dataRecords.servers[collection.selectedServerUid] ? dataRecords.servers[collection.selectedServerUid]?.url : undefined; // Transform tags: separate parent tags (groups) from child tags const { tags, tagGroups } = transformLegacyTags(collection, dataRecords); // Transform requests into paths and extract servers from full URLs const { paths, extractedServers } = transformRequestsToPaths(collection, dataRecords); /** * Merge and deduplicate servers: * 1. Start with existing collection servers * 2. Add extracted servers from paths * 3. Deduplicate by URL (keep first occurrence) */ const existingServers = collection.servers.flatMap((uid) => { const server = dataRecords.servers[uid]; if (!server) { return []; } const { uid: _, ...rest } = server; return [rest]; }); const allServers = [...existingServers, ...extractedServers]; const seenUrls = new Set(); const deduplicatedServers = allServers.filter((server) => { if (seenUrls.has(server.url)) { return false; } seenUrls.add(server.url); return true; }); const document = { openapi: collection.openapi || '3.1.0', info: collection.info || { title: documentName, version: '1.0', }, servers: deduplicatedServers, paths, /** * Preserve all component types from the collection and merge with transformed security schemes. * OpenAPI components object supports: schemas, responses, parameters, examples, * requestBodies, headers, securitySchemes, links, callbacks, pathItems */ components: { // Preserve existing components from the collection (schemas, responses, parameters, etc.) ...(collection.components || {}), // Merge security schemes (transformed from UIDs) with any existing security schemes securitySchemes: { ...(collection.components?.securitySchemes || {}), ...collection.securitySchemes.reduce((acc, uid) => { const securityScheme = dataRecords.securitySchemes[uid]; if (!securityScheme) { return acc; } const { uid: _uid, nameKey: _nameKey, ...publicSecurityScheme } = removeSecretFields(securityScheme); // Clean the flows if (securityScheme.type === 'oauth2') { const selectedScopes = new Set(); return { ...acc, [securityScheme.nameKey]: { ...publicSecurityScheme, flows: objectEntries(securityScheme.flows).reduce((acc, [key, flow]) => { if (!flow) { return acc; } // Store any selected scopes from the config if ('selectedScopes' in flow && Array.isArray(flow.selectedScopes)) { flow.selectedScopes?.forEach((scope) => selectedScopes.add(scope)); } acc[key] = removeSecretFields(flow); return acc; }, {}), 'x-default-scopes': Array.from(selectedScopes), }, }; } return { ...acc, [securityScheme.nameKey]: publicSecurityScheme, }; }, {}), }, }, security: collection.security || [], tags, webhooks: collection.webhooks, externalDocs: collection.externalDocs, // Preserve scalar extensions 'x-scalar-icon': collection['x-scalar-icon'], // Convert legacy record-based environment variables to the new array format 'x-scalar-environments': transformLegacyEnvironments(collection['x-scalar-environments']), }; // Add x-tagGroups if there are any parent tags if (tagGroups.length > 0) { document['x-tagGroups'] = tagGroups; } // x-scalar-active-environment if (collection['x-scalar-active-environment']) { document['x-scalar-active-environment'] = collection['x-scalar-active-environment']; } // selectedServerUid → x-scalar-selected-server (resolved to URL) if (selectedServerUrl) { document['x-scalar-selected-server'] = selectedServerUrl; } // documentUrl → x-scalar-original-source-url if (collection.documentUrl) { document['x-scalar-original-source-url'] = collection.documentUrl; } // Convert circular references to $ref pointers which is safe for JSON serialization const safeDocument = toJsonCompatible(document); return { document: safeDocument, auth: coerceValue(AuthSchema, { secrets: collection.securitySchemes.reduce((acc, uid) => { const securityScheme = dataRecords.securitySchemes[uid]; if (!securityScheme) { return acc; } // Oauth 2 if (securityScheme.type === 'oauth2') { return { ...acc, [securityScheme.nameKey]: { type: securityScheme.type, ...objectEntries(securityScheme.flows).reduce((acc, [key, flow]) => { if (!flow) { return acc; } acc[key] = extractConfigSecrets(flow); return acc; }, {}), }, }; } // The rest return { ...acc, [securityScheme.nameKey]: { type: securityScheme.type, ...extractConfigSecrets(securityScheme), }, }; }, {}), selected: {}, }), }; };