lib/process.js

  1. // Copyright 2018 Google Inc. All Rights Reserved.
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. 'use strict';
  15. var util = require('util')
  16. var helpers = require('./handlebar-helpers')
  17. var merge2 = require('./util').merge2;
  18. /**
  19. * @namespace processing
  20. */
  21. module.exports = process
  22. /**
  23. * ProcessedSpec is the fully traversed spec processed for unit test compilation
  24. * @typedef {object} ProcessedSpec
  25. * @memberof processing
  26. * @property {string} host Hostname to be used in the test requests; derived from the options or spec
  27. * @property {string} scheme HTTP schema to be used in the test requests; derived from the spec
  28. * @property {string} basePath Base path as defined by the spec
  29. * @property {string[]} consumes Global content type 'consumes' collection
  30. * @property {ProcessedPath[]} tests Collection of paths processed for test compilation
  31. */
  32. /**
  33. * Processes the given API spec, creating test data artifacts
  34. * @function process
  35. * @memberof processing
  36. * @instance
  37. * @param {object} api API spec object to be processed
  38. * @param {object} options options to be used during processing
  39. * @return {processing.ProcessedSpec}
  40. */
  41. function process(api, options) {
  42. var processedSpec = {
  43. 'host': (options.host !== undefined ? options.host : (api.host !== undefined
  44. ? api.host : 'localhost:5000')),
  45. 'scheme': (options.scheme !== undefined ? options.scheme : (api.schemes
  46. !== undefined ? api.schemes[0] : 'http')),
  47. 'basePath': (api.basePath !== undefined ? api.basePath : ''),
  48. 'consumes': (api.consumes !== undefined ? api.consumes : []),
  49. 'produces': (api.produces !== undefined ? api.produces : [])
  50. }
  51. var processedPaths = processPaths(api, processedSpec, options)
  52. if (processedPaths.length == 0) {
  53. console.log('no paths to process in spec')
  54. return null
  55. }
  56. processedSpec.tests = processedPaths
  57. return processedSpec
  58. }
  59. /**
  60. * ProcessedPath is the fully traversed path resource ready for unit test compilation
  61. * @typedef {object} ProcessedPath
  62. * @memberof processing
  63. * @property {string} name name to be used in generation of a file name; based on the path
  64. * @property {string} pathLevelDescription the brief description to use at the top level 'describe' block
  65. * @property {processing.ProcessedOp[]} operations Collection of operations processed for test compilation
  66. */
  67. /**
  68. * Processes the paths defined by the api spec, or indicated by the options
  69. * @function processPaths
  70. * @memberof processing
  71. * @instance
  72. * @param {object} api parsed OpenAPI spec object
  73. * @param {object} topLevel global spec properties
  74. * @param {object} options options to use during processing
  75. * @return {processing.ProcessedPath[]}
  76. */
  77. function processPaths(api, topLevel, options) {
  78. var data = []
  79. var targetPaths = [];
  80. if (options.paths !== undefined) {
  81. options.paths.forEach(function (path, ndx, arr) {
  82. targetPaths.push(api.getPath(path))
  83. })
  84. } else {
  85. targetPaths = api.getPaths();
  86. }
  87. if (options.statusCodes !== undefined) {
  88. var tempPaths = [];
  89. options.statusCodes.forEach(function (code, ndx, arr) {
  90. for (var ndx = 0; ndx < targetPaths.length; ndx++) {
  91. var opList = targetPaths[ndx].getOperations();
  92. for (var opNdx = 0; opNdx < opList.length; opNdx++) {
  93. if (opList[opNdx].getResponse(code)) {
  94. tempPaths.push(targetPaths[ndx])
  95. break;
  96. }
  97. }
  98. }
  99. });
  100. targetPaths = tempPaths;
  101. }
  102. targetPaths.forEach(function (pathObj, ndx, arr) {
  103. var ops = processOperations(api, pathObj, topLevel, options)
  104. if (ops.length !== 0) {
  105. data.push({
  106. 'name': pathObj.path.replace(/\//g, '-').substring(1),
  107. 'pathLevelDescription': 'tests for ' + pathObj.path,
  108. 'operations': ops
  109. })
  110. }
  111. })
  112. return data
  113. }
  114. /**
  115. * ProcessedOp is the fully traversed path operation ready for unit test compilation
  116. * @typedef {object} ProcessedOp
  117. * @memberof processing
  118. * @instance
  119. * @property {string} operationLevelDescription the brief description to use at the inner 'describe' block
  120. * @property {processing.ProcessedTransaction[]} transactions Collection of transactions processed for test compilation
  121. */
  122. /**
  123. * Processes the operations of the given Path object
  124. * @function processOperations
  125. * @memberof processing
  126. * @instance
  127. * @param {object} api parsed OpenAPI spec object
  128. * @param {object} parentPath sway Path object being processed
  129. * @param {object} topLevel global spec properties
  130. * @param {object} options options to use during processing
  131. * @return {processing.ProcessedOp[]}
  132. */
  133. function processOperations(api, parentPath, topLevel, options) {
  134. var ops = []
  135. parentPath.getOperations().forEach(function (op, ndx, arr) {
  136. var transactions = processTransactions(api, op, parentPath, topLevel,
  137. options)
  138. if (transactions.length !== 0) {
  139. ops.push({
  140. 'operationLevelDescription': 'tests for ' + op.method,
  141. 'transactions': transactions
  142. })
  143. }
  144. })
  145. return ops
  146. }
  147. /**
  148. * ProcessedTransaction is an HTTP transaction (request/response pair) processed for test compilation
  149. * @typedef {object} ProcessedTransaction
  150. * @memberof processing
  151. * @property {string} testLevelDescription the brief description to use in the test 'it' block
  152. * @property {string} scheme copied from the globally defined HTTP schema
  153. * @property {string} host copied from the globally defined hostname or as specified in the options
  154. * @property {string} path fully qualified path built with the base path; includes path param substitutions
  155. * @property {op} op the REST operation to use
  156. * @property {object} body the request body params
  157. * @property {object} query the request query params
  158. * @property {object} formData the request form data params
  159. * @property {ExpectedResponse} expected the expected response to use for test validation
  160. * @property {boolean} hasSamples flag indicating if the expected response includes sample values
  161. * @property {string[]} consumes based on globally defined conumes or operation defined types
  162. */
  163. /**
  164. * Processes the transactions for a given Path + Operation
  165. * @function processTransactions
  166. * @memberof processing
  167. * @instance
  168. * @param {object} api parsed OpenAPI spec object
  169. * @param {object} parentOp parent sway Operation object being processed
  170. * @param {object} parentPath parent sway Path object being processed
  171. * @param {object} topLevel global spec properties
  172. * @param {object} options options to use during processing
  173. * @return {processing.ProcessedTransaction[]}
  174. */
  175. function processTransactions(api, parentOp, parentPath, topLevel, options) {
  176. var transactions = []
  177. parentOp.getResponses().forEach(function (res, ndx, arr) {
  178. if (options.statusCodes && options.statusCodes.indexOf(res.statusCode)
  179. === -1) {
  180. // skip unwanted status codes
  181. return;
  182. }
  183. var params = processParams(res.statusCode, parentOp, parentPath, options)
  184. var expected = processResponse(res, parentOp.method, parentPath.path,
  185. options)
  186. var hasValue = options.samples
  187. if (expected.custom) {
  188. delete expected.custom
  189. hasValue = true
  190. }
  191. transactions.push({
  192. 'testLevelDescription': util.format('should respond %s for "%s"',
  193. res.statusCode, replaceNewlines(res.description)),
  194. 'scheme': topLevel.scheme,
  195. 'host': topLevel.host,
  196. 'path': (topLevel.basePath + params.path),
  197. 'op': parentOp.method,
  198. 'body': params.body,
  199. 'query': params.query,
  200. 'formData': params.formData,
  201. 'expected': expected,
  202. 'hasValue': hasValue,
  203. 'headers': merge2(params.headers,
  204. processHeaders(res.statusCode, parentOp, parentPath, topLevel,
  205. options))
  206. })
  207. })
  208. return transactions
  209. }
  210. /**
  211. * ProcessedParams is an object representing the processed request parameters for unit test compilation
  212. * @typedef {object} ProcessedParams
  213. * @memberof processing
  214. * @property {string} path processed path with path parameter sample substitutions
  215. * @property {object} body the request body params
  216. * @property {object} query the request query params
  217. * @property {object} formData the request form data params
  218. */
  219. /**
  220. * Processes the parameters of a Path + Operation for use in a test
  221. * @function processParams
  222. * @memberof processing
  223. * @instance
  224. * @param {number} code response status code being processed
  225. * @param {object} op sway Operation object that's being processed
  226. * @param {object} path sway Path object that's being process
  227. * @param {object} options options to use during processing
  228. * @return {processing.ProcessedParams}
  229. */
  230. function processParams(code, op, path, options) {
  231. var opCustomQuery = lookupCustomQueryValue(code, op.method, path.path, options);
  232. var params = {
  233. 'body': {},
  234. 'query': opCustomQuery || {},
  235. 'formData': {},
  236. 'path': path.path,
  237. 'headers': {}
  238. }
  239. op.getParameters().forEach(function (param, ndx, arr) {
  240. var customValue = lookupCustomValue(param.name, param.in, code, op.method,
  241. path.path, options)
  242. // Skip optional parameter with specifically nulled custom value or in-line example
  243. if ((customValue === null || param.example === null) && (!param.required)) {
  244. return
  245. }
  246. // If multiple examples are present, use the first one
  247. if (param.examples) {
  248. param.example = param.examples[Object.keys(param.examples)[0]].value;
  249. }
  250. if (param.in === 'body') {
  251. params.body = customValue !== undefined ? customValue : param.example !== undefined ? param.example : param.getSample()
  252. } else if (param.in === 'query' && !opCustomQuery) {
  253. params.query[param.name] = param.example !== undefined ? param.example : param.getSample();
  254. } else if (param.in === 'formData') {
  255. try {
  256. params.formData[param.name] = customValue !== undefined ? customValue
  257. : param.example !== undefined ? param.example : param.getSample()
  258. } catch (e) { // this will be because of formData property of type 'file'
  259. params.formData[param.name] = '{fileUpload}'
  260. }
  261. } else if (param.in === 'path') {
  262. params.path = pathify(params.path, param, customValue)
  263. } else if (param.in === 'header') {
  264. params.headers[param.name] = customValue !== undefined ? customValue
  265. : param.example !== undefined ? param.example : param.getSample()
  266. }
  267. })
  268. path.getParameters().forEach(function (param, ndx, arr) {
  269. var customValue = lookupCustomValue(param.name, param.in, code, op.method,
  270. path.path, options)
  271. // Skip optional parameter with specifically nulled custom value or in-line example
  272. if ((customValue === null || param.example === null) && (!param.required)) {
  273. return
  274. }
  275. params.path = pathify(params.path, param, customValue)
  276. })
  277. return params
  278. }
  279. /**
  280. * resolves any custom values available for the given parameter
  281. * @function lookupCustomValue
  282. * @memberof processing
  283. * @instance
  284. * @param {string} name parameter name being looked up
  285. * @param {string} location where the parameter is in the request/response
  286. * @param {number} code response status code being evaluated
  287. * @param {string} op the operation method being processed
  288. * @param {string} path API path being processed
  289. * @param {object} options for use in looking up the custom value
  290. * @return {any}
  291. */
  292. function lookupCustomValue(name, location, code, op, path, options) {
  293. var levels = [path, op, code]
  294. var val = undefined;
  295. if (options.customValues) {
  296. var curr = options.customValues
  297. var ndx = 0
  298. do {
  299. if (curr[location]) {
  300. if (curr[location][name] !== undefined) {
  301. val = curr[location][name]
  302. }
  303. }
  304. curr = curr[levels[ndx]]
  305. } while (ndx++ < levels.length && curr)
  306. }
  307. return val
  308. }
  309. /**
  310. * resolves any custom query values available for the given operation and path
  311. * @function lookupCustomQueryValue
  312. * @memberof processing
  313. * @instance
  314. * @param {number} code response status code being evaluated
  315. * @param {string} op the operation method being processed
  316. * @param {string} path API path being processed
  317. * @param {object} options for use in looking up the custom value
  318. * @return {any}
  319. */
  320. function lookupCustomQueryValue(code, op, path, options) {
  321. var levels = [path, op, code];
  322. var val = undefined;
  323. if (options.customValues) {
  324. var curr = options.customValues;
  325. var ndx = 0
  326. do {
  327. if (curr.query !== undefined) {
  328. val = curr.query
  329. }
  330. curr = curr[levels[ndx]];
  331. } while (ndx++ < levels.length && curr)
  332. }
  333. return val
  334. }
  335. /**
  336. * Determines the request headers for a transaction
  337. * @function processHeaders
  338. * @memberof processing
  339. * @instance
  340. * @param {string} responseCode response code being processed
  341. * @param {object} op parent operation object
  342. * @param {object} path parent path object
  343. * @param {object} top global properties
  344. * @param {object} options for use in processing headers
  345. * @return {object}
  346. */
  347. function processHeaders(responseCode, op, path, top, options) {
  348. var headers = {};
  349. var levels = [path.path, op.method, responseCode]
  350. // handle consumes
  351. if (options.consumes) { // a specific content-type was targeted
  352. if (op.definitionFullyResolved.consumes) {
  353. // this operation overrides global consumes, and does contain the requested content-type
  354. if (op.definitionFullyResolved.consumes.indexOf(options.consumes) != -1) {
  355. headers['Content-Type'] = options.consumes;
  356. } else { // we will just use the first one because this doesn't match
  357. headers['Content-Type'] = op.definitionFullyResolved.consumes[0];
  358. }
  359. } else if (top.consumes.length > 0 && top.consumes.indexOf(options.consumes)
  360. != -1) {
  361. headers['Content-Type'] = options.consumes;
  362. } else if (top.consumes.length === 1) { // unless there is a singular different global consumes, just use that one
  363. headers['Content-Type'] = top.consumes[0];
  364. }
  365. } else if (op.definitionFullyResolved.consumes) { // we will just use the first one if none are specified
  366. headers['Content-Type'] = op.definitionFullyResolved.consumes[0];
  367. } else if (top.consumes.length > 0) {
  368. headers['Content-Type'] = top.consumes[0];
  369. }
  370. // handle produces
  371. if (options.produces) {
  372. if (op.definitionFullyResolved.produces) {
  373. if (op.definitionFullyResolved.produces.indexOf(options.produces) != -1) {
  374. headers['Accept'] = options.produces;
  375. } else {
  376. headers['Accept'] = op.definitionFullyResolved.produces[0];
  377. }
  378. } else if (top.produces.length > 0 && top.produces.indexOf(options.produces)
  379. != -1) {
  380. headers['Accept'] = options.produces;
  381. } else if (top.produces.length === 1) {
  382. headers['Accept'] = top.produces[0];
  383. }
  384. } else if (op.definitionFullyResolved.produces) {
  385. headers['Accept'] = op.definitionFullyResolved.produces[0];
  386. } else if (top.produces.length > 0) {
  387. headers['Accept'] = top.produces[0];
  388. }
  389. // check custom request values for any desired headers; cascading merge
  390. if (options.customValues) {
  391. var curr = options.customValues
  392. var ndx = 0
  393. do {
  394. if (curr['header'] !== undefined) {
  395. headers = merge2(curr['header'], headers)
  396. }
  397. curr = curr[levels[ndx]]
  398. } while (ndx++ < levels.length && curr !== undefined)
  399. }
  400. return headers
  401. }
  402. /**
  403. * ExpectedResponse is the expected response generated based on the API spec
  404. * @typedef {object} ExpectedResponse
  405. * @memberof processing
  406. * @property {number} statusCode expected response status code
  407. * @property {boolean} custom indicates if the value was a custom injection
  408. * @property {object} res expected response body, if applicable; can be a generated sample
  409. */
  410. /**
  411. * Processes a sway Response for use in a test
  412. * @function processResponse
  413. * @memberof processing
  414. * @instance
  415. * @param {object} res sway Response object being processed
  416. * @param {string} op the operation method being processed
  417. * @param {string} path API path being processed
  418. * @param {object} options options to use during processing
  419. * @return {processing.ExpectedResponse}
  420. */
  421. function processResponse(res, op, path, options) {
  422. var expected = {
  423. 'statusCode': res.statusCode,
  424. 'custom': false
  425. }
  426. if (options.customValues
  427. && options.customValues[path]
  428. && options.customValues[path][op]
  429. && options.customValues[path][op][res.statusCode]
  430. && options.customValues[path][op][res.statusCode].response) {
  431. expected['res'] = options.customValues[path][op][res.statusCode].response
  432. expected.custom = true;
  433. return expected
  434. }
  435. var res = options.samples ? res.getSample()
  436. : res.definitionFullyResolved.schema
  437. if (res !== undefined) {
  438. expected['res'] = res
  439. }
  440. return expected
  441. }
  442. /**
  443. * replaces the path paremeter in the given URL path with a sample value
  444. * @function pathify
  445. * @memberof processing
  446. * @instance
  447. * @param {string} path URL path to be pathified
  448. * @param {object} param sway Parameter object to use in pathify
  449. * @param {(string|number)} value a custom value to use in the pathify
  450. * @return {string}
  451. */
  452. function pathify(path, param, value) {
  453. var regex = new RegExp('(\/.*){' + param.name + '}(\/.*)*', 'g')
  454. var sample = value !== undefined ? value : param.example !== undefined ? param.example : param.getSample()
  455. if (isNumberType(param.definition.type)) {
  456. // prevent negative sample values for numbers
  457. while (sample < 0) {
  458. sample = param.getSample()
  459. }
  460. }
  461. return path.replace(regex, '$1' + sample + '$2').replace(/ /g, '');
  462. }
  463. /**
  464. * evaluates if the given type is an OpenAPI number type or not
  465. * @function isNumberType
  466. * @memberof processing
  467. * @instance
  468. * @param {string} type type to be evaluated
  469. * @return {boolean}
  470. */
  471. function isNumberType(type) {
  472. return (type === 'integer' || type === 'float' || type === 'long' || type
  473. === 'double')
  474. }
  475. /**
  476. * Determines the proper type for the Query parameter
  477. * @function determineQueryType
  478. * @memberof processing
  479. * @instance
  480. * @param {object} param sway Parameter object to investigate
  481. * @return {string}
  482. */
  483. function determineQueryType(param) {
  484. var val = param.name; // default to the name if all else fails
  485. if (param.definitionFullyResolved.type === 'array') {
  486. val = param.definitionFullyResolved.items.type + '[]'
  487. } else {
  488. val = param.definitionFullyResolved.type
  489. }
  490. return '{' + val + '}'
  491. }
  492. /**
  493. * Used to replace newlines with a space in each response.description
  494. * @function replaceNewlines
  495. * @memberof processing
  496. * @instance
  497. * @param {string} str string to replace newlines with spaces
  498. * @return {string}
  499. */
  500. function replaceNewlines(str) {
  501. return str.replace(/\r?\n|\r/g, " ")
  502. }