UNPKG

@scalar/oas-utils

Version:

Open API spec and Yaml handling utilities

435 lines (432 loc) 19.8 kB
import { keysOf } from '@scalar/object-utils/arrays'; import { load, upgrade, dereference } from '@scalar/openapi-parser'; import { collectionSchema } from '../entities/spec/collection.js'; import { createExampleFromRequest } from '../entities/spec/request-examples.js'; import { requestSchema } from '../entities/spec/requests.js'; import { serverSchema } from '../entities/spec/server.js'; import { tagSchema } from '../entities/spec/spec-objects.js'; import { isHttpMethod } from '../helpers/http-methods.js'; import { isDefined } from '../helpers/is-defined.js'; import { combineUrlAndPath } from '../helpers/merge-urls.js'; import { schemaModel } from '../helpers/schema-model.js'; import { securitySchemeSchema } from '@scalar/types/entities'; /** Takes a string or object and parses it into an openapi spec compliant schema */ const parseSchema = async (spec, { shouldLoad = true } = {}) => { if (spec === null || (typeof spec === 'string' && spec.trim() === '')) { console.warn('[@scalar/oas-utils] Empty OpenAPI document provided.'); return { schema: {}, errors: [], }; } let filesystem = spec; let loadErrors = []; if (shouldLoad) { // TODO: Plugins for URLs and files with the proxy is missing here. // @see packages/api-reference/src/helpers/parse.ts const resp = await load(spec).catch((e) => ({ errors: [ { code: e.code, message: e.message, }, ], filesystem: [], })); filesystem = resp.filesystem; loadErrors = resp.errors ?? []; } const { specification } = upgrade(filesystem); const { schema, errors: derefErrors = [] } = await dereference(specification); if (!schema) { console.warn('[@scalar/oas-utils] OpenAPI Parser Warning: Schema is undefined'); } return { /** * Temporary fix for the parser returning an empty array * TODO: remove this once the parser is fixed */ schema: (Array.isArray(schema) ? {} : schema), errors: [...loadErrors, ...derefErrors], }; }; /** Converts selected security requirements to uids */ const getSelectedSecuritySchemeUids = (securityRequirements, preferredSecurityNames = [], securitySchemeMap) => { // Set the first security requirement if no preferred security schemes are set const names = securityRequirements[0] && !preferredSecurityNames.length ? [securityRequirements[0]] : preferredSecurityNames; // Map names to uids const uids = names .map((name) => Array.isArray(name) ? name.map((k) => securitySchemeMap[k]).filter(isDefined) : securitySchemeMap[name]) .filter(isDefined); return uids; }; /** Create a "uid" from a slug */ const getSlugUid = (slug) => `slug-uid-${slug}`; /** * Imports an OpenAPI document and converts it to workspace entities (Collection, Request, Server, etc.) * * The imported entities maintain a close mapping to the original OpenAPI specification to enable: * - Bi-directional translation between spec and workspace entities * - Preservation of specification details and structure * - Accurate representation of relationships between components * * Relationships between entities are maintained through unique identifiers (UIDs) which allow: * - Flexible organization at different levels (workspace, collection, request) * - Proper linking between related components * - Easy lookup and reference of dependent entities */ async function importSpecToWorkspace(spec, { authentication, baseServerURL, documentUrl, servers: configuredServers, useCollectionSecurity = false, slug, shouldLoad, watchMode = false, } = {}) { const { schema, errors } = await parseSchema(spec, { shouldLoad }); const importWarnings = [...errors.map((e) => e.message)]; if (!schema) { return { importWarnings, error: true, collection: undefined }; } // --------------------------------------------------------------------------- // Some entities will be broken out as individual lists for modification in the workspace const start = performance.now(); const requests = []; // Add the base server url to collection servers const collectionServers = getServersFromOpenApiDocument(configuredServers || schema.servers, { baseServerURL, }); // Store operation servers const operationServers = []; // Fallback to the current window.location.origin if no servers are provided if (!collectionServers.length) { const fallbackUrl = getFallbackUrl(); if (fallbackUrl) { collectionServers.push(serverSchema.parse({ url: fallbackUrl })); } } /** * List of all tag strings. For non compliant specs we may need to * add top level tag objects for missing tag objects */ const tagNames = new Set(); // --------------------------------------------------------------------------- // SECURITY HANDLING const security = schema.components?.securitySchemes ?? schema?.securityDefinitions ?? {}; // Toss out a deprecated warning for the old authentication state if (authentication?.oAuth2 || authentication?.apiKey || authentication?.http) { console.warn(`DEPRECATION WARNING: It looks like you're using legacy authentication config. Please migrate to use the updated config. See https://github.com/scalar/scalar/blob/main/documentation/configuration.md#authentication-partial This will be removed in a future version.`); } const securitySchemes = Object.entries(security) .map?.(([nameKey, _scheme]) => { // Apply any transforms we need before parsing const payload = { ..._scheme, // Add the new auth config overrides, we keep the old code below for backwards compatibility ...(authentication?.securitySchemes?.[nameKey] ?? {}), nameKey, }; // For oauth2 we need to add the type to the flows + prefill from authentication if (payload.type === 'oauth2' && payload.flows) { const flowKeys = Object.keys(payload.flows); flowKeys.forEach((key) => { if (!payload.flows?.[key]) { return; } // This part handles setting of flows via the new auth config, the rest can be removed in a future version payload.flows[key] = { ...(_scheme.flows?.[key] ?? {}), ...(authentication?.securitySchemes?.[nameKey]?.flows?.[key] ?? {}), }; const flow = payload.flows[key]; // Set the type flow.type = key; // Prefill values from authorization config - old deprecated config if (authentication?.oAuth2) { if (authentication.oAuth2.accessToken) { flow.token = authentication.oAuth2.accessToken; } if (authentication.oAuth2.clientId) { flow['x-scalar-client-id'] = authentication.oAuth2.clientId; } if (authentication.oAuth2.scopes) { flow.selectedScopes = authentication.oAuth2.scopes; } if (flow.type === 'password') { flow.username = authentication.oAuth2.username; flow.password = authentication.oAuth2.password; } } // Convert scopes to an object if (Array.isArray(flow.scopes)) { flow.scopes = flow.scopes.reduce((prev, s) => ({ ...prev, [s]: '' }), {}); } // Handle x-defaultClientId if (flow['x-defaultClientId']) { flow['x-scalar-client-id'] = flow['x-defaultClientId']; } }); } // Otherwise we just prefill - old deprecated config else if (authentication) { // ApiKey if (payload.type === 'apiKey' && authentication.apiKey?.token) { payload.value = authentication.apiKey.token; } // HTTP else if (payload.type === 'http') { if (payload.scheme === 'basic' && authentication.http?.basic) { payload.username = authentication.http.basic.username ?? ''; payload.password = authentication.http.basic.password ?? ''; } // Bearer else if (payload.scheme === 'bearer' && authentication.http?.bearer?.token) { payload.token = authentication.http.bearer.token ?? ''; } } } const scheme = schemaModel(payload, securitySchemeSchema, false); if (!scheme) { importWarnings.push(`Security scheme ${nameKey} is invalid.`); } return scheme; }) .filter((v) => !!v); // Map of security scheme names to UIDs const securitySchemeMap = {}; securitySchemes.forEach((s) => { securitySchemeMap[s.nameKey] = s.uid; }); // --------------------------------------------------------------------------- // REQUEST HANDLING keysOf(schema.paths ?? {}).forEach((pathString) => { const path = schema?.paths?.[pathString]; if (!path) { return; } // Path level servers must be saved const pathServers = serverSchema.array().parse(path.servers ?? []); for (const server of pathServers) { collectionServers.push(server); } // Creates a sorted array of methods based on the path object. const methods = Object.keys(path).filter(isHttpMethod); methods.forEach((method) => { const operation = path[method]; const operationLevelServers = serverSchema.array().parse(operation.servers ?? []); for (const server of operationLevelServers) { operationServers.push(server); } // We will save a list of all tags to ensure they exists at the top level // TODO: make sure we add any loose requests with no tags to the collection children operation.tags?.forEach((t) => tagNames.add(t)); // Remove security here and add it correctly below const { security: operationSecurity, ...operationWithoutSecurity } = operation; const securityRequirements = (operationSecurity ?? schema.security ?? []) .map((s) => { const keys = Object.keys(s); return keys.length > 1 ? keys : keys[0]; }) .filter(isDefined); // Filter the preferred security schemes to only include the ones that are in the security requirements const preferredSecurityNames = [authentication?.preferredSecurityScheme ?? []].flat().filter((name) => { // Match up complex security requirements, array to array if (Array.isArray(name)) { // We match every element in the array return securityRequirements.some((r) => Array.isArray(r) && r.length === name.length && r.every((v, i) => v === name[i])); } return securityRequirements.includes(name); }); // Set the initially selected security scheme const selectedSecuritySchemeUids = securityRequirements.length && !useCollectionSecurity ? getSelectedSecuritySchemeUids(securityRequirements, preferredSecurityNames, securitySchemeMap) : []; const requestPayload = { ...operationWithoutSecurity, method, path: pathString, security: operationSecurity, selectedServerUid: operationLevelServers?.[0]?.uid, selectedSecuritySchemeUids, // Merge path and operation level parameters parameters: [...(path?.parameters ?? []), ...(operation.parameters ?? [])], servers: [...pathServers, ...operationLevelServers].map((s) => s.uid), }; // Remove any examples from the request payload as they conflict with our examples property and are not valid if (requestPayload.examples) { console.warn('[@scalar/api-client] operation.examples is not a valid openapi property'); delete requestPayload.examples; } // Add list of UIDs to associate security schemes // As per the spec if there is operation level security we ignore the top level requirements if (operationSecurity?.length) { requestPayload.security = operationSecurity.map((s) => { const keys = Object.keys(s); // Handle the case of {} for optional if (keys.length) { const [key] = Object.keys(s); if (key) { return { [key]: s[key], }; } } return s; }); } // Save parse the request const request = schemaModel(requestPayload, requestSchema, false); if (!request) { importWarnings.push(`${method} Request at ${path} is invalid.`); } else { requests.push(request); } }); }); // --------------------------------------------------------------------------- // TAG HANDLING // TODO: We may need to handle de-duping tags const tags = schemaModel(schema?.tags ?? [], tagSchema.array(), false) ?? []; // Delete any tag names that already have a definition tags.forEach((t) => tagNames.delete(t.name)); // Add an entry for any tags that are used but do not have a definition tagNames.forEach((name) => name && tags.push(tagSchema.parse({ name }))); // Tag name to UID map const tagMap = {}; tags.forEach((t) => { tagMap[t.name] = t; }); // Add all tags by default. We will remove nested ones const collectionChildren = new Set(tags.map((t) => t.uid)); // Nested folders go before any requests tags.forEach((t) => { t['x-scalar-children']?.forEach((c) => { // Add the uid to the appropriate parent.children const nestedUid = tagMap[c.tagName]?.uid; if (nestedUid) { t.children.push(nestedUid); // Remove the nested uid from the root folder collectionChildren.delete(nestedUid); } }); }); // Add the request UIDs to the tag children (or collection root) requests.forEach((r) => { if (r.tags?.length) { r.tags.forEach((t) => { tagMap[t]?.children.push(r.uid); }); } else { collectionChildren.add(r.uid); } }); // --------------------------------------------------------------------------- const examples = []; // Ensure each request has at least 1 example requests.forEach((request) => { // TODO: Need to handle parsing examples // if (request['x-scalar-examples']) return // Create the initial example const example = createExampleFromRequest(request, 'Default Example'); examples.push(example); request.examples.push(example.uid); }); // --------------------------------------------------------------------------- // Generate Collection // Grab the security requirements for this operation const securityRequirements = (schema.security ?? []) .map((s) => { const keys = Object.keys(s); return keys.length > 1 ? keys : keys[0]; }) .filter(isDefined); // Here we do not filter these as we let the preferredSecurityScheme override the requirements const preferredSecurityNames = [authentication?.preferredSecurityScheme ?? []].flat(); // Set the initially selected security scheme const selectedSecuritySchemeUids = (securityRequirements.length || preferredSecurityNames?.length) && useCollectionSecurity ? getSelectedSecuritySchemeUids(securityRequirements, preferredSecurityNames, securitySchemeMap) : []; // Set the uid as a prefixed slug if we have one const slugObj = slug?.length ? { uid: getSlugUid(slug) } : {}; const collection = collectionSchema.parse({ ...slugObj, ...schema, watchMode, documentUrl, useCollectionSecurity, requests: requests.map((r) => r.uid), servers: collectionServers.map((s) => s.uid), tags: tags.map((t) => t.uid), children: [...collectionChildren], security: schema.security ?? [{}], selectedServerUid: collectionServers?.[0]?.uid, selectedSecuritySchemeUids, components: { ...schema.components, }, securitySchemes: securitySchemes.map((s) => s.uid), }); const end = performance.now(); console.log(`workspace: ${Math.round(end - start)} ms`); /** * Servers and requests will be saved in top level maps and indexed via UID to * maintain specification relationships */ return { error: false, servers: [...collectionServers, ...operationServers], schema, requests, examples, collection, tags, securitySchemes, }; } /** * Retrieves a list of servers from an OpenAPI document and converts them to a list of Server entities. */ function getServersFromOpenApiDocument(servers, { baseServerURL } = {}) { if (!servers || !Array.isArray(servers)) { return []; } return servers .map((server) => { try { // Validate the server against the schema const parsedSchema = serverSchema.parse(server); // Prepend with the base server URL (if the given URL is relative) if (parsedSchema?.url?.startsWith('/')) { // Use the base server URL (if provided) if (baseServerURL) { parsedSchema.url = combineUrlAndPath(baseServerURL, parsedSchema.url); return parsedSchema; } // Fallback to the current window origin const fallbackUrl = getFallbackUrl(); if (fallbackUrl) { parsedSchema.url = combineUrlAndPath(fallbackUrl, parsedSchema.url.replace(/^\//, '')); return parsedSchema; } } // Must be good, return it return parsedSchema; } catch (error) { console.warn('Oops, that’s an invalid server configuration.'); console.warn('Server:', server); console.warn('Error:', error); // Return undefined to remove the server return undefined; } }) .filter(isDefined); } /** * Fallback to the current window.location.origin, if available */ function getFallbackUrl() { if (typeof window === 'undefined') { return undefined; } if (typeof window?.location?.origin !== 'string') { return undefined; } return window.location.origin; } export { getSelectedSecuritySchemeUids, getServersFromOpenApiDocument, getSlugUid, importSpecToWorkspace, parseSchema };