diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index b4b28e1cb92..436e9f06393 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -114,6 +114,20 @@ Parameter name | Docker variable | Description 'none' (expands nothing). + + resolveSubtreeOnExpand + + Unavailable + Boolean=true. Controls whether an operation's or model's + subtree is resolved (dereferencing $refs and generating + samples) when it is expanded. The default is true. Set to + false to skip this work and render directly from the + unresolved specification, which can keep expansion responsive on very + large or deeply nested schemas (for example documents with many nested + oneOf compositions). When disabled, referenced schemas are + shown by name rather than fully inlined. + + filter FILTER diff --git a/src/core/config/defaults.js b/src/core/config/defaults.js index 96102dda55e..a8bf20fc9d3 100644 --- a/src/core/config/defaults.js +++ b/src/core/config/defaults.js @@ -31,6 +31,7 @@ const defaultOptions = Object.freeze({ defaultModelRendering: "example", defaultModelExpandDepth: 1, defaultModelsExpandDepth: 1, + resolveSubtreeOnExpand: true, showExtensions: false, showCommonExtensions: false, withCredentials: false, diff --git a/src/core/config/type-cast/mappings.js b/src/core/config/type-cast/mappings.js index 2063f101edb..0cda315c864 100644 --- a/src/core/config/type-cast/mappings.js +++ b/src/core/config/type-cast/mappings.js @@ -88,6 +88,10 @@ const mappings = { typeCaster: booleanTypeCaster, defaultValue: defaultOptions.requestSnippetsEnabled, }, + resolveSubtreeOnExpand: { + typeCaster: booleanTypeCaster, + defaultValue: defaultOptions.resolveSubtreeOnExpand, + }, responseInterceptor: { typeCaster: functionTypeCaster, defaultValue: defaultOptions.responseInterceptor, diff --git a/src/core/containers/OperationContainer.jsx b/src/core/containers/OperationContainer.jsx index 65ae7832b85..4179c52f111 100644 --- a/src/core/containers/OperationContainer.jsx +++ b/src/core/containers/OperationContainer.jsx @@ -85,7 +85,7 @@ export default class OperationContainer extends PureComponent { const { isShown } = this.props const resolvedSubtree = this.getResolvedSubtree() - if(isShown && resolvedSubtree === undefined) { + if (this.shouldResolveOperationSubtree() && isShown && resolvedSubtree === undefined) { this.requestResolvedSubtree() } } @@ -98,7 +98,11 @@ export default class OperationContainer extends PureComponent { this.setState({ executeInProgress: false }) } - if (isShown && resolvedSubtree === undefined) { + if ( + this.shouldResolveOperationSubtree() && + isShown && + resolvedSubtree === undefined + ) { this.requestResolvedSubtree() } } @@ -106,13 +110,22 @@ export default class OperationContainer extends PureComponent { toggleShown =() => { let { layoutActions, tag, operationId, isShown } = this.props const resolvedSubtree = this.getResolvedSubtree() - if(!isShown && resolvedSubtree === undefined) { + if ( + this.shouldResolveOperationSubtree() && + !isShown && + resolvedSubtree === undefined + ) { // transitioning from collapsed to expanded this.requestResolvedSubtree() } layoutActions.show(["operations", tag, operationId], !isShown) } + shouldResolveOperationSubtree = () => { + const { resolveSubtreeOnExpand = true } = this.props.getConfigs() + return resolveSubtreeOnExpand + } + onCancelClick=() => { this.setState({tryItOutEnabled: !this.state.tryItOutEnabled}) } @@ -214,19 +227,36 @@ export default class OperationContainer extends PureComponent { const Operation = getComponent( "operation" ) - const resolvedSubtree = this.getResolvedSubtree() || Map() + const resolvedSubtree = this.getResolvedSubtree() + const unresolvedOperation = unresolvedOp.get("operation") || Map() + const hasResolvedSubtree = Map.isMap(resolvedSubtree) && resolvedSubtree.size + // When subtree resolution is enabled we wait for the resolved subtree + // before rendering, otherwise initializing param values from the + // unresolved ($ref) schema would prevent them from refreshing once the + // resolved schema arrives. When resolution is disabled, fall back to the + // unresolved operation so the operation still renders. + const operation = hasResolvedSubtree + ? resolvedSubtree + : this.shouldResolveOperationSubtree() + ? Map() + : unresolvedOperation const operationProps = fromJS({ - op: resolvedSubtree, + op: operation, tag, path, summary: unresolvedOp.getIn(["operation", "summary"]) || "", - deprecated: resolvedSubtree.get("deprecated") || unresolvedOp.getIn(["operation", "deprecated"]) || false, + deprecated: + operation.get("deprecated") || + unresolvedOp.getIn(["operation", "deprecated"]) || + false, method, security, isAuthorized, operationId, - originalOperationId: resolvedSubtree.getIn(["operation", "__originalOperationId"]), + originalOperationId: + operation.get("__originalOperationId") || + unresolvedOp.getIn(["operation", "__originalOperationId"]), showSummary, isShown, jumpToKey, diff --git a/src/core/plugins/json-schema-5/components/model.jsx b/src/core/plugins/json-schema-5/components/model.jsx index 1fb70d386bf..fe3f43bec84 100644 --- a/src/core/plugins/json-schema-5/components/model.jsx +++ b/src/core/plugins/json-schema-5/components/model.jsx @@ -62,6 +62,9 @@ export default class Model extends ImmutablePureComponent { if (!name && $$ref) { name = this.getModelName($$ref) } + if (!name && $ref) { + name = this.getModelName($ref) + } /* * If we have an unresolved ref, get the schema and name from the ref. diff --git a/src/core/plugins/json-schema-5/components/models.jsx b/src/core/plugins/json-schema-5/components/models.jsx index e9023f36f3e..b0b305f446c 100644 --- a/src/core/plugins/json-schema-5/components/models.jsx +++ b/src/core/plugins/json-schema-5/components/models.jsx @@ -24,9 +24,10 @@ export default class Models extends Component { } handleToggle = (name, isExpanded) => { - const { layoutActions } = this.props + const { layoutActions, getConfigs } = this.props + const { resolveSubtreeOnExpand = true } = getConfigs() layoutActions.show([...this.getSchemaBasePath(), name], isExpanded) - if(isExpanded) { + if (isExpanded && resolveSubtreeOnExpand) { this.props.specActions.requestResolvedSubtree([...this.getSchemaBasePath(), name]) } } @@ -82,18 +83,13 @@ export default class Models extends Component { const schemaValue = specSelectors.specResolvedSubtree(fullPath) const rawSchemaValue = specSelectors.specJson().getIn(fullPath) - const schema = Map.isMap(schemaValue) ? schemaValue : Im.Map() const rawSchema = Map.isMap(rawSchemaValue) ? rawSchemaValue : Im.Map() + const schema = + Map.isMap(schemaValue) && schemaValue.size ? schemaValue : rawSchema const displayName = schema.get("title") || rawSchema.get("title") || name const isShown = layoutSelectors.isShown(fullPath, false) - if( isShown && (schema.size === 0 && rawSchema.size > 0) ) { - // Firing an action in a container render is not great, - // but it works for now. - this.props.specActions.requestResolvedSubtree(fullPath) - } - const content = { + if (typeof uri !== "string") { + return null + } + + const unescaped = uri.replace(/~1/g, "/").replace(/~0/g, "~") + try { + return decodeURIComponent(unescaped) + } catch { + return unescaped + } +} + +const getRefDisplayName = (ref) => { + if (typeof ref !== "string") { + return null + } + if (ref.indexOf("#/definitions/") !== -1) { + return decodeRefName(ref.replace(/^.*#\/definitions\//, "")) + } + if (ref.indexOf("#/components/schemas/") !== -1) { + return decodeRefName(ref.replace(/^.*#\/components\/schemas\//, "")) + } + return null +} + +const inferSchemaDisplayName = (schema, level = 0) => { + if (!schema || typeof schema.get !== "function" || level > 2) { + return null + } + + const title = schema.get("title") + if (typeof title === "string" && title) { + return title + } + + const refDisplayName = getRefDisplayName( + schema.get("$$ref") || schema.get("$ref") + ) + if (refDisplayName) { + return refDisplayName + } + + const compositions = ["allOf", "oneOf", "anyOf"] + for (let i = 0; i < compositions.length; i++) { + const composed = schema.get(compositions[i]) + if (List.isList(composed) && composed.size) { + for (let j = 0; j < composed.size; j++) { + const inferred = inferSchemaDisplayName(composed.get(j), level + 1) + if (inferred) { + return inferred + } + } + } + } + + return null +} + export default class ObjectModel extends Component { static propTypes = { schema: PropTypes.object.isRequired, @@ -237,6 +296,8 @@ export default class ObjectModel extends Component { {"allOf ->"} {allOf.map((schema, k) => { + const inferredDisplayName = + inferSchemaDisplayName(schema) return (
@@ -259,6 +321,8 @@ export default class ObjectModel extends Component { {"anyOf ->"} {anyOf.map((schema, k) => { + const inferredDisplayName = + inferSchemaDisplayName(schema) return (
@@ -281,6 +346,8 @@ export default class ObjectModel extends Component { {"oneOf ->"} {oneOf.map((schema, k) => { + const inferredDisplayName = + inferSchemaDisplayName(schema) return (
diff --git a/test/e2e-cypress/e2e/features/resolve-subtree-on-expand-disabled.cy.js b/test/e2e-cypress/e2e/features/resolve-subtree-on-expand-disabled.cy.js new file mode 100644 index 00000000000..ea0c75933da --- /dev/null +++ b/test/e2e-cypress/e2e/features/resolve-subtree-on-expand-disabled.cy.js @@ -0,0 +1,20 @@ +/** + * @prettier + */ +describe("resolveSubtreeOnExpand disabled", () => { + it("renders a schema model and its properties when expanded", () => { + cy.visit( + "/?url=/documents/features/models.swagger.yaml&resolveSubtreeOnExpand=false" + ) + .get("#model-Pet .model-box .model-box-control") + .click() + .get("#model-Pet .model-box .model .inner-object") + .should("exist") + + cy.get("#model-Pet").contains("tr.property-row td", "name").should("exist") + cy.get("#model-Pet") + .contains("tr.property-row td", "category") + .should("exist") + cy.get("#model-Pet").contains("Category").should("exist") + }) +}) diff --git a/test/unit/core/config/type-cast/index.js b/test/unit/core/config/type-cast/index.js index c233b13fbb7..27e27c4d384 100644 --- a/test/unit/core/config/type-cast/index.js +++ b/test/unit/core/config/type-cast/index.js @@ -12,6 +12,7 @@ describe("typeCast", () => { tryItOutEnabled: "false", withCredentials: "true", filter: "false", + resolveSubtreeOnExpand: "true", } const expectedConfig = { @@ -19,6 +20,7 @@ describe("typeCast", () => { tryItOutEnabled: false, withCredentials: true, filter: false, + resolveSubtreeOnExpand: true, } expect(typeCast(config)).toStrictEqual(expectedConfig) @@ -87,6 +89,7 @@ describe("typeCast", () => { maxDisplayedTags: "null", defaultModelExpandDepth: {}, defaultModelsExpandDepth: false, + resolveSubtreeOnExpand: "invalid", } const expectedConfig = { @@ -97,6 +100,7 @@ describe("typeCast", () => { maxDisplayedTags: -1, defaultModelExpandDepth: 1, defaultModelsExpandDepth: 1, + resolveSubtreeOnExpand: true, } expect(typeCast(config)).toStrictEqual(expectedConfig) diff --git a/test/unit/core/plugins/json-schema-5/components/model.jsx b/test/unit/core/plugins/json-schema-5/components/model.jsx new file mode 100644 index 00000000000..b5985c330a5 --- /dev/null +++ b/test/unit/core/plugins/json-schema-5/components/model.jsx @@ -0,0 +1,38 @@ +import React from "react" +import { shallow } from "enzyme" +import { fromJS } from "immutable" +import Model from "core/plugins/json-schema-5/components/model" + +describe("", function() { + const ObjectModel = () => null + const ArrayModel = () => null + const PrimitiveModel = () => null + + const getComponent = (componentName) => { + return { + ObjectModel, + ArrayModel, + PrimitiveModel, + }[componentName] + } + + const props = { + getComponent, + getConfigs: () => ({}), + specSelectors: { + isOAS3: () => true, + findDefinition: () => fromJS({ type: "object" }), + }, + specPath: fromJS([]), + } + + it("derives model name from unresolved $ref", function() { + const wrapper = shallow( + + ) + + const renderedObjectModel = wrapper.find(ObjectModel) + expect(renderedObjectModel.length).toEqual(1) + expect(renderedObjectModel.first().prop("name")).toEqual("Links") + }) +}) diff --git a/test/unit/core/plugins/json-schema-5/components/models.jsx b/test/unit/core/plugins/json-schema-5/components/models.jsx index 3203f486376..6ff0fe4ed85 100644 --- a/test/unit/core/plugins/json-schema-5/components/models.jsx +++ b/test/unit/core/plugins/json-schema-5/components/models.jsx @@ -17,6 +17,9 @@ describe("", function(){ getComponent: (c) => { return components[c] }, + specActions: { + requestResolvedSubtree: jest.fn() + }, specSelectors: { isOAS3: () => false, specJson: () => Map(), @@ -31,10 +34,14 @@ describe("", function(){ layoutSelectors: { isShown: jest.fn() }, - layoutActions: {}, + layoutActions: { + show: jest.fn(), + readyToScroll: jest.fn() + }, getConfigs: () => ({ docExpansion: "list", - defaultModelsExpandDepth: 0 + defaultModelsExpandDepth: 0, + resolveSubtreeOnExpand: false }) } @@ -51,4 +58,51 @@ describe("", function(){ }) }) + it("does not resolve model subtree on expand when disabled", function() { + const localProps = { + ...props, + specActions: { + requestResolvedSubtree: jest.fn() + }, + layoutActions: { + show: jest.fn(), + readyToScroll: jest.fn() + }, + getConfigs: () => ({ + docExpansion: "list", + defaultModelsExpandDepth: 0, + resolveSubtreeOnExpand: false + }) + } + + const wrapper = shallow() + wrapper.instance().handleToggle("def1", true) + + expect(localProps.layoutActions.show).toHaveBeenCalledWith(["definitions", "def1"], true) + expect(localProps.specActions.requestResolvedSubtree).not.toHaveBeenCalled() + }) + + it("resolves model subtree on expand when enabled", function() { + const localProps = { + ...props, + specActions: { + requestResolvedSubtree: jest.fn() + }, + layoutActions: { + show: jest.fn(), + readyToScroll: jest.fn() + }, + getConfigs: () => ({ + docExpansion: "list", + defaultModelsExpandDepth: 0, + resolveSubtreeOnExpand: true + }) + } + + const wrapper = shallow() + wrapper.instance().handleToggle("def1", true) + + expect(localProps.specActions.requestResolvedSubtree).toHaveBeenCalledWith(["definitions", "def1"]) + }) + }) diff --git a/test/unit/core/plugins/json-schema-5/components/object-model.jsx b/test/unit/core/plugins/json-schema-5/components/object-model.jsx index 9f4c5f72382..941a79bf69c 100644 --- a/test/unit/core/plugins/json-schema-5/components/object-model.jsx +++ b/test/unit/core/plugins/json-schema-5/components/object-model.jsx @@ -61,6 +61,25 @@ describe("", function() { ...props, schema: props.schema.set("minProperties", 1).set("maxProperties", 5) } + const propsOneOfRefs = { + ...props, + schema: Immutable.fromJS({ + oneOf: [ + { + allOf: [ + { + $ref: "#/components/schemas/Application" + } + ] + } + ] + }), + specSelectors: { + isOAS3(){ + return true + } + } + } it("renders a collapsible header", function(){ const wrapper = shallow() @@ -106,4 +125,15 @@ describe("", function() { expect(renderProperties.get(1).props.propKey).toEqual("maxProperties") expect(renderProperties.get(1).props.propVal).toEqual(5) }) + + it("infers display names for composed refs in oneOf entries", function() { + const wrapper = shallow() + const oneOfModel = wrapper.find(Model).findWhere((node) => { + const specPath = node.prop("specPath") + return specPath && specPath.equals(List(["oneOf", 0])) + }) + + expect(oneOfModel.length).toEqual(1) + expect(oneOfModel.first().prop("displayName")).toEqual("Application") + }) })