fix(config): cast configuration values into proper types (#9829)

Refs #9808
This commit is contained in:
Oliwia Rogala
2024-04-18 11:04:05 +02:00
committed by GitHub
parent c2b63ab4d7
commit 7378821669
28 changed files with 359 additions and 83 deletions

View File

@@ -142,7 +142,7 @@ SwaggerUI.defaultProps = {
deepLinking: false, deepLinking: false,
showExtensions: false, showExtensions: false,
showCommonExtensions: false, showCommonExtensions: false,
filter: null, filter: false,
requestSnippetsEnabled: false, requestSnippetsEnabled: false,
requestSnippets: { requestSnippets: {
generators: { generators: {
@@ -164,7 +164,7 @@ SwaggerUI.defaultProps = {
}, },
tryItOutEnabled: false, tryItOutEnabled: false,
displayRequestDuration: false, displayRequestDuration: false,
withCredentials: undefined, withCredentials: false,
persistAuthorization: false, persistAuthorization: false,
oauth2RedirectUrl: undefined, oauth2RedirectUrl: undefined,
} }

View File

@@ -73,10 +73,10 @@ export default class LiveResponse extends React.Component {
return ( return (
<div> <div>
{ curlRequest && (requestSnippetsEnabled === true || requestSnippetsEnabled === "true" { curlRequest && requestSnippetsEnabled
? <RequestSnippets request={ curlRequest }/> ? <RequestSnippets request={ curlRequest }/>
: <Curl request={ curlRequest } /> : <Curl request={ curlRequest } />
)} }
{ url && <div> { url && <div>
<div className="request-url"> <div className="request-url">
<h4>Request URL</h4> <h4>Request URL</h4>

View File

@@ -47,8 +47,6 @@ export default class OperationTag extends React.Component {
deepLinking, deepLinking,
} = getConfigs() } = getConfigs()
const isDeepLinkingEnabled = deepLinking && deepLinking !== "false"
const Collapse = getComponent("Collapse") const Collapse = getComponent("Collapse")
const Markdown = getComponent("Markdown", true) const Markdown = getComponent("Markdown", true)
const DeepLink = getComponent("DeepLink") const DeepLink = getComponent("DeepLink")
@@ -80,7 +78,7 @@ export default class OperationTag extends React.Component {
data-is-open={showTag} data-is-open={showTag}
> >
<DeepLink <DeepLink
enabled={isDeepLinkingEnabled} enabled={deepLinking}
isShown={showTag} isShown={showTag}
path={createDeepLinkPath(tag)} path={createDeepLinkPath(tag)}
text={tag} /> text={tag} />

View File

@@ -11,8 +11,8 @@ const defaultOptions = Object.freeze({
urls: null, urls: null,
layout: "BaseLayout", layout: "BaseLayout",
docExpansion: "list", docExpansion: "list",
maxDisplayedTags: null, maxDisplayedTags: -1,
filter: null, filter: false,
validatorUrl: "https://validator.swagger.io/validator", 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`, oauth2RedirectUrl: `${window.location.protocol}//${window.location.host}${window.location.pathname.substring(0, window.location.pathname.lastIndexOf("/"))}/oauth2-redirect.html`,
persistAuthorization: false, persistAuthorization: false,
@@ -30,7 +30,7 @@ const defaultOptions = Object.freeze({
defaultModelsExpandDepth: 1, defaultModelsExpandDepth: 1,
showExtensions: false, showExtensions: false,
showCommonExtensions: false, showCommonExtensions: false,
withCredentials: undefined, withCredentials: false,
requestSnippetsEnabled: false, requestSnippetsEnabled: false,
requestSnippets: { requestSnippets: {
generators: { generators: {

View File

@@ -1,10 +1,10 @@
/** /**
* @prettier * @prettier
*/ */
import merge from "../merge" import deepExtend from "deep-extend"
const storeFactorization = (options) => { const storeFactorization = (options) => {
const state = merge( const state = deepExtend(
{ {
layout: { layout: {
layout: options.layout, layout: options.layout,

View File

@@ -7,12 +7,13 @@
* *
* NOTE1: lodash.merge & lodash.mergeWith prefers to ignore undefined values * 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) * 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. * NOTE3: oauth2RedirectUrl option can be set to undefined. By expecting null instead of undefined, we can't use lodash.merge.
* NOTE4: urls.primaryName needs to handled in special way, because it's an arbitrary property on Array instance * NOTE4: urls.primaryName needs to handled in special way, because it's an arbitrary property on Array instance
* *
* TODO(vladimir.gorej@gmail.com): remove deep-extend in favor of lodash.merge * TODO(vladimir.gorej@gmail.com): remove deep-extend in favor of lodash.merge
*/ */
import deepExtend from "deep-extend" import deepExtend from "deep-extend"
import typeCast from "./type-cast"
const merge = (target, ...sources) => { const merge = (target, ...sources) => {
let domNode = Symbol.for("domNode") let domNode = Symbol.for("domNode")
@@ -51,7 +52,7 @@ const merge = (target, ...sources) => {
merged.urls.primaryName = primaryName merged.urls.primaryName = primaryName
} }
return merged return typeCast(merged)
} }
export default merge export default merge

View File

@@ -0,0 +1,24 @@
/**
* @prettier
*/
import has from "lodash/has"
import get from "lodash/get"
import set from "lodash/fp/set"
import typeCasters from "./mappings"
const typeCast = (options) => {
return Object.entries(typeCasters).reduce(
(acc, [optionPath, { typeCaster, defaultValue }]) => {
if (has(acc, optionPath)) {
const uncasted = get(acc, optionPath)
const casted = typeCaster(uncasted, defaultValue)
acc = set(optionPath, casted, acc)
}
return acc
},
{ ...options }
)
}
export default typeCast

View File

@@ -0,0 +1,115 @@
/**
* @prettier
*/
import arrayTypeCaster from "./type-casters/array"
import booleanTypeCaster from "./type-casters/boolean"
import domNodeTypeCaster from "./type-casters/dom-node"
import filterTypeCaster from "./type-casters/filter"
import nullableArrayTypeCaster from "./type-casters/nullable-array"
import nullableStringTypeCaster from "./type-casters/nullable-string"
import numberTypeCaster from "./type-casters/number"
import objectTypeCaster from "./type-casters/object"
import stringTypeCaster from "./type-casters/string"
import syntaxHighlightTypeCaster from "./type-casters/syntax-highlight"
import undefinedStringTypeCaster from "./type-casters/undefined-string"
import defaultOptions from "../defaults"
const typeCasters = {
configUrl: { typeCaster: stringTypeCaster },
deepLinking: {
typeCaster: booleanTypeCaster,
defaultValue: defaultOptions.deepLinking,
},
defaultModelExpandDepth: {
typeCaster: numberTypeCaster,
defaultValue: defaultOptions.defaultModelExpandDepth,
},
defaultModelRendering: { typeCaster: stringTypeCaster },
defaultModelsExpandDepth: {
typeCaster: numberTypeCaster,
defaultValue: defaultOptions.defaultModelsExpandDepth,
},
displayOperationId: {
typeCaster: booleanTypeCaster,
defaultValue: defaultOptions.displayOperationId,
},
displayRequestDuration: {
typeCaster: booleanTypeCaster,
defaultValue: defaultOptions.displayRequestDuration,
},
docExpansion: { typeCaster: stringTypeCaster },
dom_id: { typeCaster: nullableStringTypeCaster },
domNode: { typeCaster: domNodeTypeCaster },
filter: { typeCaster: filterTypeCaster },
layout: { typeCaster: stringTypeCaster },
maxDisplayedTags: {
typeCaster: numberTypeCaster,
defaultValue: defaultOptions.maxDisplayedTags,
},
oauth2RedirectUrl: { typeCaster: undefinedStringTypeCaster },
persistAuthorization: {
typeCaster: booleanTypeCaster,
defaultValue: defaultOptions.persistAuthorization,
},
plugins: {
typeCaster: arrayTypeCaster,
defaultValue: defaultOptions.plugins,
},
pluginsOptions: {
typeCaster: objectTypeCaster,
pluginsOptions: defaultOptions.pluginsOptions,
},
"pluginsOptions.pluginsLoadType": { typeCaster: stringTypeCaster },
presets: {
typeCaster: arrayTypeCaster,
defaultValue: defaultOptions.presets,
},
requestSnippets: {
typeCaster: objectTypeCaster,
defaultValue: defaultOptions.requestSnippets,
},
requestSnippetsEnabled: {
typeCaster: booleanTypeCaster,
defaultValue: defaultOptions.requestSnippetsEnabled,
},
showCommonExtensions: {
typeCaster: booleanTypeCaster,
defaultValue: defaultOptions.showCommonExtensions,
},
showExtensions: {
typeCaster: booleanTypeCaster,
defaultValue: defaultOptions.showExtensions,
},
showMutatedRequest: {
typeCaster: booleanTypeCaster,
defaultValue: defaultOptions.showMutatedRequest,
},
spec: { typeCaster: objectTypeCaster, defaultValue: defaultOptions.spec },
supportedSubmitMethods: {
typeCaster: arrayTypeCaster,
defaultValue: defaultOptions.supportedSubmitMethods,
},
syntaxHighlight: {
typeCaster: syntaxHighlightTypeCaster,
defaultValue: defaultOptions.syntaxHighlight,
},
"syntaxHighlight.activated": {
typeCaster: booleanTypeCaster,
defaultValue: defaultOptions.syntaxHighlight.activated,
},
"syntaxHighlight.theme": { typeCaster: stringTypeCaster },
tryItOutEnabled: {
typeCaster: booleanTypeCaster,
defaultValue: defaultOptions.tryItOutEnabled,
},
url: { typeCaster: stringTypeCaster },
urls: { typeCaster: nullableArrayTypeCaster },
"urls.primaryName": { typeCaster: stringTypeCaster },
validatorUrl: { typeCaster: nullableStringTypeCaster },
withCredentials: {
typeCaster: booleanTypeCaster,
defaultValue: defaultOptions.withCredentials,
},
}
export default typeCasters

View File

@@ -0,0 +1,7 @@
/**
* @prettier
*/
const arrayTypeCaster = (value, defaultValue = []) =>
Array.isArray(value) ? value : defaultValue
export default arrayTypeCaster

View File

@@ -0,0 +1,11 @@
/**
* @prettier
*/
const booleanTypeCaster = (value, defaultValue = false) =>
value === true || value === "true" || value === 1 || value === "1"
? true
: value === false || value === "false" || value === 0 || value === "0"
? false
: defaultValue
export default booleanTypeCaster

View File

@@ -0,0 +1,7 @@
/**
* @prettier
*/
const domNodeTypeCaster = (value) =>
value === null || value === "null" ? null : value
export default domNodeTypeCaster

View File

@@ -0,0 +1,11 @@
/**
* @prettier
*/
import booleanTypeCaster from "./boolean"
const filterTypeCaster = (value) => {
const defaultValue = String(value)
return booleanTypeCaster(value, defaultValue)
}
export default filterTypeCaster

View File

@@ -0,0 +1,6 @@
/**
* @prettier
*/
const nullableArrayTypeCaster = (value) => (Array.isArray(value) ? value : null)
export default nullableArrayTypeCaster

View File

@@ -0,0 +1,7 @@
/**
* @prettier
*/
const nullableStringTypeCaster = (value) =>
value === null || value === "null" ? null : String(value)
export default nullableStringTypeCaster

View File

@@ -0,0 +1,9 @@
/**
* @prettier
*/
const numberTypeCaster = (value, defaultValue = -1) => {
const parsedValue = parseInt(value, 10)
return Number.isNaN(parsedValue) ? defaultValue : parsedValue
}
export default numberTypeCaster

View File

@@ -0,0 +1,9 @@
/**
* @prettier
*/
import isPlainObject from "lodash/isPlainObject"
const objectTypeCaster = (value, defaultValue = {}) =>
isPlainObject(value) ? value : defaultValue
export default objectTypeCaster

View File

@@ -0,0 +1,6 @@
/**
* @prettier
*/
const stringTypeCaster = (value) => String(value)
export default stringTypeCaster

View File

@@ -0,0 +1,14 @@
/**
* @prettier
*/
import isPlainObject from "lodash/isPlainObject"
const syntaxHighlightTypeCaster = (value, defaultValue) => {
return isPlainObject(value)
? value
: value === false || value === "false" || value === 0 || value === "0"
? { activated: false }
: defaultValue
}
export default syntaxHighlightTypeCaster

View File

@@ -0,0 +1,7 @@
/**
* @prettier
*/
const undefinedStringTypeCaster = (value) =>
value === undefined || value === "undefined" ? undefined : String(value)
export default undefinedStringTypeCaster

View File

@@ -11,7 +11,7 @@ export default class OperationContainer extends PureComponent {
const { tryItOutEnabled } = props.getConfigs() const { tryItOutEnabled } = props.getConfigs()
this.state = { this.state = {
tryItOutEnabled: tryItOutEnabled === true || tryItOutEnabled === "true", tryItOutEnabled,
executeInProgress: false executeInProgress: false
} }
} }
@@ -61,14 +61,13 @@ export default class OperationContainer extends PureComponent {
const showSummary = layoutSelectors.showSummary() const showSummary = layoutSelectors.showSummary()
const operationId = op.getIn(["operation", "__originalOperationId"]) || op.getIn(["operation", "operationId"]) || opId(op.get("operation"), props.path, props.method) || op.get("id") const operationId = op.getIn(["operation", "__originalOperationId"]) || op.getIn(["operation", "operationId"]) || opId(op.get("operation"), props.path, props.method) || op.get("id")
const isShownKey = ["operations", props.tag, operationId] const isShownKey = ["operations", props.tag, operationId]
const isDeepLinkingEnabled = deepLinking && deepLinking !== "false"
const allowTryItOut = supportedSubmitMethods.indexOf(props.method) >= 0 && (typeof props.allowTryItOut === "undefined" ? const allowTryItOut = supportedSubmitMethods.indexOf(props.method) >= 0 && (typeof props.allowTryItOut === "undefined" ?
props.specSelectors.allowTryItOutFor(props.path, props.method) : props.allowTryItOut) props.specSelectors.allowTryItOutFor(props.path, props.method) : props.allowTryItOut)
const security = op.getIn(["operation", "security"]) || props.specSelectors.security() const security = op.getIn(["operation", "security"]) || props.specSelectors.security()
return { return {
operationId, operationId,
isDeepLinkingEnabled, isDeepLinkingEnabled: deepLinking,
showSummary, showSummary,
displayOperationId, displayOperationId,
displayRequestDuration, displayRequestDuration,

View File

@@ -29,11 +29,11 @@ export default class FilterContainer extends React.Component {
return ( return (
<div> <div>
{filter === null || filter === false || filter === "false" ? null : {filter === false ? null :
<div className="filter-container"> <div className="filter-container">
<Col className="filter wrapper" mobile={12}> <Col className="filter wrapper" mobile={12}>
<input className={classNames.join(" ")} placeholder="Filter by tag" type="text" <input className={classNames.join(" ")} placeholder="Filter by tag" type="text"
onChange={this.onFilterChange} value={filter === true || filter === "true" ? "" : filter} onChange={this.onFilterChange} value={typeof filter === "string" ? filter : ""}
disabled={isLoading}/> disabled={isLoading}/>
</Col> </Col>
</div> </div>

View File

@@ -9,12 +9,12 @@ export const taggedOperations = (oriSelector, system) => (state, ...args) => {
// Filter, if requested // Filter, if requested
let filter = layoutSelectors.currentFilter() let filter = layoutSelectors.currentFilter()
if (filter) { if (filter) {
if (filter !== true && filter !== "true" && filter !== "false") { if (filter !== true) {
taggedOps = fn.opsFilter(taggedOps, filter) taggedOps = fn.opsFilter(taggedOps, filter)
} }
} }
// Limit to [max] items, if specified // Limit to [max] items, if specified
if (maxDisplayedTags && !isNaN(maxDisplayedTags) && maxDisplayedTags >= 0) { if (maxDisplayedTags >= 0) {
taggedOps = taggedOps.slice(0, maxDisplayedTags) taggedOps = taggedOps.slice(0, maxDisplayedTags)
} }

View File

@@ -2,7 +2,5 @@ export const loaded = (ori, system) => (...args) => {
ori(...args) ori(...args)
const value = system.getConfigs().withCredentials const value = system.getConfigs().withCredentials
if(value !== undefined) { system.fn.fetch.withCredentials = value
system.fn.fetch.withCredentials = typeof value === "string" ? (value === "true") : !!value
}
} }

View File

@@ -4,7 +4,6 @@
import React from "react" import React from "react"
import PropTypes from "prop-types" import PropTypes from "prop-types"
import ReactSyntaxHighlighter from "react-syntax-highlighter/dist/esm/light" import ReactSyntaxHighlighter from "react-syntax-highlighter/dist/esm/light"
import get from "lodash/get"
const SyntaxHighlighter = ({ const SyntaxHighlighter = ({
language, language,
@@ -13,8 +12,7 @@ const SyntaxHighlighter = ({
syntaxHighlighting = {}, syntaxHighlighting = {},
children = "", children = "",
}) => { }) => {
const configs = getConfigs() const theme = getConfigs().syntaxHighlight.theme
const theme = get(configs, "syntaxHighlight.theme")
const { styles, defaultStyle } = syntaxHighlighting const { styles, defaultStyle } = syntaxHighlighting
const style = styles?.[theme] ?? defaultStyle const style = styles?.[theme] ?? defaultStyle

View File

@@ -3,12 +3,10 @@
*/ */
import React from "react" import React from "react"
import PropTypes from "prop-types" import PropTypes from "prop-types"
import get from "lodash/get"
const SyntaxHighlighterWrapper = (Original, system) => { const SyntaxHighlighterWrapper = (Original, system) => {
const SyntaxHighlighter = ({ renderPlainText, children, ...rest }) => { const SyntaxHighlighter = ({ renderPlainText, children, ...rest }) => {
const configs = system.getConfigs() const canSyntaxHighlight = system.getConfigs().syntaxHighlight.activated
const canSyntaxHighlight = !!get(configs, "syntaxHighlight.activated")
const PlainTextViewer = system.getComponent("PlainTextViewer") const PlainTextViewer = system.getComponent("PlainTextViewer")
if (!canSyntaxHighlight && typeof renderPlainText === "function") { if (!canSyntaxHighlight && typeof renderPlainText === "function") {

View File

@@ -30,21 +30,6 @@ describe("<FilterContainer/>", function(){
expect(renderedColInsideFilter.length).toEqual(1) expect(renderedColInsideFilter.length).toEqual(1)
}) })
it("does not render FilterContainer if filter is null", function(){
// Given
let props = {...mockedProps}
props.layoutSelectors = {...mockedProps.specSelectors}
props.layoutSelectors.currentFilter = function() {return null}
// When
let wrapper = mount(<FilterContainer {...props}/>)
// Then
const renderedColInsideFilter = wrapper.find(Col)
expect(renderedColInsideFilter.length).toEqual(0)
})
it("does not render FilterContainer if filter is false", function(){ it("does not render FilterContainer if filter is false", function(){
// Given // Given

View File

@@ -0,0 +1,104 @@
/**
* @prettier
*/
import typeCast from "core/config/type-cast"
jest.mock("core/presets/apis", () => {})
describe("typeCast", () => {
it("should cast stringified `true` and `false` values to `boolean`", () => {
const config = {
deepLinking: "true",
tryItOutEnabled: "false",
withCredentials: "true",
filter: "false",
}
const expectedConfig = {
deepLinking: true,
tryItOutEnabled: false,
withCredentials: true,
filter: false,
}
expect(typeCast(config)).toStrictEqual(expectedConfig)
})
it("should cast stringified `number` values to `number`", () => {
const config = {
defaultModelExpandDepth: "5",
defaultModelsExpandDepth: "-1",
maxDisplayedTags: "1",
}
const expectedConfig = {
defaultModelExpandDepth: 5,
defaultModelsExpandDepth: -1,
maxDisplayedTags: 1,
}
expect(typeCast(config)).toStrictEqual(expectedConfig)
})
it("should cast stringified `null` values to `null`", () => {
const config = {
validatorUrl: "null",
}
const expectedConfig = {
validatorUrl: null,
}
expect(typeCast(config)).toStrictEqual(expectedConfig)
})
it("should cast `string` values to `string`", () => {
const config = { defaultModelRendering: "model", filter: "pet" }
const expectedConfig = { defaultModelRendering: "model", filter: "pet" }
expect(typeCast(config)).toStrictEqual(expectedConfig)
})
it("should cast stringified values to correct type", () => {
const config = {
dom_id: "null",
oauth2RedirectUrl: "undefined",
syntaxHighlight: "false",
urls: "null",
}
const expectedConfig = {
dom_id: null,
oauth2RedirectUrl: undefined,
syntaxHighlight: { activated: false },
urls: null,
}
expect(typeCast(config)).toStrictEqual(expectedConfig)
})
it("should cast incorrect value types to default value", () => {
const config = {
deepLinking: "deepLinking",
urls: "urls",
syntaxHighlight: "syntaxHighlight",
spec: "spec",
maxDisplayedTags: "null",
defaultModelExpandDepth: {},
defaultModelsExpandDepth: false,
}
const expectedConfig = {
deepLinking: false,
urls: null,
syntaxHighlight: { activated: true, theme: "agate" },
spec: {},
maxDisplayedTags: -1,
defaultModelExpandDepth: 1,
defaultModelsExpandDepth: 1,
}
expect(typeCast(config)).toStrictEqual(expectedConfig)
})
})

View File

@@ -49,44 +49,6 @@ describe("swagger-client plugin - withCredentials", () => {
const loadedFn = loaded(oriExecute, system) const loadedFn = loaded(oriExecute, system)
loadedFn() loadedFn()
expect(oriExecute.mock.calls.length).toBe(1)
expect(system.fn.fetch.withCredentials).toBe(false)
})
it("should allow setting flag to true via config as string", () => {
// for query string config
const system = {
fn: {
fetch: jest.fn().mockImplementation(() => Promise.resolve())
},
getConfigs: () => ({
withCredentials: "true"
})
}
const oriExecute = jest.fn()
const loadedFn = loaded(oriExecute, system)
loadedFn()
expect(oriExecute.mock.calls.length).toBe(1)
expect(system.fn.fetch.withCredentials).toBe(true)
})
it("should allow setting flag to false via config as string", () => {
// for query string config
const system = {
fn: {
fetch: jest.fn().mockImplementation(() => Promise.resolve())
},
getConfigs: () => ({
withCredentials: "false"
})
}
const oriExecute = jest.fn()
const loadedFn = loaded(oriExecute, system)
loadedFn()
expect(oriExecute.mock.calls.length).toBe(1) expect(oriExecute.mock.calls.length).toBe(1)
expect(system.fn.fetch.withCredentials).toBe(false) expect(system.fn.fetch.withCredentials).toBe(false)
}) })