// Copyright 2018 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
'use strict';
var util = require('util')
var helpers = require('./handlebar-helpers')
var merge2 = require('./util').merge2;
/**
* @namespace processing
*/
module.exports = process
/**
* ProcessedSpec is the fully traversed spec processed for unit test compilation
* @typedef {object} ProcessedSpec
* @memberof processing
* @property {string} host Hostname to be used in the test requests; derived from the options or spec
* @property {string} scheme HTTP schema to be used in the test requests; derived from the spec
* @property {string} basePath Base path as defined by the spec
* @property {string[]} consumes Global content type 'consumes' collection
* @property {ProcessedPath[]} tests Collection of paths processed for test compilation
*/
/**
* Processes the given API spec, creating test data artifacts
* @function process
* @memberof processing
* @instance
* @param {object} api API spec object to be processed
* @param {object} options options to be used during processing
* @return {processing.ProcessedSpec}
*/
function process(api, options) {
var processedSpec = {
'host': (options.host !== undefined ? options.host : (api.host !== undefined
? api.host : 'localhost:5000')),
'scheme': (options.scheme !== undefined ? options.scheme : (api.schemes
!== undefined ? api.schemes[0] : 'http')),
'basePath': (api.basePath !== undefined ? api.basePath : ''),
'consumes': (api.consumes !== undefined ? api.consumes : []),
'produces': (api.produces !== undefined ? api.produces : [])
}
var processedPaths = processPaths(api, processedSpec, options)
if (processedPaths.length == 0) {
console.log('no paths to process in spec')
return null
}
processedSpec.tests = processedPaths
return processedSpec
}
/**
* ProcessedPath is the fully traversed path resource ready for unit test compilation
* @typedef {object} ProcessedPath
* @memberof processing
* @property {string} name name to be used in generation of a file name; based on the path
* @property {string} pathLevelDescription the brief description to use at the top level 'describe' block
* @property {processing.ProcessedOp[]} operations Collection of operations processed for test compilation
*/
/**
* Processes the paths defined by the api spec, or indicated by the options
* @function processPaths
* @memberof processing
* @instance
* @param {object} api parsed OpenAPI spec object
* @param {object} topLevel global spec properties
* @param {object} options options to use during processing
* @return {processing.ProcessedPath[]}
*/
function processPaths(api, topLevel, options) {
var data = []
var targetPaths = [];
if (options.paths !== undefined) {
options.paths.forEach(function (path, ndx, arr) {
targetPaths.push(api.getPath(path))
})
} else {
targetPaths = api.getPaths();
}
if (options.statusCodes !== undefined) {
var tempPaths = [];
options.statusCodes.forEach(function (code, ndx, arr) {
for (var ndx = 0; ndx < targetPaths.length; ndx++) {
var opList = targetPaths[ndx].getOperations();
for (var opNdx = 0; opNdx < opList.length; opNdx++) {
if (opList[opNdx].getResponse(code)) {
tempPaths.push(targetPaths[ndx])
break;
}
}
}
});
targetPaths = tempPaths;
}
targetPaths.forEach(function (pathObj, ndx, arr) {
var ops = processOperations(api, pathObj, topLevel, options)
if (ops.length !== 0) {
data.push({
'name': pathObj.path.replace(/\//g, '-').substring(1),
'pathLevelDescription': 'tests for ' + pathObj.path,
'operations': ops
})
}
})
return data
}
/**
* ProcessedOp is the fully traversed path operation ready for unit test compilation
* @typedef {object} ProcessedOp
* @memberof processing
* @instance
* @property {string} operationLevelDescription the brief description to use at the inner 'describe' block
* @property {processing.ProcessedTransaction[]} transactions Collection of transactions processed for test compilation
*/
/**
* Processes the operations of the given Path object
* @function processOperations
* @memberof processing
* @instance
* @param {object} api parsed OpenAPI spec object
* @param {object} parentPath sway Path object being processed
* @param {object} topLevel global spec properties
* @param {object} options options to use during processing
* @return {processing.ProcessedOp[]}
*/
function processOperations(api, parentPath, topLevel, options) {
var ops = []
parentPath.getOperations().forEach(function (op, ndx, arr) {
var transactions = processTransactions(api, op, parentPath, topLevel,
options)
if (transactions.length !== 0) {
ops.push({
'operationLevelDescription': 'tests for ' + op.method,
'transactions': transactions
})
}
})
return ops
}
/**
* ProcessedTransaction is an HTTP transaction (request/response pair) processed for test compilation
* @typedef {object} ProcessedTransaction
* @memberof processing
* @property {string} testLevelDescription the brief description to use in the test 'it' block
* @property {string} scheme copied from the globally defined HTTP schema
* @property {string} host copied from the globally defined hostname or as specified in the options
* @property {string} path fully qualified path built with the base path; includes path param substitutions
* @property {op} op the REST operation to use
* @property {object} body the request body params
* @property {object} query the request query params
* @property {object} formData the request form data params
* @property {ExpectedResponse} expected the expected response to use for test validation
* @property {boolean} hasSamples flag indicating if the expected response includes sample values
* @property {string[]} consumes based on globally defined conumes or operation defined types
*/
/**
* Processes the transactions for a given Path + Operation
* @function processTransactions
* @memberof processing
* @instance
* @param {object} api parsed OpenAPI spec object
* @param {object} parentOp parent sway Operation object being processed
* @param {object} parentPath parent sway Path object being processed
* @param {object} topLevel global spec properties
* @param {object} options options to use during processing
* @return {processing.ProcessedTransaction[]}
*/
function processTransactions(api, parentOp, parentPath, topLevel, options) {
var transactions = []
parentOp.getResponses().forEach(function (res, ndx, arr) {
if (options.statusCodes && options.statusCodes.indexOf(res.statusCode)
=== -1) {
// skip unwanted status codes
return;
}
var params = processParams(res.statusCode, parentOp, parentPath, options)
var expected = processResponse(res, parentOp.method, parentPath.path,
options)
var hasValue = options.samples
if (expected.custom) {
delete expected.custom
hasValue = true
}
transactions.push({
'testLevelDescription': util.format('should respond %s for "%s"',
res.statusCode, replaceNewlines(res.description)),
'scheme': topLevel.scheme,
'host': topLevel.host,
'path': (topLevel.basePath + params.path),
'op': parentOp.method,
'body': params.body,
'query': params.query,
'formData': params.formData,
'expected': expected,
'hasValue': hasValue,
'headers': merge2(params.headers,
processHeaders(res.statusCode, parentOp, parentPath, topLevel,
options))
})
})
return transactions
}
/**
* ProcessedParams is an object representing the processed request parameters for unit test compilation
* @typedef {object} ProcessedParams
* @memberof processing
* @property {string} path processed path with path parameter sample substitutions
* @property {object} body the request body params
* @property {object} query the request query params
* @property {object} formData the request form data params
*/
/**
* Processes the parameters of a Path + Operation for use in a test
* @function processParams
* @memberof processing
* @instance
* @param {number} code response status code being processed
* @param {object} op sway Operation object that's being processed
* @param {object} path sway Path object that's being process
* @param {object} options options to use during processing
* @return {processing.ProcessedParams}
*/
function processParams(code, op, path, options) {
var opCustomQuery = lookupCustomQueryValue(code, op.method, path.path, options);
var params = {
'body': {},
'query': opCustomQuery || {},
'formData': {},
'path': path.path,
'headers': {}
}
op.getParameters().forEach(function (param, ndx, arr) {
var customValue = lookupCustomValue(param.name, param.in, code, op.method,
path.path, options)
// Skip optional parameter with specifically nulled custom value or in-line example
if ((customValue === null || param.example === null) && (!param.required)) {
return
}
// If multiple examples are present, use the first one
if (param.examples) {
param.example = param.examples[Object.keys(param.examples)[0]].value;
}
if (param.in === 'body') {
params.body = customValue !== undefined ? customValue : param.example !== undefined ? param.example : param.getSample()
} else if (param.in === 'query' && !opCustomQuery) {
params.query[param.name] = param.example !== undefined ? param.example : param.getSample();
} else if (param.in === 'formData') {
try {
params.formData[param.name] = customValue !== undefined ? customValue
: param.example !== undefined ? param.example : param.getSample()
} catch (e) { // this will be because of formData property of type 'file'
params.formData[param.name] = '{fileUpload}'
}
} else if (param.in === 'path') {
params.path = pathify(params.path, param, customValue)
} else if (param.in === 'header') {
params.headers[param.name] = customValue !== undefined ? customValue
: param.example !== undefined ? param.example : param.getSample()
}
})
path.getParameters().forEach(function (param, ndx, arr) {
var customValue = lookupCustomValue(param.name, param.in, code, op.method,
path.path, options)
// Skip optional parameter with specifically nulled custom value or in-line example
if ((customValue === null || param.example === null) && (!param.required)) {
return
}
params.path = pathify(params.path, param, customValue)
})
return params
}
/**
* resolves any custom values available for the given parameter
* @function lookupCustomValue
* @memberof processing
* @instance
* @param {string} name parameter name being looked up
* @param {string} location where the parameter is in the request/response
* @param {number} code response status code being evaluated
* @param {string} op the operation method being processed
* @param {string} path API path being processed
* @param {object} options for use in looking up the custom value
* @return {any}
*/
function lookupCustomValue(name, location, code, op, path, options) {
var levels = [path, op, code]
var val = undefined;
if (options.customValues) {
var curr = options.customValues
var ndx = 0
do {
if (curr[location]) {
if (curr[location][name] !== undefined) {
val = curr[location][name]
}
}
curr = curr[levels[ndx]]
} while (ndx++ < levels.length && curr)
}
return val
}
/**
* resolves any custom query values available for the given operation and path
* @function lookupCustomQueryValue
* @memberof processing
* @instance
* @param {number} code response status code being evaluated
* @param {string} op the operation method being processed
* @param {string} path API path being processed
* @param {object} options for use in looking up the custom value
* @return {any}
*/
function lookupCustomQueryValue(code, op, path, options) {
var levels = [path, op, code];
var val = undefined;
if (options.customValues) {
var curr = options.customValues;
var ndx = 0
do {
if (curr.query !== undefined) {
val = curr.query
}
curr = curr[levels[ndx]];
} while (ndx++ < levels.length && curr)
}
return val
}
/**
* Determines the request headers for a transaction
* @function processHeaders
* @memberof processing
* @instance
* @param {string} responseCode response code being processed
* @param {object} op parent operation object
* @param {object} path parent path object
* @param {object} top global properties
* @param {object} options for use in processing headers
* @return {object}
*/
function processHeaders(responseCode, op, path, top, options) {
var headers = {};
var levels = [path.path, op.method, responseCode]
// handle consumes
if (options.consumes) { // a specific content-type was targeted
if (op.definitionFullyResolved.consumes) {
// this operation overrides global consumes, and does contain the requested content-type
if (op.definitionFullyResolved.consumes.indexOf(options.consumes) != -1) {
headers['Content-Type'] = options.consumes;
} else { // we will just use the first one because this doesn't match
headers['Content-Type'] = op.definitionFullyResolved.consumes[0];
}
} else if (top.consumes.length > 0 && top.consumes.indexOf(options.consumes)
!= -1) {
headers['Content-Type'] = options.consumes;
} else if (top.consumes.length === 1) { // unless there is a singular different global consumes, just use that one
headers['Content-Type'] = top.consumes[0];
}
} else if (op.definitionFullyResolved.consumes) { // we will just use the first one if none are specified
headers['Content-Type'] = op.definitionFullyResolved.consumes[0];
} else if (top.consumes.length > 0) {
headers['Content-Type'] = top.consumes[0];
}
// handle produces
if (options.produces) {
if (op.definitionFullyResolved.produces) {
if (op.definitionFullyResolved.produces.indexOf(options.produces) != -1) {
headers['Accept'] = options.produces;
} else {
headers['Accept'] = op.definitionFullyResolved.produces[0];
}
} else if (top.produces.length > 0 && top.produces.indexOf(options.produces)
!= -1) {
headers['Accept'] = options.produces;
} else if (top.produces.length === 1) {
headers['Accept'] = top.produces[0];
}
} else if (op.definitionFullyResolved.produces) {
headers['Accept'] = op.definitionFullyResolved.produces[0];
} else if (top.produces.length > 0) {
headers['Accept'] = top.produces[0];
}
// check custom request values for any desired headers; cascading merge
if (options.customValues) {
var curr = options.customValues
var ndx = 0
do {
if (curr['header'] !== undefined) {
headers = merge2(curr['header'], headers)
}
curr = curr[levels[ndx]]
} while (ndx++ < levels.length && curr !== undefined)
}
return headers
}
/**
* ExpectedResponse is the expected response generated based on the API spec
* @typedef {object} ExpectedResponse
* @memberof processing
* @property {number} statusCode expected response status code
* @property {boolean} custom indicates if the value was a custom injection
* @property {object} res expected response body, if applicable; can be a generated sample
*/
/**
* Processes a sway Response for use in a test
* @function processResponse
* @memberof processing
* @instance
* @param {object} res sway Response object being processed
* @param {string} op the operation method being processed
* @param {string} path API path being processed
* @param {object} options options to use during processing
* @return {processing.ExpectedResponse}
*/
function processResponse(res, op, path, options) {
var expected = {
'statusCode': res.statusCode,
'custom': false
}
if (options.customValues
&& options.customValues[path]
&& options.customValues[path][op]
&& options.customValues[path][op][res.statusCode]
&& options.customValues[path][op][res.statusCode].response) {
expected['res'] = options.customValues[path][op][res.statusCode].response
expected.custom = true;
return expected
}
var res = options.samples ? res.getSample()
: res.definitionFullyResolved.schema
if (res !== undefined) {
expected['res'] = res
}
return expected
}
/**
* replaces the path paremeter in the given URL path with a sample value
* @function pathify
* @memberof processing
* @instance
* @param {string} path URL path to be pathified
* @param {object} param sway Parameter object to use in pathify
* @param {(string|number)} value a custom value to use in the pathify
* @return {string}
*/
function pathify(path, param, value) {
var regex = new RegExp('(\/.*){' + param.name + '}(\/.*)*', 'g')
var sample = value !== undefined ? value : param.example !== undefined ? param.example : param.getSample()
if (isNumberType(param.definition.type)) {
// prevent negative sample values for numbers
while (sample < 0) {
sample = param.getSample()
}
}
return path.replace(regex, '$1' + sample + '$2').replace(/ /g, '');
}
/**
* evaluates if the given type is an OpenAPI number type or not
* @function isNumberType
* @memberof processing
* @instance
* @param {string} type type to be evaluated
* @return {boolean}
*/
function isNumberType(type) {
return (type === 'integer' || type === 'float' || type === 'long' || type
=== 'double')
}
/**
* Determines the proper type for the Query parameter
* @function determineQueryType
* @memberof processing
* @instance
* @param {object} param sway Parameter object to investigate
* @return {string}
*/
function determineQueryType(param) {
var val = param.name; // default to the name if all else fails
if (param.definitionFullyResolved.type === 'array') {
val = param.definitionFullyResolved.items.type + '[]'
} else {
val = param.definitionFullyResolved.type
}
return '{' + val + '}'
}
/**
* Used to replace newlines with a space in each response.description
* @function replaceNewlines
* @memberof processing
* @instance
* @param {string} str string to replace newlines with spaces
* @return {string}
*/
function replaceNewlines(str) {
return str.replace(/\r?\n|\r/g, " ")
}