diff --git a/src/core/components/execute.jsx b/src/core/components/execute.jsx index 0c005fcbbb4..04765e91b9f 100644 --- a/src/core/components/execute.jsx +++ b/src/core/components/execute.jsx @@ -31,6 +31,7 @@ export default class Execute extends Component { let oas3RequiredRequestBodyContentType = specSelectors.getOAS3RequiredRequestBodyContentType([path, method]) let oas3RequestBodyValue = oas3Selectors.requestBodyValue(path, method) let oas3ValidateBeforeExecuteSuccess = oas3Selectors.validateBeforeExecute([path, method]) + let oas3RequestContentType = oas3Selectors.requestContentType(path, method) if (!oas3ValidateBeforeExecuteSuccess) { validationErrors.missingBodyValue = true @@ -40,7 +41,11 @@ export default class Execute extends Component { if (!oas3RequiredRequestBodyContentType) { return true } - let missingRequiredKeys = oas3Selectors.validateShallowRequired({ oas3RequiredRequestBodyContentType, oas3RequestBodyValue }) + let missingRequiredKeys = oas3Selectors.validateShallowRequired({ + oas3RequiredRequestBodyContentType, + oas3RequestContentType, + oas3RequestBodyValue + }) if (!missingRequiredKeys || missingRequiredKeys.length < 1) { return true } diff --git a/src/core/components/parameters/parameters.jsx b/src/core/components/parameters/parameters.jsx index 916e8471fbb..a1bb9eb61ba 100644 --- a/src/core/components/parameters/parameters.jsx +++ b/src/core/components/parameters/parameters.jsx @@ -77,6 +77,21 @@ export default class Parameters extends Component { } } + onChangeMediaType = ( { value, pathMethod } ) => { + let { specSelectors, specActions, oas3Selectors, oas3Actions } = this.props + let targetMediaType = value + let currentMediaType = oas3Selectors.requestContentType(...pathMethod) + let schemaPropertiesMatch = specSelectors.isMediaTypeSchemaPropertiesEqual(pathMethod, currentMediaType, targetMediaType) + if (!schemaPropertiesMatch) { + oas3Actions.clearRequestBodyValue({ pathMethod }) + specActions.clearResponse(...pathMethod) + specActions.clearRequest(...pathMethod) + specActions.clearValidateParams(pathMethod) + } + oas3Actions.setRequestContentType({ value, pathMethod }) + oas3Actions.initRequestBodyValidateError({ pathMethod }) + } + render(){ let { @@ -187,8 +202,7 @@ export default class Parameters extends Component { value={oas3Selectors.requestContentType(...pathMethod)} contentTypes={ requestBody.get("content", List()).keySeq() } onChange={(value) => { - oas3Actions.setRequestContentType({ value, pathMethod }) - oas3Actions.initRequestBodyValidateError({ pathMethod }) + this.onChangeMediaType({ value, pathMethod }) }} className="body-param-content-type" /> diff --git a/src/core/plugins/oas3/actions.js b/src/core/plugins/oas3/actions.js index ef6a7ae6b9b..e0f3c256e85 100644 --- a/src/core/plugins/oas3/actions.js +++ b/src/core/plugins/oas3/actions.js @@ -10,6 +10,7 @@ export const UPDATE_RESPONSE_CONTENT_TYPE = "oas3_set_response_content_type" export const UPDATE_SERVER_VARIABLE_VALUE = "oas3_set_server_variable_value" export const SET_REQUEST_BODY_VALIDATE_ERROR = "oas3_set_request_body_validate_error" export const CLEAR_REQUEST_BODY_VALIDATE_ERROR = "oas3_clear_request_body_validate_error" +export const CLEAR_REQUEST_BODY_VALUE = "oas3_clear_request_body_value" export function setSelectedServer (selectedServerUrl, namespace) { return { @@ -80,3 +81,10 @@ export const initRequestBodyValidateError = ({ pathMethod } ) => { payload: { path: pathMethod[0], method: pathMethod[1] } } } + +export const clearRequestBodyValue = ({ pathMethod }) => { + return { + type: CLEAR_REQUEST_BODY_VALUE, + payload: { pathMethod } + } +} diff --git a/src/core/plugins/oas3/reducers.js b/src/core/plugins/oas3/reducers.js index 9951bf839d0..e317bbdcb03 100644 --- a/src/core/plugins/oas3/reducers.js +++ b/src/core/plugins/oas3/reducers.js @@ -10,6 +10,7 @@ import { UPDATE_RESPONSE_CONTENT_TYPE, SET_REQUEST_BODY_VALIDATE_ERROR, CLEAR_REQUEST_BODY_VALIDATE_ERROR, + CLEAR_REQUEST_BODY_VALUE, } from "./actions" export default { @@ -94,4 +95,15 @@ export default { }, bodyValues) }) }, + [CLEAR_REQUEST_BODY_VALUE]: (state, { payload: { pathMethod }}) => { + let [path, method] = pathMethod + const requestBodyValue = state.getIn(["requestData", path, method, "bodyValue"]) + if (!requestBodyValue) { + return state + } + if (!Map.isMap(requestBodyValue)) { + return state.setIn(["requestData", path, method, "bodyValue"], "") + } + return state.setIn(["requestData", path, method, "bodyValue"], Map()) + } } diff --git a/src/core/plugins/oas3/selectors.js b/src/core/plugins/oas3/selectors.js index 1c5df118bfa..2176e2e52e6 100644 --- a/src/core/plugins/oas3/selectors.js +++ b/src/core/plugins/oas3/selectors.js @@ -154,23 +154,23 @@ export const validateBeforeExecute = validateRequestBodyIsRequired( (state, pathMethod) => validateRequestBodyValueExists(state, pathMethod) ) -export const validateShallowRequired = ( state, {oas3RequiredRequestBodyContentType, oas3RequestBodyValue} ) => { +export const validateShallowRequired = (state, { oas3RequiredRequestBodyContentType, oas3RequestContentType, oas3RequestBodyValue} ) => { let missingRequiredKeys = [] - // context: json => String; urlencoded => Map + // context: json => String; urlencoded, form-data => Map if (!Map.isMap(oas3RequestBodyValue)) { return missingRequiredKeys } let requiredKeys = [] - // We intentionally cycle through list of contentTypes for defined requiredKeys - // instead of assuming first contentType will accurately list all expected requiredKeys - // Alternatively, we could try retrieving the contentType first, and match exactly. This would be a more accurate representation of definition + // Cycle through list of possible contentTypes for matching contentType and defined requiredKeys Object.keys(oas3RequiredRequestBodyContentType.requestContentType).forEach((contentType) => { - let contentTypeVal = oas3RequiredRequestBodyContentType.requestContentType[contentType] - contentTypeVal.forEach((requiredKey) => { - if (requiredKeys.indexOf(requiredKey) < 0 ) { - requiredKeys.push(requiredKey) - } - }) + if (contentType === oas3RequestContentType) { + let contentTypeVal = oas3RequiredRequestBodyContentType.requestContentType[contentType] + contentTypeVal.forEach((requiredKey) => { + if (requiredKeys.indexOf(requiredKey) < 0 ) { + requiredKeys.push(requiredKey) + } + }) + } }) requiredKeys.forEach((key) => { let requiredKeyValue = oas3RequestBodyValue.getIn([key, "value"]) diff --git a/src/core/plugins/spec/selectors.js b/src/core/plugins/spec/selectors.js index 2c854729b1b..f9d314dfaf0 100644 --- a/src/core/plugins/spec/selectors.js +++ b/src/core/plugins/spec/selectors.js @@ -514,6 +514,17 @@ export const getOAS3RequiredRequestBodyContentType = (state, pathMethod) => { return requiredObj } +export const isMediaTypeSchemaPropertiesEqual = ( state, pathMethod, currentMediaType, targetMediaType) => { + let requestBodyContent = state.getIn(["resolvedSubtrees", "paths", ...pathMethod, "requestBody", "content"], fromJS([])) + if (requestBodyContent.size < 2 || !currentMediaType || !targetMediaType) { + // nothing to compare + return false + } + let currentMediaTypeSchemaProperties = requestBodyContent.getIn([currentMediaType, "schema", "properties"], fromJS([])) + let targetMediaTypeSchemaProperties = requestBodyContent.getIn([targetMediaType, "schema", "properties"], fromJS([])) + return currentMediaTypeSchemaProperties.equals(targetMediaTypeSchemaProperties) ? true: false +} + function returnSelfOrNewMap(obj) { // returns obj if obj is an Immutable map, else returns a new Map return Map.isMap(obj) ? obj : new Map() diff --git a/test/e2e-cypress/static/documents/features/oas3-multiple-media-type.yaml b/test/e2e-cypress/static/documents/features/oas3-multiple-media-type.yaml new file mode 100644 index 00000000000..280891ae9a2 --- /dev/null +++ b/test/e2e-cypress/static/documents/features/oas3-multiple-media-type.yaml @@ -0,0 +1,50 @@ +openapi: 3.0.0 +info: + title: Switching between multiple content-type test + version: 1.0.0 +servers: + - url: https://httpbin.org +paths: + /post: + post: + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/Bar' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Foo' + application/json: + schema: + $ref: '#/components/schemas/FooBar' + responses: + '200': + description: ok + +components: + schemas: + Foo: + type: object + properties: + foo: + type: string + example: '' + Bar: + type: object + required: [bar] + properties: + bar: + type: integer + example: 1 + FooBar: + type: object + required: + - bar + properties: + foo: + type: string + example: '' + bar: + type: integer + example: 1 diff --git a/test/e2e-cypress/tests/features/oas3-multiple-media-type.js b/test/e2e-cypress/tests/features/oas3-multiple-media-type.js new file mode 100644 index 00000000000..a70d4486bc7 --- /dev/null +++ b/test/e2e-cypress/tests/features/oas3-multiple-media-type.js @@ -0,0 +1,174 @@ +// https://github.com/swagger-api/swagger-ui/issues/6201 +// https://github.com/swagger-api/swagger-ui/issues/6250 +// https://github.com/swagger-api/swagger-ui/issues/6476 + +describe("OpenAPI 3.0 Multiple Media Types with different schemas", () => { + const mediaTypeFormData = "multipart/form-data" + const mediaTypeUrlencoded = "application/x-www-form-urlencoded" + const mediaTypeJson = "application/json" + + beforeEach(() => { + cy.visit( + "/?url=/documents/features/oas3-multiple-media-type.yaml" + ) + .get("#operations-default-post_post") + .click() + // Expand Try It Out + .get(".try-out__btn") + .click() + // @alias Execute Button + cy.get(".execute.opblock-control__btn").as("executeBtn") + // @alias Media Type Dropdown + cy.get(".opblock-section-request-body .content-type").as("selectMediaType") + }) + + // In all cases, + // - assume that examples are populated based on schema (not explicitly tested) + // - assume validation passes based on successful "execute" + // - expect final cURL command result doees not contain unexpected artifacts from other content-type schemas + describe("multipart/form-data (only 'bar')", () => { + it("should execute multipart/form-data", () => { + cy.get("@selectMediaType") + .select(mediaTypeFormData) + cy.get("@executeBtn") + .click() + // cURL component + cy.get(".responses-wrapper .curl-command") + .should("exist") + .get(".responses-wrapper .curl-command span") + .should("contains.text", "bar") + .should("not.contains.text", "foo") + }) + it("should execute application/x-www-form-urlencoded THEN execute multipart/form-data", () => { + cy.get("@selectMediaType") + .select(mediaTypeUrlencoded) + cy.get("@executeBtn") + .click() + cy.get("@selectMediaType") + .select(mediaTypeFormData) + cy.get("@executeBtn") + .click() + // cURL component + cy.get(".responses-wrapper .curl-command") + .should("exist") + .get(".responses-wrapper .curl-command span") + .should("contains.text", "bar") + .should("not.contains.text", "foo") + }) + it("should execute application/json THEN execute multipart/form-data", () => { + cy.get("@selectMediaType") + .select(mediaTypeJson) + cy.get("@executeBtn") + .click() + cy.get("@selectMediaType") + .select(mediaTypeFormData) + cy.get("@executeBtn") + .click() + // cURL component + cy.get(".responses-wrapper .curl-command") + .should("exist") + .get(".responses-wrapper .curl-command span") + .should("contains.text", "bar") + .should("not.contains.text", "foo") + }) + }) + + describe("application/x-www-form-urlencoded (only 'foo')", () => { + it("should execute application/x-www-form-urlencoded", () => { + cy.get("@selectMediaType") + .select(mediaTypeUrlencoded) + cy.get("@executeBtn") + .click() + // cURL component + cy.get(".responses-wrapper .curl-command") + .should("exist") + .get(".responses-wrapper .curl-command span") + .should("contains.text", "foo") + .should("not.contains.text", "bar") + }) + it("should execute multipart/form-data THEN execute application/x-www-form-urlencoded", () => { + cy.get("@selectMediaType") + .select(mediaTypeFormData) + cy.get("@executeBtn") + .click() + cy.get("@selectMediaType") + .select(mediaTypeUrlencoded) + cy.get("@executeBtn") + .click() + // cURL component + cy.get(".responses-wrapper .curl-command") + .should("exist") + .get(".responses-wrapper .curl-command span") + .should("contains.text", "foo") + .should("not.contains.text", "bar") + }) + it("should execute application/json THEN execute application/x-www-form-urlencoded", () => { + cy.get("@selectMediaType") + .select(mediaTypeJson) + cy.get("@executeBtn") + .click() + cy.get("@selectMediaType") + .select(mediaTypeUrlencoded) + cy.get("@executeBtn") + .click() + // cURL component + cy.get(".responses-wrapper .curl-command") + .should("exist") + .get(".responses-wrapper .curl-command span") + .should("contains.text", "foo") + .should("not.contains.text", "bar") + }) + }) + + describe("application/json (both 'foo' and 'bar')", () => { + // note: form input for "application/json" is a string; not multiple form fields + it("should execute application/json", () => { + // final curl should have both "bar" and "foo" + cy.get("@selectMediaType") + .select(mediaTypeJson) + cy.get("@executeBtn") + .click() + cy.get("@executeBtn") + .click() + // cURL component + cy.get(".responses-wrapper .curl-command") + .should("exist") + .get(".responses-wrapper .curl-command span") + .should("contains.text", "foo") + .should("contains.text", "bar") + }) + it("should execute multipart/form-data THEN execute application/json", () => { + cy.get("@selectMediaType") + .select(mediaTypeFormData) + cy.get("@executeBtn") + .click() + cy.get("@selectMediaType") + .select(mediaTypeJson) + cy.get("@executeBtn") + .click() + // cURL component + cy.get(".responses-wrapper .curl-command") + .should("exist") + .get(".responses-wrapper .curl-command span") + .should("contains.text", "foo") + .should("contains.text", "bar") + }) + it("should execute application/x-www-form-urlencoded THEN execute application/json", () => { + // final curl should have both "bar" and "foo" + cy.get("@selectMediaType") + .select(mediaTypeUrlencoded) + cy.get("@executeBtn") + .click() + cy.get("@selectMediaType") + .select(mediaTypeJson) + cy.get("@executeBtn") + .click() + // cURL component + cy.get(".responses-wrapper .curl-command") + .should("exist") + .get(".responses-wrapper .curl-command span") + .should("contains.text", "foo") + .should("contains.text", "bar") + }) + }) +}) diff --git a/test/unit/core/plugins/oas3/reducers.js b/test/unit/core/plugins/oas3/reducers.js index 102b25e3e8e..c99eaba1da8 100644 --- a/test/unit/core/plugins/oas3/reducers.js +++ b/test/unit/core/plugins/oas3/reducers.js @@ -504,4 +504,79 @@ describe("oas3 plugin - reducer", function () { }) }) + describe("CLEAR_REQUEST_BODY_VALUE", function () { + const clearRequestBodyValue = reducer["oas3_clear_request_body_value"] + describe("when requestBodyValue is a String", () => { + it("should clear requestBodyValue with empty String", () => { + const state = fromJS({ + requestData: { + "/pet": { + post: { + bodyValue: "some random string", + requestContentType: "application/json" + } + } + } + }) + + const result = clearRequestBodyValue(state, { + payload: { + pathMethod: ["/pet", "post"], + } + }) + + const expectedResult = { + requestData: { + "/pet": { + post: { + bodyValue: "", + requestContentType: "application/json", + } + } + } + } + + expect(result.toJS()).toEqual(expectedResult) + }) + }) + + describe("when requestBodyValue is a Map", () => { + it("should clear requestBodyValue with empty Map", () => { + const state = fromJS({ + requestData: { + "/pet": { + post: { + bodyValue: { + id: { + value: "10", + }, + }, + requestContentType: "application/x-www-form-urlencoded" + } + } + } + }) + + const result = clearRequestBodyValue(state, { + payload: { + pathMethod: ["/pet", "post"], + } + }) + + const expectedResult = { + requestData: { + "/pet": { + post: { + bodyValue: {}, + requestContentType: "application/x-www-form-urlencoded", + } + } + } + } + + expect(result.toJS()).toEqual(expectedResult) + }) + }) + }) + })