diff --git a/README.md b/README.md index c31f0a5e..689c6132 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ parameterMacro | MUST be a function. Function to set default value to parameters modelPropertyMacro | MUST be a function. Function to set default values to each property in model. Accepts one argument modelPropertyMacro(property), property is immutable docExpansion | Controls the default expansion setting for the operations and tags. It can be 'list' (expands only the tags), 'full' (expands the tags and operations) or 'none' (expands nothing). The default is 'list'. displayOperationId | Controls the display of operationId in operations list. The default is `false`. +displayRequestDuration | Controls the display of the request duration (in milliseconds) for `Try it out` requests. The default is `false`. ### Plugins diff --git a/src/core/components/live-response.jsx b/src/core/components/live-response.jsx index bb61e252..2a52d7bf 100644 --- a/src/core/components/live-response.jsx +++ b/src/core/components/live-response.jsx @@ -9,28 +9,40 @@ const Headers = ( { headers } )=>{
{headers}
) } - Headers.propTypes = { headers: PropTypes.array.isRequired } +const Duration = ( { duration } ) => { + return ( +
+
Request duration
+
{duration} ms
+
+ ) +} +Duration.propTypes = { + duration: PropTypes.number.isRequired +} + + export default class LiveResponse extends React.Component { static propTypes = { response: PropTypes.object.isRequired, - getComponent: PropTypes.func.isRequired + getComponent: PropTypes.func.isRequired, + displayRequestDuration: PropTypes.bool.isRequired } render() { - const { request, response, getComponent } = this.props + const { request, response, getComponent, displayRequestDuration } = this.props const status = response.get("status") const url = response.get("url") const headers = response.get("headers").toJS() const notDocumented = response.get("notDocumented") const isError = response.get("error") - - const body = isError ? response.get("response").get("text") : response.get("text") - + const body = response.get("text") + const duration = response.get("duration") const headersKeys = Object.keys(headers) const contentType = headers["content-type"] @@ -81,6 +93,9 @@ export default class LiveResponse extends React.Component { { hasHeaders ? : null } + { + displayRequestDuration && duration ? : null + } diff --git a/src/core/components/model.jsx b/src/core/components/model.jsx index f2b2e99d..de714403 100644 --- a/src/core/components/model.jsx +++ b/src/core/components/model.jsx @@ -118,12 +118,13 @@ class ObjectModel extends Component { class Primitive extends Component { static propTypes = { schema: PropTypes.object.isRequired, + name: PropTypes.string, getComponent: PropTypes.func.isRequired, required: PropTypes.bool } render(){ - let { schema, getComponent, required } = this.props + let { schema, getComponent, name, required } = this.props if(!schema || !schema.get) { // don't render if schema isn't correctly formed @@ -134,12 +135,18 @@ class Primitive extends Component { let format = schema.get("format") let xml = schema.get("xml") let enumArray = schema.get("enum") + let title = schema.get("title") || name let description = schema.get("description") let properties = schema.filter( ( v, key) => ["enum", "type", "format", "description", "$$ref"].indexOf(key) === -1 ) let style = required ? { fontWeight: "bold" } : {} const Markdown = getComponent("Markdown") - return + return + { + title && + { title } + + } { type } { required && *} { format && (${format})} { @@ -177,17 +184,20 @@ class ArrayModel extends Component { } render(){ - let { required, schema, depth, expandDepth } = this.props + let { required, schema, depth, name, expandDepth } = this.props let items = schema.get("items") + let title = schema.get("title") || name let properties = schema.filter( ( v, key) => ["type", "items", "$$ref"].indexOf(key) === -1 ) - + return - - { schema.get("title") } - + { + title && + { title } + + } expandDepth } collapsedContent="[...]"> [ - + ] { properties.size ? @@ -250,13 +260,13 @@ class Model extends Component { name={ name || modelName } isRef={ isRef!== undefined ? isRef : !!$$ref }/> case "array": - return + return case "string": case "number": case "integer": case "boolean": default: - return + return } } } diff --git a/src/core/components/operation.jsx b/src/core/components/operation.jsx index 50552ddc..52807722 100644 --- a/src/core/components/operation.jsx +++ b/src/core/components/operation.jsx @@ -18,6 +18,7 @@ export default class Operation extends PureComponent { allowTryItOut: PropTypes.bool, displayOperationId: PropTypes.bool, + displayRequestDuration: PropTypes.bool, response: PropTypes.object, request: PropTypes.object, @@ -38,6 +39,7 @@ export default class Operation extends PureComponent { response: null, allowTryItOut: true, displayOperationId: false, + displayRequestDuration: false } constructor(props, context) { @@ -108,7 +110,7 @@ export default class Operation extends PureComponent { request, allowTryItOut, displayOperationId, - + displayRequestDuration, fn, getComponent, specActions, @@ -127,6 +129,7 @@ export default class Operation extends PureComponent { let schemes = operation.get("schemes") let parameters = getList(operation, ["parameters"]) let operationId = operation.get("__originalOperationId") + let operationScheme = specSelectors.operationScheme(path, method) const Responses = getComponent("responses") const Parameters = getComponent( "parameters" ) @@ -212,7 +215,8 @@ export default class Operation extends PureComponent { + specActions={ specActions } + operationScheme={ operationScheme } /> : null } @@ -251,6 +255,7 @@ export default class Operation extends PureComponent { produces={ produces } producesValue={ operation.get("produces_value") } pathMethod={ [path, method] } + displayRequestDuration={ displayRequestDuration } fn={fn} /> } diff --git a/src/core/components/operations.jsx b/src/core/components/operations.jsx index 7275eea2..6c20d963 100644 --- a/src/core/components/operations.jsx +++ b/src/core/components/operations.jsx @@ -33,7 +33,7 @@ export default class Operations extends React.Component { const Collapse = getComponent("Collapse") let showSummary = layoutSelectors.showSummary() - let { docExpansion, displayOperationId } = getConfigs() + let { docExpansion, displayOperationId, displayRequestDuration } = getConfigs() return (
@@ -88,6 +88,7 @@ export default class Operations extends React.Component { allowTryItOut={allowTryItOut} displayOperationId={displayOperationId} + displayRequestDuration={displayRequestDuration} specActions={ specActions } specSelectors={ specSelectors } diff --git a/src/core/components/response-body.jsx b/src/core/components/response-body.jsx index b933eb70..df3bd061 100644 --- a/src/core/components/response-body.jsx +++ b/src/core/components/response-body.jsx @@ -7,7 +7,7 @@ export default class ResponseBody extends React.Component { static propTypes = { content: PropTypes.any.isRequired, - contentType: PropTypes.string.isRequired, + contentType: PropTypes.string, getComponent: PropTypes.func.isRequired, headers: PropTypes.object, url: PropTypes.string diff --git a/src/core/components/responses.jsx b/src/core/components/responses.jsx index f32e7a1f..b0dfc4f7 100644 --- a/src/core/components/responses.jsx +++ b/src/core/components/responses.jsx @@ -15,19 +15,21 @@ export default class Responses extends React.Component { specSelectors: PropTypes.object.isRequired, specActions: PropTypes.object.isRequired, pathMethod: PropTypes.array.isRequired, + displayRequestDuration: PropTypes.bool.isRequired, fn: PropTypes.object.isRequired } static defaultProps = { request: null, tryItOutResponse: null, - produces: fromJS(["application/json"]) + produces: fromJS(["application/json"]), + displayRequestDuration: false } onChangeProducesWrapper = ( val ) => this.props.specActions.changeProducesValue(this.props.pathMethod, val) render() { - let { responses, request, tryItOutResponse, getComponent, specSelectors, fn, producesValue } = this.props + let { responses, request, tryItOutResponse, getComponent, specSelectors, fn, producesValue, displayRequestDuration } = this.props let defaultCode = defaultStatusCode( responses ) const ContentType = getComponent( "contentType" ) @@ -54,7 +56,8 @@ export default class Responses extends React.Component { :
+ getComponent={ getComponent } + displayRequestDuration={ displayRequestDuration } />

Responses

diff --git a/src/core/components/schemes.jsx b/src/core/components/schemes.jsx index 4742e691..8be4180a 100644 --- a/src/core/components/schemes.jsx +++ b/src/core/components/schemes.jsx @@ -7,7 +7,8 @@ export default class Schemes extends React.Component { specActions: PropTypes.object.isRequired, schemes: PropTypes.object.isRequired, path: PropTypes.string, - method: PropTypes.string + method: PropTypes.string, + operationScheme: PropTypes.string } componentWillMount() { @@ -17,11 +18,18 @@ export default class Schemes extends React.Component { this.setScheme(schemes.first()) } + componentWillReceiveProps(nextProps) { + if ( this.props.operationScheme && !nextProps.schemes.has(this.props.operationScheme) ) { + //fire 'change' event if our selected scheme is no longer an option + this.setScheme(nextProps.schemes.first()) + } + } + onChange =( e ) => { this.setScheme( e.target.value ) } - setScheme =( value ) => { + setScheme = ( value ) => { let { path, method, specActions } = this.props specActions.setScheme( value, path, method ) diff --git a/src/core/index.js b/src/core/index.js index 107001cb..80335da5 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -8,7 +8,7 @@ import { parseSeach, filterConfigs } from "core/utils" const CONFIGS = [ "url", "urls", "urls.primaryName", "spec", "validatorUrl", "onComplete", "onFailure", "authorizations", "docExpansion", "apisSorter", "operationsSorter", "supportedSubmitMethods", "dom_id", "defaultModelRendering", "oauth2RedirectUrl", - "showRequestHeaders", "custom", "modelPropertyMacro", "parameterMacro", "displayOperationId" ] + "showRequestHeaders", "custom", "modelPropertyMacro", "parameterMacro", "displayOperationId" , "displayRequestDuration"] // eslint-disable-next-line no-undef const { GIT_DIRTY, GIT_COMMIT, PACKAGE_VERSION } = buildInfo @@ -30,6 +30,7 @@ module.exports = function SwaggerUI(opts) { configs: {}, custom: {}, displayOperationId: false, + displayRequestDuration: false, // Initial set of plugins ( TODO rename this, or refactor - we don't need presets _and_ plugins. Its just there for performance. // Instead, we can compile the first plugin ( it can be a collection of plugins ), then batch the rest. diff --git a/src/core/plugins/spec/actions.js b/src/core/plugins/spec/actions.js index d531630a..31671b87 100644 --- a/src/core/plugins/spec/actions.js +++ b/src/core/plugins/spec/actions.js @@ -92,28 +92,28 @@ export const resolveSpec = (json, url) => ({specActions, specSelectors, errActio let specStr = specSelectors.specStr() return resolve({fetch, spec: json, baseDoc: url, modelPropertyMacro, parameterMacro }) - .then( ({spec, errors}) => { - errActions.clear({ - type: "thrown" - }) + .then( ({spec, errors}) => { + errActions.clear({ + type: "thrown" + }) - if(errors.length > 0) { - let preparedErrors = errors - .map(err => { - console.error(err) - err.line = err.fullPath ? getLineNumberForPath(specStr, err.fullPath) : null - err.path = err.fullPath ? err.fullPath.join(".") : null - err.level = "error" - err.type = "thrown" - err.source = "resolver" - Object.defineProperty(err, "message", { enumerable: true, value: err.message }) - return err - }) - errActions.newThrownErrBatch(preparedErrors) - } + if(errors.length > 0) { + let preparedErrors = errors + .map(err => { + console.error(err) + err.line = err.fullPath ? getLineNumberForPath(specStr, err.fullPath) : null + err.path = err.fullPath ? err.fullPath.join(".") : null + err.level = "error" + err.type = "thrown" + err.source = "resolver" + Object.defineProperty(err, "message", { enumerable: true, value: err.message }) + return err + }) + errActions.newThrownErrBatch(preparedErrors) + } - return specActions.updateResolved(spec) - }) + return specActions.updateResolved(spec) + }) } export const formatIntoYaml = () => ({specActions, specSelectors}) => { @@ -207,8 +207,14 @@ export const executeRequest = (req) => ({fn, specActions, specSelectors}) => { specActions.setRequest(req.pathName, req.method, parsedRequest) + // track duration of request + const startTime = Date.now() + return fn.execute(req) - .then( res => specActions.setResponse(req.pathName, req.method, res)) + .then( res => { + res.duration = Date.now() - startTime + specActions.setResponse(req.pathName, req.method, res) + } ) .catch( err => specActions.setResponse(req.pathName, req.method, { error: true, err: serializeError(err) } ) ) } diff --git a/src/core/plugins/spec/reducers.js b/src/core/plugins/spec/reducers.js index 6bc51d53..d9670d7b 100644 --- a/src/core/plugins/spec/reducers.js +++ b/src/core/plugins/spec/reducers.js @@ -75,7 +75,12 @@ export default { [SET_RESPONSE]: (state, { payload: { res, path, method } } ) =>{ let result if ( res.error ) { - result = Object.assign({error: true}, res.err) + result = Object.assign({ + error: true, + name: res.err.name, + message: res.err.message, + statusCode: res.err.statusCode + }, res.err.response) } else { result = res } @@ -86,7 +91,7 @@ export default { let newState = state.setIn( [ "responses", path, method ], fromJSOrdered(result) ) // ImmutableJS messes up Blob. Needs to reset its value. - if (res.data instanceof win.Blob) { + if (win.Blob && res.data instanceof win.Blob) { newState = newState.setIn( [ "responses", path, method, "text" ], res.data) } return newState diff --git a/src/core/utils.js b/src/core/utils.js index 2131e8d2..caaa88cc 100644 --- a/src/core/utils.js +++ b/src/core/utils.js @@ -450,15 +450,15 @@ export const propChecker = (props, nextProps, objectList=[], ignoreList=[]) => { || objectList.some( objectPropName => !eq(props[objectPropName], nextProps[objectPropName]))) } -const validateNumber = ( val ) => { - if ( !/^-?\d+(.?\d+)?$/.test(val)) { +export const validateNumber = ( val ) => { + if ( !/^-?\d+(\.?\d+)?$/.test(val)) { return "Value must be a number" } } -const validateInteger = ( val ) => { +export const validateInteger = ( val ) => { if ( !/^-?\d+$/.test(val)) { - return "Value must be integer" + return "Value must be an integer" } } @@ -469,13 +469,14 @@ export const validateParam = (param, isXml) => { let required = param.get("required") let type = param.get("type") - if ( required && (!value || (type==="array" && Array.isArray(value) && !value.length ))) { + let stringCheck = type === "string" && !value + let arrayCheck = type === "array" && Array.isArray(value) && !value.length + let listCheck = type === "array" && Im.List.isList(value) && !value.count() + if ( required && (stringCheck || arrayCheck || listCheck) ) { errors.push("Required field is not provided") return errors } - if ( !value ) return errors - if ( type === "number" ) { let err = validateNumber(value) if (!err) return errors diff --git a/test/core/plugins/spec-reducer.js b/test/core/plugins/spec-reducer.js index 32113543..09e73b7c 100644 --- a/test/core/plugins/spec-reducer.js +++ b/test/core/plugins/spec-reducer.js @@ -69,4 +69,56 @@ describe("spec plugin - reducer", function(){ expect(result.toJS()).toEqual(state.toJS()) }) }) + + describe("set response value", function() { + it("should combine the response and error objects", () => { + const setResponse = reducer["spec_set_response"] + + const path = "/pet/post" + const method = "POST" + + const state = fromJS({}) + const result = setResponse(state, { + payload: { + path: path, + method: method, + res: { + error: true, + err: { + message: "Not Found", + name: "Error", + response: { + data: "response data", + headers: { + key: "value" + }, + ok: false, + status: 404, + statusText: "Not Found" + }, + status: 404, + statusCode: 404 + } + } + } + }) + + let expectedResult = { + error: true, + message: "Not Found", + name: "Error", + data: "response data", + headers: { + key: "value" + }, + ok: false, + status: 404, + statusCode: 404, + statusText: "Not Found" + } + + const response = result.getIn(["responses", path, method]).toJS() + expect(response).toEqual(expectedResult) + }) + }) }) diff --git a/test/core/utils.js b/test/core/utils.js index 636c5f00..baf7dbf7 100644 --- a/test/core/utils.js +++ b/test/core/utils.js @@ -1,7 +1,7 @@ /* eslint-env mocha */ import expect from "expect" import { fromJS } from "immutable" -import { mapToList } from "core/utils" +import { mapToList, validateNumber, validateInteger, validateParam } from "core/utils" describe("utils", function(){ @@ -67,9 +67,181 @@ describe("utils", function(){ // Then expect(aList.toJS()).toEqual([]) - }) }) + describe("validateNumber", function() { + let errorMessage = "Value must be a number" + + it("doesn't return for whole numbers", function() { + expect(validateNumber(0)).toBeFalsy() + expect(validateNumber(1)).toBeFalsy() + expect(validateNumber(20)).toBeFalsy() + expect(validateNumber(5000000)).toBeFalsy() + expect(validateNumber("1")).toBeFalsy() + expect(validateNumber("2")).toBeFalsy() + expect(validateNumber(-1)).toBeFalsy() + expect(validateNumber(-20)).toBeFalsy() + expect(validateNumber(-5000000)).toBeFalsy() + }) + + it("doesn't return for negative numbers", function() { + expect(validateNumber(-1)).toBeFalsy() + expect(validateNumber(-20)).toBeFalsy() + expect(validateNumber(-5000000)).toBeFalsy() + }) + + it("doesn't return for decimal numbers", function() { + expect(validateNumber(1.1)).toBeFalsy() + expect(validateNumber(2.5)).toBeFalsy() + expect(validateNumber(-30.99)).toBeFalsy() + }) + + it("returns a message for strings", function() { + expect(validateNumber("")).toEqual(errorMessage) + expect(validateNumber(" ")).toEqual(errorMessage) + expect(validateNumber("test")).toEqual(errorMessage) + }) + + it("returns a message for invalid input", function() { + expect(validateNumber(undefined)).toEqual(errorMessage) + expect(validateNumber(null)).toEqual(errorMessage) + expect(validateNumber({})).toEqual(errorMessage) + expect(validateNumber([])).toEqual(errorMessage) + expect(validateNumber(true)).toEqual(errorMessage) + expect(validateNumber(false)).toEqual(errorMessage) + }) + }) + + describe("validateInteger", function() { + let errorMessage = "Value must be an integer" + + it("doesn't return for positive integers", function() { + expect(validateInteger(0)).toBeFalsy() + expect(validateInteger(1)).toBeFalsy() + expect(validateInteger(20)).toBeFalsy() + expect(validateInteger(5000000)).toBeFalsy() + expect(validateInteger("1")).toBeFalsy() + expect(validateInteger("2")).toBeFalsy() + expect(validateInteger(-1)).toBeFalsy() + expect(validateInteger(-20)).toBeFalsy() + expect(validateInteger(-5000000)).toBeFalsy() + }) + + it("doesn't return for negative integers", function() { + expect(validateInteger(-1)).toBeFalsy() + expect(validateInteger(-20)).toBeFalsy() + expect(validateInteger(-5000000)).toBeFalsy() + }) + + it("returns a message for decimal values", function() { + expect(validateInteger(1.1)).toEqual(errorMessage) + expect(validateInteger(2.5)).toEqual(errorMessage) + expect(validateInteger(-30.99)).toEqual(errorMessage) + }) + + it("returns a message for strings", function() { + expect(validateInteger("")).toEqual(errorMessage) + expect(validateInteger(" ")).toEqual(errorMessage) + expect(validateInteger("test")).toEqual(errorMessage) + }) + + it("returns a message for invalid input", function() { + expect(validateInteger(undefined)).toEqual(errorMessage) + expect(validateInteger(null)).toEqual(errorMessage) + expect(validateInteger({})).toEqual(errorMessage) + expect(validateInteger([])).toEqual(errorMessage) + expect(validateInteger(true)).toEqual(errorMessage) + expect(validateInteger(false)).toEqual(errorMessage) + }) + }) + + describe("validateParam", function() { + let param = null + let result = null + + it("validates required strings", function() { + param = fromJS({ + required: true, + type: "string", + value: "" + }) + result = validateParam( param, false ) + expect( result ).toEqual( ["Required field is not provided"] ) + }) + + it("validates required arrays", function() { + param = fromJS({ + required: true, + type: "array", + value: [] + }) + result = validateParam( param, false ) + expect( result ).toEqual( ["Required field is not provided"] ) + + param = fromJS({ + required: true, + type: "array", + value: [] + }) + result = validateParam( param, false ) + expect( result ).toEqual( ["Required field is not provided"] ) + }) + + it("validates numbers", function() { + param = fromJS({ + required: false, + type: "number", + value: "test" + }) + result = validateParam( param, false ) + expect( result ).toEqual( ["Value must be a number"] ) + }) + + it("validates integers", function() { + param = fromJS({ + required: false, + type: "integer", + value: "test" + }) + result = validateParam( param, false ) + expect( result ).toEqual( ["Value must be an integer"] ) + }) + + it("validates arrays", function() { + // empty array + param = fromJS({ + required: false, + type: "array", + value: [] + }) + result = validateParam( param, false ) + expect( result ).toEqual( [] ) + + // numbers + param = fromJS({ + required: false, + type: "array", + value: ["number"], + items: { + type: "number" + } + }) + result = validateParam( param, false ) + expect( result ).toEqual( [{index: 0, error: "Value must be a number"}] ) + + // integers + param = fromJS({ + required: false, + type: "array", + value: ["not", "numbers"], + items: { + type: "integer" + } + }) + result = validateParam( param, false ) + expect( result ).toEqual( [{index: 0, error: "Value must be an integer"}, {index: 1, error: "Value must be an integer"}] ) + }) + }) })