Skip to content

fix(oas3): switching media types should update schema properties #6518

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Oct 14, 2020
7 changes: 6 additions & 1 deletion src/core/components/execute.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
18 changes: 16 additions & 2 deletions src/core/components/parameters/parameters.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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" />
</label>
Expand Down
8 changes: 8 additions & 0 deletions src/core/plugins/oas3/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
}
}
12 changes: 12 additions & 0 deletions src/core/plugins/oas3/reducers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
}
}
22 changes: 11 additions & 11 deletions src/core/plugins/oas3/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
11 changes: 11 additions & 0 deletions src/core/plugins/spec/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
174 changes: 174 additions & 0 deletions test/e2e-cypress/tests/features/oas3-multiple-media-type.js
Original file line number Diff line number Diff line change
@@ -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")
})
})
})
Loading