From 68eb346a2366d1035d2d08a3aae138bb5cff075b Mon Sep 17 00:00:00 2001 From: Oliwia Rogala Date: Mon, 15 Apr 2024 11:29:11 +0200 Subject: [PATCH] refactor: consolidate all config related code into config module (#9811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #9806 Co-authored-by: Vladimír Gorej --- src/core/config/defaults.js | 91 +++++++ .../config/factorization/inline-plugin.js | 11 + src/core/config/factorization/store.js | 45 ++++ src/core/config/index.js | 7 + src/core/config/merge.js | 40 +++ src/core/config/sources/query.js | 15 ++ src/core/config/sources/system.js | 16 ++ src/core/config/sources/url.js | 33 +++ src/core/index.js | 235 ++++-------------- 9 files changed, 303 insertions(+), 190 deletions(-) create mode 100644 src/core/config/defaults.js create mode 100644 src/core/config/factorization/inline-plugin.js create mode 100644 src/core/config/factorization/store.js create mode 100644 src/core/config/index.js create mode 100644 src/core/config/merge.js create mode 100644 src/core/config/sources/query.js create mode 100644 src/core/config/sources/system.js create mode 100644 src/core/config/sources/url.js diff --git a/src/core/config/defaults.js b/src/core/config/defaults.js new file mode 100644 index 00000000..aa304ad1 --- /dev/null +++ b/src/core/config/defaults.js @@ -0,0 +1,91 @@ +/** + * @prettier + */ +import ApisPreset from "core/presets/apis" + +const defaultOptions = Object.freeze({ + dom_id: null, + domNode: null, + spec: {}, + url: "", + urls: null, + layout: "BaseLayout", + docExpansion: "list", + maxDisplayedTags: null, + filter: null, + validatorUrl: "https://validator.swagger.io/validator", + oauth2RedirectUrl: `${window.location.protocol}//${window.location.host}${window.location.pathname.substring(0, window.location.pathname.lastIndexOf("/"))}/oauth2-redirect.html`, + persistAuthorization: false, + configs: {}, + custom: {}, + displayOperationId: false, + displayRequestDuration: false, + deepLinking: false, + tryItOutEnabled: false, + requestInterceptor: (a) => a, + responseInterceptor: (a) => a, + showMutatedRequest: true, + defaultModelRendering: "example", + defaultModelExpandDepth: 1, + defaultModelsExpandDepth: 1, + showExtensions: false, + showCommonExtensions: false, + withCredentials: undefined, + requestSnippetsEnabled: false, + requestSnippets: { + generators: { + curl_bash: { + title: "cURL (bash)", + syntax: "bash", + }, + curl_powershell: { + title: "cURL (PowerShell)", + syntax: "powershell", + }, + curl_cmd: { + title: "cURL (CMD)", + syntax: "bash", + }, + }, + defaultExpanded: true, + languages: null, // e.g. only show curl bash = ["curl_bash"] + }, + supportedSubmitMethods: [ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + ], + queryConfigEnabled: false, + + // Initial set of plugins ( TODO rename this, or refactor - we don't need presets _and_ plugins. Its just there for performance. + // Instead, we can compile the first plugin ( it can be a collection of plugins ), then batch the rest. + presets: [ApisPreset], + + // Plugins; ( loaded after presets ) + plugins: [], + + pluginsOptions: { + // Behavior during plugin registration. Can be : + // - legacy (default) : the current behavior for backward compatibility – last plugin takes precedence over the others + // - chain : chain wrapComponents when targeting the same core component + pluginLoadType: "legacy", + }, + + initialState: {}, + + // Inline Plugin + fn: {}, + components: {}, + + syntaxHighlight: { + activated: true, + theme: "agate", + }, +}) + +export default defaultOptions diff --git a/src/core/config/factorization/inline-plugin.js b/src/core/config/factorization/inline-plugin.js new file mode 100644 index 00000000..da4d119a --- /dev/null +++ b/src/core/config/factorization/inline-plugin.js @@ -0,0 +1,11 @@ +/** + * @prettier + */ + +const InlinePluginFactorization = (options) => () => ({ + fn: options.fn, + components: options.components, + state: options.state, +}) + +export default InlinePluginFactorization diff --git a/src/core/config/factorization/store.js b/src/core/config/factorization/store.js new file mode 100644 index 00000000..9d29c6c4 --- /dev/null +++ b/src/core/config/factorization/store.js @@ -0,0 +1,45 @@ +/** + * @prettier + */ +import merge from "../merge" + +const storeFactorization = (options) => { + const state = merge( + { + layout: { + layout: options.layout, + filter: options.filter, + }, + spec: { + spec: "", + url: options.url, + }, + requestSnippets: options.requestSnippets, + }, + options.initialState + ) + + if (options.initialState) { + /** + * If the user sets a key as `undefined`, that signals to us that we + * should delete the key entirely. + * known usage: Swagger-Editor validate plugin tests + */ + for (const [key, value] of Object.entries(options.initialState)) { + if (value === undefined) { + delete state[key] + } + } + } + + return { + system: { + configs: options.configs, + }, + plugins: options.presets, + pluginsOptions: options.pluginsOptions, + state, + } +} + +export default storeFactorization diff --git a/src/core/config/index.js b/src/core/config/index.js new file mode 100644 index 00000000..e0b7d945 --- /dev/null +++ b/src/core/config/index.js @@ -0,0 +1,7 @@ +export { default as inlinePluginOptionsFactorization } from "./factorization/inline-plugin" +export { default as storeOptionsFactorization } from "./factorization/store" +export { default as optionsFromQuery } from "./sources/query" +export { default as optionsFromURL } from "./sources/url" +export { default as optionsFromSystem } from "./sources/system" +export { default as defaultOptions } from "./defaults" +export { default as mergeOptions } from "./merge" diff --git a/src/core/config/merge.js b/src/core/config/merge.js new file mode 100644 index 00000000..bcfac221 --- /dev/null +++ b/src/core/config/merge.js @@ -0,0 +1,40 @@ +/** + * @prettier + * + * We're currently stuck with using deep-extend as it handles the following case: + * + * deepExtend({ a: 1 }, { a: undefined }) => { a: undefined } + * + * NOTE1: lodash.merge & lodash.mergeWith prefers to ignore undefined values + * NOTE2: special handling of `domNode` option is now required as `deep-extend` will corrupt it (lodash.merge handles it correctly) + * NOTE3: oauth2RedirectUrl and withCredentials options can be set to undefined. By expecting null instead of undefined, we can't use lodash.merge. + * + * TODO(vladimir.gorej@gmail.com): remove deep-extend in favor of lodash.merge + */ +import deepExtend from "deep-extend" + +const merge = (target, ...sources) => { + let domNode = Symbol.for("domNode") + const sourcesWithoutDomNode = [] + + for (const source of sources) { + if (Object.hasOwn(source, "domNode")) { + domNode = source.domNode + const sourceWithoutDomNode = { ...source } + delete sourceWithoutDomNode.domNode + sourcesWithoutDomNode.push(sourceWithoutDomNode) + } else { + sourcesWithoutDomNode.push(source) + } + } + + const merged = deepExtend(target, ...sourcesWithoutDomNode) + + if (domNode !== Symbol.for("domNode")) { + merged.domNode = domNode + } + + return merged +} + +export default merge diff --git a/src/core/config/sources/query.js b/src/core/config/sources/query.js new file mode 100644 index 00000000..ee1b36f2 --- /dev/null +++ b/src/core/config/sources/query.js @@ -0,0 +1,15 @@ +/** + * @prettier + */ +import { parseSearch } from "core/utils" + +/** + * Receives options from the query string of the URL where SwaggerUI + * is being served. + */ + +const optionsFromQuery = () => (options) => { + return options.queryConfigEnabled ? parseSearch() : {} +} + +export default optionsFromQuery diff --git a/src/core/config/sources/system.js b/src/core/config/sources/system.js new file mode 100644 index 00000000..220a658b --- /dev/null +++ b/src/core/config/sources/system.js @@ -0,0 +1,16 @@ +/** + * @prettier + * + * Receives options from a System. + * These options are baked-in to the System during the compile time. + */ + +const optionsFromSystem = + ({ system }) => + () => { + if (typeof system.specSelectors?.getLocalConfig !== "function") return {} + + return system.specSelectors.getLocalConfig() + } + +export default optionsFromSystem diff --git a/src/core/config/sources/url.js b/src/core/config/sources/url.js new file mode 100644 index 00000000..962b45c7 --- /dev/null +++ b/src/core/config/sources/url.js @@ -0,0 +1,33 @@ +/** + * @prettier + * Receives options from a remote URL. + */ + +const optionsFromURL = + ({ url, system }) => + async (options) => { + if (!url) return {} + if (typeof system.specActions?.getConfigByUrl !== "function") return {} + let resolve + const deferred = new Promise((res) => { + resolve = res + }) + const callback = (fetchedOptions) => { + // receives null on remote URL fetch failure + resolve(fetchedOptions) + } + + system.specActions.getConfigByUrl( + { + url, + loadRemoteConfig: true, + requestInterceptor: options.requestInterceptor, + responseInterceptor: options.responseInterceptor, + }, + callback + ) + + return deferred + } + +export default optionsFromURL diff --git a/src/core/index.js b/src/core/index.js index c08685de..5ff03fd7 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -1,5 +1,3 @@ -import deepExtend from "deep-extend" - import System from "./system" // presets import BasePreset from "./presets/base" @@ -30,14 +28,21 @@ import DownloadUrlPlugin from "./plugins/download-url" import SyntaxHighlightingPlugin from "core/plugins/syntax-highlighting" import SafeRenderPlugin from "./plugins/safe-render" -import { parseSearch } from "./utils" import win from "./window" +import { + defaultOptions, + optionsFromQuery, + optionsFromURL, + optionsFromSystem, + mergeOptions, + inlinePluginOptionsFactorization, + storeOptionsFactorization +} from "./config" // eslint-disable-next-line no-undef const { GIT_DIRTY, GIT_COMMIT, PACKAGE_VERSION, BUILD_TIME } = buildInfo -export default function SwaggerUI(opts) { - +export default function SwaggerUI(userOptions) { win.versions = win.versions || {} win.versions.swaggerUi = { version: PACKAGE_VERSION, @@ -46,200 +51,50 @@ export default function SwaggerUI(opts) { buildTimestamp: BUILD_TIME, } - const defaults = { - // Some general settings, that we floated to the top - dom_id: null, // eslint-disable-line camelcase - domNode: null, - spec: {}, - url: "", - urls: null, - layout: "BaseLayout", - docExpansion: "list", - maxDisplayedTags: null, - filter: null, - validatorUrl: "https://validator.swagger.io/validator", - oauth2RedirectUrl: `${window.location.protocol}//${window.location.host}${window.location.pathname.substring(0, window.location.pathname.lastIndexOf("/"))}/oauth2-redirect.html`, - persistAuthorization: false, - configs: {}, - custom: {}, - displayOperationId: false, - displayRequestDuration: false, - deepLinking: false, - tryItOutEnabled: false, - requestInterceptor: (a => a), - responseInterceptor: (a => a), - showMutatedRequest: true, - defaultModelRendering: "example", - defaultModelExpandDepth: 1, - defaultModelsExpandDepth: 1, - showExtensions: false, - showCommonExtensions: false, - withCredentials: undefined, - requestSnippetsEnabled: false, - requestSnippets: { - generators: { - "curl_bash": { - title: "cURL (bash)", - syntax: "bash" - }, - "curl_powershell": { - title: "cURL (PowerShell)", - syntax: "powershell" - }, - "curl_cmd": { - title: "cURL (CMD)", - syntax: "bash" - }, - }, - defaultExpanded: true, - languages: null, // e.g. only show curl bash = ["curl_bash"] - }, - supportedSubmitMethods: [ - "get", - "put", - "post", - "delete", - "options", - "head", - "patch", - "trace" - ], - queryConfigEnabled: false, + const queryOptions = optionsFromQuery()(userOptions) + let mergedOptions = mergeOptions({}, defaultOptions, userOptions, queryOptions) + const storeOptions = storeOptionsFactorization(mergedOptions) + const InlinePlugin = inlinePluginOptionsFactorization(mergedOptions) - // Initial set of plugins ( TODO rename this, or refactor - we don't need presets _and_ plugins. Its just there for performance. - // Instead, we can compile the first plugin ( it can be a collection of plugins ), then batch the rest. - presets: [ - ApisPreset - ], - // Plugins; ( loaded after presets ) - plugins: [ - ], + const store = new System(storeOptions) + store.register([mergedOptions.plugins, InlinePlugin]) + const system = store.getSystem() - pluginsOptions: { - // Behavior during plugin registration. Can be : - // - legacy (default) : the current behavior for backward compatibility – last plugin takes precedence over the others - // - chain : chain wrapComponents when targeting the same core component - pluginLoadType: "legacy" - }, + const configURL = queryOptions.config ?? mergedOptions.configUrl + const systemOptions = optionsFromSystem({ system })(mergedOptions) - // Initial state - initialState: { }, + optionsFromURL({ url: configURL, system })(mergedOptions) + .then((urlOptions) => { + const urlOptionsFailedToFetch = urlOptions === null - // Inline Plugin - fn: { }, - components: { }, + mergedOptions = mergeOptions({}, systemOptions, mergedOptions, urlOptions, queryOptions) + store.setConfigs(mergedOptions) + system.configsActions.loaded() - syntaxHighlight: { - activated: true, - theme: "agate" - } - } - - let queryConfig = opts.queryConfigEnabled ? parseSearch() : {} - - const domNode = opts.domNode - delete opts.domNode - - const constructorConfig = deepExtend({}, defaults, opts, queryConfig) - - const storeConfigs = { - system: { - configs: constructorConfig.configs - }, - plugins: constructorConfig.presets, - pluginsOptions: constructorConfig.pluginsOptions, - state: deepExtend({ - layout: { - layout: constructorConfig.layout, - filter: constructorConfig.filter - }, - spec: { - spec: "", - // support Relative References - url: constructorConfig.url, - }, - requestSnippets: constructorConfig.requestSnippets - }, constructorConfig.initialState) - } - - if(constructorConfig.initialState) { - // if the user sets a key as `undefined`, that signals to us that we - // should delete the key entirely. - // known usage: Swagger-Editor validate plugin tests - for (var key in constructorConfig.initialState) { - if( - Object.prototype.hasOwnProperty.call(constructorConfig.initialState, key) - && constructorConfig.initialState[key] === undefined - ) { - delete storeConfigs.state[key] + if (!urlOptionsFailedToFetch) { + if (!queryOptions.url && typeof mergedOptions.spec === "object" && Object.keys(mergedOptions.spec).length > 0) { + system.specActions.updateUrl("") + system.specActions.updateLoadingStatus("success") + system.specActions.updateSpec(JSON.stringify(mergedOptions.spec)) + } else if (typeof system.specActions.download === "function" && mergedOptions.url && !mergedOptions.urls) { + system.specActions.updateUrl(mergedOptions.url) + system.specActions.download(mergedOptions.url) + } } - } - } - let inlinePlugin = ()=> { - return { - fn: constructorConfig.fn, - components: constructorConfig.components, - state: constructorConfig.state, - } - } - - var store = new System(storeConfigs) - store.register([constructorConfig.plugins, inlinePlugin]) - - var system = store.getSystem() - - const downloadSpec = (fetchedConfig) => { - let localConfig = system.specSelectors.getLocalConfig ? system.specSelectors.getLocalConfig() : {} - let mergedConfig = deepExtend({}, localConfig, constructorConfig, fetchedConfig || {}, queryConfig) - - // deep extend mangles domNode, we need to set it manually - if(domNode) { - mergedConfig.domNode = domNode - } - - store.setConfigs(mergedConfig) - system.configsActions.loaded() - - if (fetchedConfig !== null) { - if (!queryConfig.url && typeof mergedConfig.spec === "object" && Object.keys(mergedConfig.spec).length) { - system.specActions.updateUrl("") - system.specActions.updateLoadingStatus("success") - system.specActions.updateSpec(JSON.stringify(mergedConfig.spec)) - } else if (system.specActions.download && mergedConfig.url && !mergedConfig.urls) { - system.specActions.updateUrl(mergedConfig.url) - system.specActions.download(mergedConfig.url) + if (mergedOptions.domNode) { + system.render(mergedOptions.domNode, "App") + } else if(mergedOptions.dom_id) { + let domNode = document.querySelector(mergedOptions.dom_id) + system.render(domNode, "App") + } else if(mergedOptions.dom_id === null || mergedOptions.domNode === null) { + // do nothing + // this is useful for testing that does not need to do any rendering + } else { + console.error("Skipped rendering: no `dom_id` or `domNode` was specified") } - } - - if(mergedConfig.domNode) { - system.render(mergedConfig.domNode, "App") - } else if(mergedConfig.dom_id) { - let domNode = document.querySelector(mergedConfig.dom_id) - system.render(domNode, "App") - } else if(mergedConfig.dom_id === null || mergedConfig.domNode === null) { - // do nothing - // this is useful for testing that does not need to do any rendering - } else { - console.error("Skipped rendering: no `dom_id` or `domNode` was specified") - } - - return system - } - - const configUrl = queryConfig.config || constructorConfig.configUrl - - if (configUrl && system.specActions && system.specActions.getConfigByUrl) { - system.specActions.getConfigByUrl({ - url: configUrl, - loadRemoteConfig: true, - requestInterceptor: constructorConfig.requestInterceptor, - responseInterceptor: constructorConfig.responseInterceptor, - }, downloadSpec) - } else { - return downloadSpec() - } + }) return system }