feat(RequestBody): validation support for required fields (#6223)

fixes #5181

* application/json
* application/xml
* application/x-www-form-urlencoded

* Set requestBodyValue values to be an immutable Map, as "value". Previously stored as a normal String.
* This enables adding "errors" to the Map, for validation use

* note: getOAS3RequiredRequestBodyContentType requires state.spec,
* which is not available to state.oas3
This commit is contained in:
Tim Lai
2020-07-16 17:53:28 -07:00
committed by GitHub
parent b68942c043
commit 2fd1e4037c
15 changed files with 1029 additions and 26 deletions

View File

@@ -9,26 +9,79 @@ export default class Execute extends Component {
operation: PropTypes.object.isRequired, operation: PropTypes.object.isRequired,
path: PropTypes.string.isRequired, path: PropTypes.string.isRequired,
method: PropTypes.string.isRequired, method: PropTypes.string.isRequired,
oas3Selectors: PropTypes.object.isRequired,
oas3Actions: PropTypes.object.isRequired,
onExecute: PropTypes.func onExecute: PropTypes.func
} }
onClick=()=>{ handleValidateParameters = () => {
let { specSelectors, specActions, operation, path, method } = this.props let { specSelectors, specActions, path, method } = this.props
specActions.validateParams([path, method])
return specSelectors.validateBeforeExecute([path, method])
}
specActions.validateParams( [path, method] ) handleValidateRequestBody = () => {
let { path, method, specSelectors, oas3Selectors, oas3Actions } = this.props
let validationErrors = {
missingBodyValue: false,
missingRequiredKeys: []
}
// context: reset errors, then (re)validate
oas3Actions.clearRequestBodyValidateError({ path, method })
let oas3RequiredRequestBodyContentType = specSelectors.getOAS3RequiredRequestBodyContentType([path, method])
let oas3RequestBodyValue = oas3Selectors.requestBodyValue(path, method)
let oas3ValidateBeforeExecuteSuccess = oas3Selectors.validateBeforeExecute([path, method])
if ( specSelectors.validateBeforeExecute([path, method]) ) { if (!oas3ValidateBeforeExecuteSuccess) {
if(this.props.onExecute) { validationErrors.missingBodyValue = true
oas3Actions.setRequestBodyValidateError({ path, method, validationErrors })
return false
}
if (!oas3RequiredRequestBodyContentType) {
return true
}
let missingRequiredKeys = oas3Selectors.validateShallowRequired({ oas3RequiredRequestBodyContentType, oas3RequestBodyValue })
if (!missingRequiredKeys || missingRequiredKeys.length < 1) {
return true
}
missingRequiredKeys.forEach((missingKey) => {
validationErrors.missingRequiredKeys.push(missingKey)
})
oas3Actions.setRequestBodyValidateError({ path, method, validationErrors })
return false
}
handleValidationResultPass = () => {
let { specActions, operation, path, method } = this.props
if (this.props.onExecute) {
// loading spinner
this.props.onExecute() this.props.onExecute()
} }
specActions.execute( { operation, path, method } ) specActions.execute({ operation, path, method })
} else { }
handleValidationResultFail = () => {
let { specActions, path, method } = this.props
// deferred by 40ms, to give element class change time to settle. // deferred by 40ms, to give element class change time to settle.
specActions.clearValidateParams( [path, method] ) specActions.clearValidateParams([path, method])
setTimeout(() => { setTimeout(() => {
specActions.validateParams([path, method]) specActions.validateParams([path, method])
}, 40) }, 40)
} }
handleValidationResult = (isPass) => {
if (isPass) {
this.handleValidationResultPass()
} else {
this.handleValidationResultFail()
}
}
onClick = () => {
let paramsResult = this.handleValidateParameters()
let requestBodyResult = this.handleValidateRequestBody()
let isPass = paramsResult && requestBodyResult
this.handleValidationResult(isPass)
} }
onChangeProducesWrapper = ( val ) => this.props.specActions.changeProducesValue([this.props.path, this.props.method], val) onChangeProducesWrapper = ( val ) => this.props.specActions.changeProducesValue([this.props.path, this.props.method], val)

View File

@@ -192,6 +192,8 @@ export default class Operation extends PureComponent {
operation={ operation } operation={ operation }
specActions={ specActions } specActions={ specActions }
specSelectors={ specSelectors } specSelectors={ specSelectors }
oas3Selectors={ oas3Selectors }
oas3Actions={ oas3Actions }
path={ path } path={ path }
method={ method } method={ method }
onExecute={ onExecute } /> onExecute={ onExecute } />

View File

@@ -187,6 +187,7 @@ export default class Parameters extends Component {
contentTypes={ requestBody.get("content", List()).keySeq() } contentTypes={ requestBody.get("content", List()).keySeq() }
onChange={(value) => { onChange={(value) => {
oas3Actions.setRequestContentType({ value, pathMethod }) oas3Actions.setRequestContentType({ value, pathMethod })
oas3Actions.initRequestBodyValidateError({ pathMethod })
}} }}
className="body-param-content-type" /> className="body-param-content-type" />
</label> </label>
@@ -197,6 +198,7 @@ export default class Parameters extends Component {
requestBody={requestBody} requestBody={requestBody}
requestBodyValue={oas3Selectors.requestBodyValue(...pathMethod)} requestBodyValue={oas3Selectors.requestBodyValue(...pathMethod)}
requestBodyInclusionSetting={oas3Selectors.requestBodyInclusionSetting(...pathMethod)} requestBodyInclusionSetting={oas3Selectors.requestBodyInclusionSetting(...pathMethod)}
requestBodyErrors={oas3Selectors.requestBodyErrors(...pathMethod)}
isExecute={isExecute} isExecute={isExecute}
activeExamplesKey={oas3Selectors.activeExamplesMember( activeExamplesKey={oas3Selectors.activeExamplesMember(
...pathMethod, ...pathMethod,

View File

@@ -8,6 +8,8 @@ export const UPDATE_ACTIVE_EXAMPLES_MEMBER = "oas3_set_active_examples_member"
export const UPDATE_REQUEST_CONTENT_TYPE = "oas3_set_request_content_type" export const UPDATE_REQUEST_CONTENT_TYPE = "oas3_set_request_content_type"
export const UPDATE_RESPONSE_CONTENT_TYPE = "oas3_set_response_content_type" export const UPDATE_RESPONSE_CONTENT_TYPE = "oas3_set_response_content_type"
export const UPDATE_SERVER_VARIABLE_VALUE = "oas3_set_server_variable_value" 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 function setSelectedServer (selectedServerUrl, namespace) { export function setSelectedServer (selectedServerUrl, namespace) {
return { return {
@@ -57,3 +59,24 @@ export function setServerVariableValue ({ server, namespace, key, val }) {
payload: { server, namespace, key, val } payload: { server, namespace, key, val }
} }
} }
export const setRequestBodyValidateError = ({ path, method, validationErrors }) => {
return {
type: SET_REQUEST_BODY_VALIDATE_ERROR,
payload: { path, method, validationErrors }
}
}
export const clearRequestBodyValidateError = ({ path, method }) => {
return {
type: CLEAR_REQUEST_BODY_VALIDATE_ERROR,
payload: { path, method }
}
}
export const initRequestBodyValidateError = ({ pathMethod } ) => {
return {
type: CLEAR_REQUEST_BODY_VALIDATE_ERROR,
payload: { path: pathMethod[0], method: pathMethod[1] }
}
}

View File

@@ -1,5 +1,6 @@
import React, { PureComponent } from "react" import React, { PureComponent } from "react"
import PropTypes from "prop-types" import PropTypes from "prop-types"
import cx from "classnames"
import { stringify } from "core/utils" import { stringify } from "core/utils"
const NOOP = Function.prototype const NOOP = Function.prototype
@@ -11,6 +12,7 @@ export default class RequestBodyEditor extends PureComponent {
getComponent: PropTypes.func.isRequired, getComponent: PropTypes.func.isRequired,
value: PropTypes.string, value: PropTypes.string,
defaultValue: PropTypes.string, defaultValue: PropTypes.string,
errors: PropTypes.array,
}; };
static defaultProps = { static defaultProps = {
@@ -74,19 +76,22 @@ export default class RequestBodyEditor extends PureComponent {
render() { render() {
let { let {
getComponent getComponent,
errors
} = this.props } = this.props
let { let {
value value
} = this.state } = this.state
let isInvalid = errors.size > 0 ? true : false
const TextArea = getComponent("TextArea") const TextArea = getComponent("TextArea")
return ( return (
<div className="body-param"> <div className="body-param">
<TextArea <TextArea
className={"body-param__text"} className={cx("body-param__text", { invalid: isInvalid } )}
title={errors.size ? errors.join(", ") : ""}
value={value} value={value}
onChange={ this.onDomChange } onChange={ this.onDomChange }
/> />

View File

@@ -38,6 +38,7 @@ const RequestBody = ({
requestBody, requestBody,
requestBodyValue, requestBodyValue,
requestBodyInclusionSetting, requestBodyInclusionSetting,
requestBodyErrors,
getComponent, getComponent,
getConfigs, getConfigs,
specSelectors, specSelectors,
@@ -88,6 +89,7 @@ const RequestBody = ({
const handleExamplesSelect = (key /*, { isSyntheticChange } */) => { const handleExamplesSelect = (key /*, { isSyntheticChange } */) => {
updateActiveExamplesKey(key) updateActiveExamplesKey(key)
} }
requestBodyErrors = List.isList(requestBodyErrors) ? requestBodyErrors : List()
if(!mediaTypeValue.size) { if(!mediaTypeValue.size) {
return null return null
@@ -138,7 +140,8 @@ const RequestBody = ({
const type = prop.get("type") const type = prop.get("type")
const format = prop.get("format") const format = prop.get("format")
const description = prop.get("description") const description = prop.get("description")
const currentValue = requestBodyValue.get(key) const currentValue = requestBodyValue.getIn([key, "value"])
const currentErrors = requestBodyValue.getIn([key, "errors"]) || requestBodyErrors
let initialValue = prop.get("default") || prop.get("example") || "" let initialValue = prop.get("default") || prop.get("example") || ""
@@ -179,6 +182,8 @@ const RequestBody = ({
description={key} description={key}
getComponent={getComponent} getComponent={getComponent}
value={currentValue === undefined ? initialValue : currentValue} value={currentValue === undefined ? initialValue : currentValue}
required = { required }
errors = { currentErrors }
onChange={(value) => { onChange={(value) => {
onChange(value, [key]) onChange(value, [key])
}} }}
@@ -223,6 +228,7 @@ const RequestBody = ({
<div> <div>
<RequestBodyEditor <RequestBodyEditor
value={requestBodyValue} value={requestBodyValue}
errors={requestBodyErrors}
defaultValue={getDefaultRequestBodyValue( defaultValue={getDefaultRequestBodyValue(
requestBody, requestBody,
contentType, contentType,
@@ -270,6 +276,7 @@ RequestBody.propTypes = {
requestBody: ImPropTypes.orderedMap.isRequired, requestBody: ImPropTypes.orderedMap.isRequired,
requestBodyValue: ImPropTypes.orderedMap.isRequired, requestBodyValue: ImPropTypes.orderedMap.isRequired,
requestBodyInclusionSetting: ImPropTypes.Map.isRequired, requestBodyInclusionSetting: ImPropTypes.Map.isRequired,
requestBodyErrors: ImPropTypes.list.isRequired,
getComponent: PropTypes.func.isRequired, getComponent: PropTypes.func.isRequired,
getConfigs: PropTypes.func.isRequired, getConfigs: PropTypes.func.isRequired,
fn: PropTypes.object.isRequired, fn: PropTypes.object.isRequired,

View File

@@ -1,3 +1,5 @@
import { fromJS, Map } from "immutable"
import { import {
UPDATE_SELECTED_SERVER, UPDATE_SELECTED_SERVER,
UPDATE_REQUEST_BODY_VALUE, UPDATE_REQUEST_BODY_VALUE,
@@ -5,7 +7,9 @@ import {
UPDATE_ACTIVE_EXAMPLES_MEMBER, UPDATE_ACTIVE_EXAMPLES_MEMBER,
UPDATE_REQUEST_CONTENT_TYPE, UPDATE_REQUEST_CONTENT_TYPE,
UPDATE_SERVER_VARIABLE_VALUE, UPDATE_SERVER_VARIABLE_VALUE,
UPDATE_RESPONSE_CONTENT_TYPE UPDATE_RESPONSE_CONTENT_TYPE,
SET_REQUEST_BODY_VALIDATE_ERROR,
CLEAR_REQUEST_BODY_VALIDATE_ERROR,
} from "./actions" } from "./actions"
export default { export default {
@@ -15,7 +19,27 @@ export default {
}, },
[UPDATE_REQUEST_BODY_VALUE]: (state, { payload: { value, pathMethod } } ) =>{ [UPDATE_REQUEST_BODY_VALUE]: (state, { payload: { value, pathMethod } } ) =>{
let [path, method] = pathMethod let [path, method] = pathMethod
if (!Map.isMap(value)) {
// context: application/json is always a String (instead of Map)
return state.setIn( [ "requestData", path, method, "bodyValue" ], value) return state.setIn( [ "requestData", path, method, "bodyValue" ], value)
}
let currentVal = state.getIn(["requestData", path, method, "bodyValue"]) || Map()
if (!Map.isMap(currentVal)) {
// context: user switch from application/json to application/x-www-form-urlencoded
currentVal = Map()
}
let newVal
const [...valueKeys] = value.keys()
valueKeys.forEach((valueKey) => {
let valueKeyVal = value.getIn([valueKey])
if (!currentVal.has(valueKey)) {
newVal = currentVal.setIn([valueKey, "value"], valueKeyVal)
} else if (!Map.isMap(valueKeyVal)) {
// context: user input will be received as String
newVal = currentVal.setIn([valueKey, "value"], valueKeyVal)
}
})
return state.setIn(["requestData", path, method, "bodyValue"], newVal)
}, },
[UPDATE_REQUEST_BODY_INCLUSION]: (state, { payload: { value, pathMethod, name } } ) =>{ [UPDATE_REQUEST_BODY_INCLUSION]: (state, { payload: { value, pathMethod, name } } ) =>{
let [path, method] = pathMethod let [path, method] = pathMethod
@@ -36,4 +60,38 @@ export default {
const path = namespace ? [ namespace, "serverVariableValues", server, key ] : [ "serverVariableValues", server, key ] const path = namespace ? [ namespace, "serverVariableValues", server, key ] : [ "serverVariableValues", server, key ]
return state.setIn(path, val) return state.setIn(path, val)
}, },
[SET_REQUEST_BODY_VALIDATE_ERROR]: (state, { payload: { path, method, validationErrors } } ) => {
let errors = []
errors.push("Required field is not provided")
if (validationErrors.missingBodyValue) {
// context: is application/json or application/xml, where typeof (missing) bodyValue = String
return state.setIn(["requestData", path, method, "errors"], fromJS(errors))
}
if (validationErrors.missingRequiredKeys && validationErrors.missingRequiredKeys.length > 0) {
// context: is application/x-www-form-urlencoded, with list of missing keys
const { missingRequiredKeys } = validationErrors
return state.updateIn(["requestData", path, method, "bodyValue"], fromJS({}), missingKeyValues => {
return missingRequiredKeys.reduce((bodyValue, currentMissingKey) => {
return bodyValue.setIn([currentMissingKey, "errors"], fromJS(errors))
}, missingKeyValues)
})
}
console.warn("unexpected result: SET_REQUEST_BODY_VALIDATE_ERROR")
return state
},
[CLEAR_REQUEST_BODY_VALIDATE_ERROR]: (state, { payload: { path, method } }) => {
const requestBodyValue = state.getIn(["requestData", path, method, "bodyValue"])
if (!Map.isMap(requestBodyValue)) {
return state.setIn(["requestData", path, method, "errors"], fromJS([]))
}
const [...valueKeys] = requestBodyValue.keys()
if (!valueKeys) {
return state
}
return state.updateIn(["requestData", path, method, "bodyValue"], fromJS({}), bodyValues => {
return valueKeys.reduce((bodyValue, curr) => {
return bodyValue.setIn([curr, "errors"], fromJS([]))
}, bodyValues)
})
},
} }

View File

@@ -1,7 +1,6 @@
import { OrderedMap, Map } from "immutable" import { OrderedMap, Map } from "immutable"
import { isOAS3 as isOAS3Helper } from "./helpers" import { isOAS3 as isOAS3Helper } from "./helpers"
// Helpers // Helpers
function onlyOAS3(selector) { function onlyOAS3(selector) {
@@ -15,6 +14,35 @@ function onlyOAS3(selector) {
} }
} }
function validateRequestBodyIsRequired(selector) {
return (...args) => (system) => {
const specJson = system.getSystem().specSelectors.specJson()
const argsList = [...args]
// expect argsList[0] = state
let pathMethod = argsList[1] || []
let isOas3RequestBodyRequired = specJson.getIn(["paths", ...pathMethod, "requestBody", "required"])
if (isOas3RequestBodyRequired) {
return selector(...args)
} else {
// validation pass b/c not required
return true
}
}
}
const validateRequestBodyValueExists = (state, pathMethod) => {
pathMethod = pathMethod || []
let oas3RequestBodyValue = state.getIn(["requestData", ...pathMethod, "bodyValue"])
// context: bodyValue can be a String, or a Map
if (!oas3RequestBodyValue) {
return false
}
// validation pass if String is not empty, or if Map exists
return true
}
export const selectedServer = onlyOAS3((state, namespace) => { export const selectedServer = onlyOAS3((state, namespace) => {
const path = namespace ? [namespace, "selectedServer"] : ["selectedServer"] const path = namespace ? [namespace, "selectedServer"] : ["selectedServer"]
return state.getIn(path) || "" return state.getIn(path) || ""
@@ -31,6 +59,11 @@ export const requestBodyInclusionSetting = onlyOAS3((state, path, method) => {
} }
) )
export const requestBodyErrors = onlyOAS3((state, path, method) => {
return state.getIn(["requestData", path, method, "errors"]) || null
}
)
export const activeExamplesMember = onlyOAS3((state, path, method, type, name) => { export const activeExamplesMember = onlyOAS3((state, path, method, type, name) => {
return state.getIn(["examples", path, method, type, name, "activeExample"]) || null return state.getIn(["examples", path, method, type, name, "activeExample"]) || null
} }
@@ -116,3 +149,34 @@ export const serverEffectiveValue = onlyOAS3((state, locationData) => {
return str return str
} }
) )
export const validateBeforeExecute = validateRequestBodyIsRequired(
(state, pathMethod) => validateRequestBodyValueExists(state, pathMethod)
)
export const validateShallowRequired = ( state, {oas3RequiredRequestBodyContentType, oas3RequestBodyValue} ) => {
let missingRequiredKeys = []
// context: json => String; urlencoded => 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
Object.keys(oas3RequiredRequestBodyContentType.requestContentType).forEach((contentType) => {
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"])
if (!requiredKeyValue) {
missingRequiredKeys.push(key)
}
})
return missingRequiredKeys
}

View File

@@ -406,7 +406,19 @@ export const executeRequest = (req) =>
if(isJSONObject(requestBody)) { if(isJSONObject(requestBody)) {
req.requestBody = JSON.parse(requestBody) req.requestBody = JSON.parse(requestBody)
} else if(requestBody && requestBody.toJS) { } else if(requestBody && requestBody.toJS) {
req.requestBody = requestBody.filter((value, key) => !isEmptyValue(value) || requestBodyInclusionSetting.get(key)).toJS() req.requestBody = requestBody
.map(
(val) => {
if (Map.isMap(val)) {
return val.get("value")
}
return val
}
)
.filter(
(value, key) => !isEmptyValue(value) || requestBodyInclusionSetting.get(key)
)
.toJS()
} else{ } else{
req.requestBody = requestBody req.requestBody = requestBody
} }

View File

@@ -314,7 +314,6 @@ export const parameterWithMetaByIdentity = (state, pathMethod, param) => {
hashKeyedMeta hashKeyedMeta
) )
}) })
return mergedParams.find(curr => curr.get("in") === param.get("in") && curr.get("name") === param.get("name"), OrderedMap()) return mergedParams.find(curr => curr.get("in") === param.get("in") && curr.get("name") === param.get("name"), OrderedMap())
} }
@@ -327,7 +326,6 @@ export const parameterInclusionSettingFor = (state, pathMethod, paramName, param
export const parameterWithMeta = (state, pathMethod, paramName, paramIn) => { export const parameterWithMeta = (state, pathMethod, paramName, paramIn) => {
const opParams = specJsonWithResolvedSubtrees(state).getIn(["paths", ...pathMethod, "parameters"], OrderedMap()) const opParams = specJsonWithResolvedSubtrees(state).getIn(["paths", ...pathMethod, "parameters"], OrderedMap())
const currentParam = opParams.find(param => param.get("in") === paramIn && param.get("name") === paramName, OrderedMap()) const currentParam = opParams.find(param => param.get("in") === paramIn && param.get("name") === paramName, OrderedMap())
return parameterWithMetaByIdentity(state, pathMethod, currentParam) return parameterWithMetaByIdentity(state, pathMethod, currentParam)
} }
@@ -364,7 +362,6 @@ export const hasHost = createSelector(
// Get the parameter values, that the user filled out // Get the parameter values, that the user filled out
export function parameterValues(state, pathMethod, isXml) { export function parameterValues(state, pathMethod, isXml) {
pathMethod = pathMethod || [] pathMethod = pathMethod || []
// let paramValues = state.getIn(["meta", "paths", ...pathMethod, "parameters"], fromJS([]))
let paramValues = operationWithMeta(state, ...pathMethod).get("parameters", List()) let paramValues = operationWithMeta(state, ...pathMethod).get("parameters", List())
return paramValues.reduce( (hash, p) => { return paramValues.reduce( (hash, p) => {
let value = isXml && p.get("in") === "body" ? p.get("value_xml") : p.get("value") let value = isXml && p.get("in") === "body" ? p.get("value_xml") : p.get("value")
@@ -495,6 +492,28 @@ export const validateBeforeExecute = ( state, pathMethod ) => {
return isValid return isValid
} }
export const getOAS3RequiredRequestBodyContentType = (state, pathMethod) => {
let requiredObj = {
requestBody: false,
requestContentType: {}
}
let requestBody = state.getIn(["resolvedSubtrees", "paths", ...pathMethod, "requestBody"], fromJS([]))
if (requestBody.size < 1) {
return requiredObj
}
if (requestBody.getIn(["required"])) {
requiredObj.requestBody = requestBody.getIn(["required"])
}
requestBody.getIn(["content"]).entrySeq().forEach((contentType) => { // e.g application/json
const key = contentType[0]
if (contentType[1].getIn(["schema", "required"])) {
const val = contentType[1].getIn(["schema", "required"]).toJS()
requiredObj.requestContentType[key] = val
}
})
return requiredObj
}
function returnSelfOrNewMap(obj) { function returnSelfOrNewMap(obj) {
// returns obj if obj is an Immutable map, else returns a new Map // returns obj if obj is an Immutable map, else returns a new Map
return Map.isMap(obj) ? obj : new Map() return Map.isMap(obj) ? obj : new Map()

View File

@@ -0,0 +1,27 @@
info:
title: Required parameter missing, doesn't block request from executing.
version: '1'
openapi: 3.0.0
servers:
- url: http://httpbin.org/anything
paths:
/foos:
post:
requestBody:
content:
application/x-www-form-urlencoded:
# application/json:
# application/xml:
schema:
properties:
foo:
type: string
bar:
type: string
required:
- foo
type: object
required: true # Note this doesn't have an impact
responses:
default:
description: ok

View File

@@ -67,6 +67,9 @@ describe("OpenAPI 3.0 Allow Empty Values in Request Body", () => {
// Expand Try It Out // Expand Try It Out
.get(".try-out__btn") .get(".try-out__btn")
.click() .click()
// add item to pass required validation
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(4) > .parameters-col_description button")
.click()
// Execute // Execute
.get(".execute.opblock-control__btn") .get(".execute.opblock-control__btn")
.click() .click()
@@ -91,6 +94,9 @@ describe("OpenAPI 3.0 Allow Empty Values in Request Body", () => {
// Request Body // Request Body
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(5) > .parameters-col_description .parameter__empty_value_toggle input") .get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(5) > .parameters-col_description .parameter__empty_value_toggle input")
.uncheck() .uncheck()
// add item to pass required validation
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(4) > .parameters-col_description button")
.click()
// Execute // Execute
.get(".execute.opblock-control__btn") .get(".execute.opblock-control__btn")
.click() .click()
@@ -118,6 +124,9 @@ describe("OpenAPI 3.0 Allow Empty Values in Request Body", () => {
.uncheck() .uncheck()
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(6) > .parameters-col_description .parameter__empty_value_toggle input") .get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(6) > .parameters-col_description .parameter__empty_value_toggle input")
.uncheck() .uncheck()
// add item to pass required validation
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(4) > .parameters-col_description button")
.click()
// Execute // Execute
.get(".execute.opblock-control__btn") .get(".execute.opblock-control__btn")
.click() .click()

View File

@@ -0,0 +1,214 @@
/**
* @prettier
*/
describe("OpenAPI 3.0 Validation for Required Request Body and Request Body Fields", () => {
describe("Request Body required bug/5181", () => {
it("on execute, if empty value, SHOULD render class 'invalid' and should NOT render cURL component", () => {
cy.visit(
"/?url=/documents/bugs/5181.yaml"
)
.get("#operations-default-post_foos")
.click()
// Expand Try It Out
.get(".try-out__btn")
.click()
// get input
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(1) > .parameters-col_description input")
.should("not.have.class", "invalid")
// Execute
.get(".execute.opblock-control__btn")
.click()
// class "invalid" should now exist (and render red, which we won't check)
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(1) > .parameters-col_description input")
.should("have.class", "invalid")
// cURL component should not exist
.get(".responses-wrapper .copy-paste")
.should("not.exist")
})
it("on execute, if value exists, should NOT render class 'invalid' and SHOULD render cURL component", () => {
cy.visit(
"/?url=/documents/bugs/5181.yaml"
)
.get("#operations-default-post_foos")
.click()
// Expand Try It Out
.get(".try-out__btn")
.click()
// get input
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(1) > .parameters-col_description input")
.type("abc")
// Execute
.get(".execute.opblock-control__btn")
.click()
.should("not.have.class", "invalid")
// cURL component should exist
.get(".responses-wrapper .copy-paste")
.should("exist")
})
})
describe("Request Body required fields - application/json", () => {
it("on execute, if empty value, SHOULD render class 'invalid' and should NOT render cURL component", () => {
cy.visit(
"/?url=/documents/features/petstore-only-pet.openapi.yaml"
)
.get("#operations-pet-addPet")
.click()
// Expand Try It Out
.get(".try-out__btn")
.click()
// get and clear textarea
.get(".opblock-body .opblock-section .opblock-section-request-body .body-param textarea")
.should("not.have.class", "invalid")
.clear()
// Execute
.get(".execute.opblock-control__btn")
.click()
// class "invalid" should now exist (and render red, which we won't check)
.get(".opblock-body .opblock-section .opblock-section-request-body .body-param textarea")
.should("have.class", "invalid")
// cURL component should not exist
.get(".responses-wrapper .copy-paste")
.should("not.exist")
})
it("on execute, if value exists, even if just single space, should NOT render class 'invalid' and SHOULD render cURL component that contains the single space", () => {
cy.visit(
"/?url=/documents/features/petstore-only-pet.openapi.yaml"
)
.get("#operations-pet-addPet")
.click()
// Expand Try It Out
.get(".try-out__btn")
.click()
// get, clear, then modify textarea
.get(".opblock-body .opblock-section .opblock-section-request-body .body-param textarea")
.clear()
.type(" ")
// Execute
.get(".execute.opblock-control__btn")
.click()
.get(".opblock-body .opblock-section .opblock-section-request-body .body-param textarea")
.should("not.have.class", "invalid")
// cURL component should exist
.get(".responses-wrapper .copy-paste")
.should("exist")
.get(".responses-wrapper .copy-paste textarea")
.should("contains.text", "-d \" \"")
})
})
/*
petstore ux notes:
- required field, but if example value exists, will populate the field. So this test will clear the example value.
- "add item" will insert an empty array, and display an input text box. This establishes a value for the field.
*/
describe("Request Body required fields - application/x-www-form-urlencoded", () => {
it("on execute, if empty value, SHOULD render class 'invalid' and should NOT render cURL component", () => {
cy.visit(
"/?url=/documents/features/petstore-only-pet.openapi.yaml"
)
.get("#operations-pet-addPet")
.click()
.get(".opblock-section .opblock-section-request-body .body-param-content-type > select")
.select("application/x-www-form-urlencoded")
// Expand Try It Out
.get(".try-out__btn")
.click()
// get and clear input populated from example value
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(2) > .parameters-col_description input")
.clear()
// Execute
.get(".execute.opblock-control__btn")
.click()
// class "invalid" should now exist (and render red, which we won't check)
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(2) > .parameters-col_description input")
.should("have.class", "invalid")
// cURL component should not exist
.get(".responses-wrapper .copy-paste")
.should("not.exist")
})
it("on execute, if all values exist, even if array exists but is empty, should NOT render class 'invalid' and SHOULD render cURL component", () => {
cy.visit(
"/?url=/documents/features/petstore-only-pet.openapi.yaml"
)
.get("#operations-pet-addPet")
.click()
.get(".opblock-section .opblock-section-request-body .body-param-content-type > select")
.select("application/x-www-form-urlencoded")
// Expand Try It Out
.get(".try-out__btn")
.click()
// add item to get input
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(4) > .parameters-col_description button")
.click()
// Execute
.get(".execute.opblock-control__btn")
.click()
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(2) > .parameters-col_description input")
.should("have.value", "doggie")
.should("not.have.class", "invalid")
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(4) > .parameters-col_description input")
.should("have.value", "")
.should("not.have.class", "invalid")
// cURL component should exist
.get(".responses-wrapper .copy-paste")
.should("exist")
})
})
describe("Request Body: switching between Content Types", () => {
it("after application/json 'invalid' error, on switch content type to application/x-www-form-urlencoded, SHOULD be free of errors", () => {
cy.visit(
"/?url=/documents/features/petstore-only-pet.openapi.yaml"
)
.get("#operations-pet-addPet")
.click()
// Expand Try It Out
.get(".try-out__btn")
.click()
// get and clear textarea
.get(".opblock-body .opblock-section .opblock-section-request-body .body-param textarea")
.should("not.have.class", "invalid")
.clear()
// Execute
.get(".execute.opblock-control__btn")
.click()
.get(".opblock-body .opblock-section .opblock-section-request-body .body-param textarea")
.should("have.class", "invalid")
// switch content type
.get(".opblock-section .opblock-section-request-body .body-param-content-type > select")
.select("application/x-www-form-urlencoded")
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(2) > .parameters-col_description input")
.should("not.have.class", "invalid")
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(4) > .parameters-col_description input")
.should("not.have.class", "invalid")
})
it("after application/x-www-form-urlencoded 'invalid' error, on switch content type to application/json, SHOULD be free of errors", () => {
cy.visit(
"/?url=/documents/features/petstore-only-pet.openapi.yaml"
)
.get("#operations-pet-addPet")
.click()
.get(".opblock-section .opblock-section-request-body .body-param-content-type > select")
.select("application/x-www-form-urlencoded")
// Expand Try It Out
.get(".try-out__btn")
.click()
// get and clear input
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(2) > .parameters-col_description input")
.clear()
// Execute
.get(".execute.opblock-control__btn")
.click()
// class "invalid" should now exist (and render red, which we won't check)
.get(".opblock-body .opblock-section .opblock-section-request-body .parameters:nth-child(2) > .parameters-col_description input")
.should("have.class", "invalid")
// switch content type
.get(".opblock-section .opblock-section-request-body .body-param-content-type > select")
.select("application/json")
.get(".opblock-body .opblock-section .opblock-section-request-body .body-param textarea")
.should("not.have.class", "invalid")
})
})
})

View File

@@ -0,0 +1,508 @@
/* eslint-env mocha */
import expect from "expect"
import { fromJS } from "immutable"
import reducer from "corePlugins/oas3/reducers"
describe("oas3 plugin - reducer", function () {
describe("SET_REQUEST_BODY_VALIDATE_ERROR", () => {
const setRequestBodyValidateError = reducer["oas3_set_request_body_validate_error"]
describe("missingBodyValue exists, e.g. application/json", () => {
it("should set errors", () => {
const state = fromJS({
requestData: {
"/pet": {
post: {
bodyValue: "",
requestContentType: "application/json"
}
}
}
})
const result = setRequestBodyValidateError(state, {
payload: {
path: "/pet",
method: "post",
validationErrors: {
missingBodyValue: true,
missingRequiredKeys: []
},
}
})
const expectedResult = {
requestData: {
"/pet": {
post: {
bodyValue: "",
requestContentType: "application/json",
errors: ["Required field is not provided"]
}
}
}
}
expect(result.toJS()).toEqual(expectedResult)
})
})
describe("missingRequiredKeys exists with length, e.g. application/x-www-form-urleconded", () => {
it("should set nested errors", () => {
const state = fromJS({
requestData: {
"/pet": {
post: {
bodyValue: {
id: {
value: "10",
},
name: {
value: "",
},
},
requestContentType: "application/x-www-form-urlencoded"
}
}
}
})
const result = setRequestBodyValidateError(state, {
payload: {
path: "/pet",
method: "post",
validationErrors: {
missingBodyValue: null,
missingRequiredKeys: ["name"]
},
}
})
const expectedResult = {
requestData: {
"/pet": {
post: {
bodyValue: {
id: {
value: "10",
},
name: {
value: "",
errors: ["Required field is not provided"]
},
},
requestContentType: "application/x-www-form-urlencoded",
}
}
}
}
expect(result.toJS()).toEqual(expectedResult)
})
it("should overwrite nested errors, for keys listed in missingRequiredKeys", () => {
const state = fromJS({
requestData: {
"/pet": {
post: {
bodyValue: {
id: {
value: "10",
},
name: {
value: "",
errors: ["some fake error"]
},
},
requestContentType: "application/x-www-form-urlencoded"
}
}
}
})
const result = setRequestBodyValidateError(state, {
payload: {
path: "/pet",
method: "post",
validationErrors: {
missingBodyValue: null,
missingRequiredKeys: ["name"]
},
}
})
const expectedResult = {
requestData: {
"/pet": {
post: {
bodyValue: {
id: {
value: "10",
},
name: {
value: "",
errors: ["Required field is not provided"]
},
},
requestContentType: "application/x-www-form-urlencoded",
}
}
}
}
expect(result.toJS()).toEqual(expectedResult)
})
it("should not overwrite nested errors, for keys not listed in missingRequiredKeys", () => {
const state = fromJS({
requestData: {
"/pet": {
post: {
bodyValue: {
id: {
value: "10",
errors: ["random error should not be overwritten"]
},
name: {
value: "",
errors: ["some fake error"]
},
},
requestContentType: "application/x-www-form-urlencoded"
}
}
}
})
const result = setRequestBodyValidateError(state, {
payload: {
path: "/pet",
method: "post",
validationErrors: {
missingBodyValue: null,
missingRequiredKeys: ["name"]
},
}
})
const expectedResult = {
requestData: {
"/pet": {
post: {
bodyValue: {
id: {
value: "10",
errors: ["random error should not be overwritten"]
},
name: {
value: "",
errors: ["Required field is not provided"]
},
},
requestContentType: "application/x-www-form-urlencoded",
}
}
}
}
expect(result.toJS()).toEqual(expectedResult)
})
it("should set multiple nested errors", () => {
const state = fromJS({
requestData: {
"/pet": {
post: {
bodyValue: {
id: {
value: "",
},
name: {
value: "",
},
},
requestContentType: "application/x-www-form-urlencoded"
}
}
}
})
const result = setRequestBodyValidateError(state, {
payload: {
path: "/pet",
method: "post",
validationErrors: {
missingBodyValue: null,
missingRequiredKeys: ["id", "name"]
},
}
})
const expectedResult = {
requestData: {
"/pet": {
post: {
bodyValue: {
id: {
value: "",
errors: ["Required field is not provided"]
},
name: {
value: "",
errors: ["Required field is not provided"]
},
},
requestContentType: "application/x-www-form-urlencoded",
}
}
}
}
expect(result.toJS()).toEqual(expectedResult)
})
})
describe("missingRequiredKeys is empty list", () => {
it("should not set any errors, and return state unchanged", () => {
const state = fromJS({
requestData: {
"/pet": {
post: {
bodyValue: {
id: {
value: "10",
},
name: {
value: "",
},
},
requestContentType: "application/x-www-form-urlencoded"
}
}
}
})
const result = setRequestBodyValidateError(state, {
payload: {
path: "/pet",
method: "post",
validationErrors: {
missingBodyValue: null,
missingRequiredKeys: []
},
}
})
const expectedResult = {
requestData: {
"/pet": {
post: {
bodyValue: {
id: {
value: "10",
},
name: {
value: "",
},
},
requestContentType: "application/x-www-form-urlencoded",
}
}
}
}
expect(result.toJS()).toEqual(expectedResult)
})
})
describe("other unexpected payload, e.g. no missingBodyValue or missingRequiredKeys", () => {
it("should not throw error if receiving unexpected validationError format. return state unchanged", () => {
const state = fromJS({
requestData: {
"/pet": {
post: {
bodyValue: {
id: {
value: "10",
},
name: {
value: "",
},
},
requestContentType: "application/x-www-form-urlencoded"
}
}
}
})
const result = setRequestBodyValidateError(state, {
payload: {
path: "/pet",
method: "post",
validationErrors: {
missingBodyValue: null,
// missingRequiredKeys: ["none provided"]
},
}
})
const expectedResult = {
requestData: {
"/pet": {
post: {
bodyValue: {
id: {
value: "10",
},
name: {
value: "",
},
},
requestContentType: "application/x-www-form-urlencoded",
}
}
}
}
expect(result.toJS()).toEqual(expectedResult)
})
})
})
describe("CLEAR_REQUEST_BODY_VALIDATE_ERROR", function() {
const clearRequestBodyValidateError = reducer["oas3_clear_request_body_validate_error"]
describe("bodyValue is String, e.g. application/json", () => {
it("should clear errors", () => {
const state = fromJS({
requestData: {
"/pet": {
post: {
bodyValue: "{}",
requestContentType: "application/json"
}
}
}
})
const result = clearRequestBodyValidateError(state, {
payload: {
path: "/pet",
method: "post",
}
})
const expectedResult = {
requestData: {
"/pet": {
post: {
bodyValue: "{}",
requestContentType: "application/json",
errors: []
}
}
}
}
expect(result.toJS()).toEqual(expectedResult)
})
})
describe("bodyValue is Map with entries, e.g. application/x-www-form-urleconded", () => {
it("should clear nested errors, and apply empty error list to all entries", () => {
const state = fromJS({
requestData: {
"/pet": {
post: {
bodyValue: {
id: {
value: "10",
errors: ["some random error"]
},
name: {
value: "doggie",
errors: ["Required field is not provided"]
},
status: {
value: "available"
}
},
requestContentType: "application/x-www-form-urlencoded"
}
}
}
})
const result = clearRequestBodyValidateError(state, {
payload: {
path: "/pet",
method: "post",
}
})
const expectedResult = {
requestData: {
"/pet": {
post: {
bodyValue: {
id: {
value: "10",
errors: [],
},
name: {
value: "doggie",
errors: [],
},
status: {
value: "available",
errors: [],
},
},
requestContentType: "application/x-www-form-urlencoded",
}
}
}
}
expect(result.toJS()).toEqual(expectedResult)
})
})
describe("bodyValue is empty Map", () => {
it("should return state unchanged", () => {
const state = fromJS({
requestData: {
"/pet": {
post: {
bodyValue: {},
requestContentType: "application/x-www-form-urlencoded"
}
}
}
})
const result = clearRequestBodyValidateError(state, {
payload: {
path: "/pet",
method: "post",
}
})
const expectedResult = {
requestData: {
"/pet": {
post: {
bodyValue: {
},
requestContentType: "application/x-www-form-urlencoded",
}
}
}
}
expect(result.toJS()).toEqual(expectedResult)
})
})
})
})