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,
path: PropTypes.string.isRequired,
method: PropTypes.string.isRequired,
oas3Selectors: PropTypes.object.isRequired,
oas3Actions: PropTypes.object.isRequired,
onExecute: PropTypes.func
}
onClick=()=>{
let { specSelectors, specActions, operation, path, method } = this.props
handleValidateParameters = () => {
let { specSelectors, specActions, path, method } = this.props
specActions.validateParams([path, method])
return specSelectors.validateBeforeExecute([path, method])
}
specActions.validateParams( [path, method] )
if ( specSelectors.validateBeforeExecute([path, method]) ) {
if(this.props.onExecute) {
this.props.onExecute()
}
specActions.execute( { operation, path, method } )
} else {
// deferred by 40ms, to give element class change time to settle.
specActions.clearValidateParams( [path, method] )
setTimeout(() => {
specActions.validateParams([path, method])
}, 40)
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 (!oas3ValidateBeforeExecuteSuccess) {
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()
}
specActions.execute({ operation, path, method })
}
handleValidationResultFail = () => {
let { specActions, path, method } = this.props
// deferred by 40ms, to give element class change time to settle.
specActions.clearValidateParams([path, method])
setTimeout(() => {
specActions.validateParams([path, method])
}, 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)

View File

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

View File

@@ -187,6 +187,7 @@ export default class Parameters extends Component {
contentTypes={ requestBody.get("content", List()).keySeq() }
onChange={(value) => {
oas3Actions.setRequestContentType({ value, pathMethod })
oas3Actions.initRequestBodyValidateError({ pathMethod })
}}
className="body-param-content-type" />
</label>
@@ -197,6 +198,7 @@ export default class Parameters extends Component {
requestBody={requestBody}
requestBodyValue={oas3Selectors.requestBodyValue(...pathMethod)}
requestBodyInclusionSetting={oas3Selectors.requestBodyInclusionSetting(...pathMethod)}
requestBodyErrors={oas3Selectors.requestBodyErrors(...pathMethod)}
isExecute={isExecute}
activeExamplesKey={oas3Selectors.activeExamplesMember(
...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_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 function setSelectedServer (selectedServerUrl, namespace) {
return {
@@ -57,3 +59,24 @@ export function setServerVariableValue ({ 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 PropTypes from "prop-types"
import cx from "classnames"
import { stringify } from "core/utils"
const NOOP = Function.prototype
@@ -11,6 +12,7 @@ export default class RequestBodyEditor extends PureComponent {
getComponent: PropTypes.func.isRequired,
value: PropTypes.string,
defaultValue: PropTypes.string,
errors: PropTypes.array,
};
static defaultProps = {
@@ -74,19 +76,22 @@ export default class RequestBodyEditor extends PureComponent {
render() {
let {
getComponent
getComponent,
errors
} = this.props
let {
value
} = this.state
let isInvalid = errors.size > 0 ? true : false
const TextArea = getComponent("TextArea")
return (
<div className="body-param">
<TextArea
className={"body-param__text"}
className={cx("body-param__text", { invalid: isInvalid } )}
title={errors.size ? errors.join(", ") : ""}
value={value}
onChange={ this.onDomChange }
/>

View File

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

View File

@@ -1,3 +1,5 @@
import { fromJS, Map } from "immutable"
import {
UPDATE_SELECTED_SERVER,
UPDATE_REQUEST_BODY_VALUE,
@@ -5,7 +7,9 @@ import {
UPDATE_ACTIVE_EXAMPLES_MEMBER,
UPDATE_REQUEST_CONTENT_TYPE,
UPDATE_SERVER_VARIABLE_VALUE,
UPDATE_RESPONSE_CONTENT_TYPE
UPDATE_RESPONSE_CONTENT_TYPE,
SET_REQUEST_BODY_VALIDATE_ERROR,
CLEAR_REQUEST_BODY_VALIDATE_ERROR,
} from "./actions"
export default {
@@ -15,7 +19,27 @@ export default {
},
[UPDATE_REQUEST_BODY_VALUE]: (state, { payload: { value, pathMethod } } ) =>{
let [path, method] = pathMethod
return state.setIn( [ "requestData", path, method, "bodyValue" ], value)
if (!Map.isMap(value)) {
// context: application/json is always a String (instead of Map)
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 } } ) =>{
let [path, method] = pathMethod
@@ -36,4 +60,38 @@ export default {
const path = namespace ? [ namespace, "serverVariableValues", server, key ] : [ "serverVariableValues", server, key ]
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 { isOAS3 as isOAS3Helper } from "./helpers"
// Helpers
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) => {
const path = namespace ? [namespace, "selectedServer"] : ["selectedServer"]
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) => {
return state.getIn(["examples", path, method, type, name, "activeExample"]) || null
}
@@ -116,3 +149,34 @@ export const serverEffectiveValue = onlyOAS3((state, locationData) => {
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)) {
req.requestBody = JSON.parse(requestBody)
} 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{
req.requestBody = requestBody
}

View File

@@ -314,7 +314,6 @@ export const parameterWithMetaByIdentity = (state, pathMethod, param) => {
hashKeyedMeta
)
})
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) => {
const opParams = specJsonWithResolvedSubtrees(state).getIn(["paths", ...pathMethod, "parameters"], OrderedMap())
const currentParam = opParams.find(param => param.get("in") === paramIn && param.get("name") === paramName, OrderedMap())
return parameterWithMetaByIdentity(state, pathMethod, currentParam)
}
@@ -364,7 +362,6 @@ export const hasHost = createSelector(
// Get the parameter values, that the user filled out
export function parameterValues(state, pathMethod, isXml) {
pathMethod = pathMethod || []
// let paramValues = state.getIn(["meta", "paths", ...pathMethod, "parameters"], fromJS([]))
let paramValues = operationWithMeta(state, ...pathMethod).get("parameters", List())
return paramValues.reduce( (hash, p) => {
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
}
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) {
// returns obj if obj is an Immutable map, else returns a new Map
return Map.isMap(obj) ? obj : new Map()