UNPKG

13.7 kBJavaScriptView Raw
1'use strict'
2
3const Options = require('./options')
4const fileSpecs = require('./fileSpecs')
5const util = require('./util')
6const fs = require('fs')
7const path = require('path')
8const assert = require('assert')
9const jsonSchema = require('jsonschema')
10const url = require('url')
11const request = require('axios')
12const swaggerSpec = require('./swaggerSpec')
13const expect = require('expect')
14
15const ONLY_SPEC_MARKER = '+'
16const ONLY_VERBOSE_SPEC_MARKER = 'v+'
17
18module.exports = apiSpecs
19
20/**
21 * Generates a suite of Mocha test specifications for each of your Swagger api operations.
22 *
23 * Both request and response of an operation call are validated for conformity
24 * with your Swagger document.
25 *
26 * You'll need to depend on and set up Mocha in your project yourself.
27 *
28 * @param {object} options
29 * - `api` path to your Swagger spec, or the loaded spec reference.
30 * - `host` server host + port where your tests will run e.g. `localhost:3453`.
31 * - `specs` path to specs dir, or function to return the set of specs for an operation.
32 * - `maxTimeout` maximum time a test can take to complete.
33 * - `slowTime` time taken before a test is marked slow. Defaults to 1 second.
34 * - `startServer(done)` function called before all tests where you can start your local server.
35 * - `stopServer(done)`function called after all tests where you can stop your local server.
36 * - `fixtures`: path to a yaml file with test fixtures.
37 * - `sortByStatus`: Sort specs by response status, lowest to highest. Defaults to true.
38 * - `prefetch`: path to a yaml file with requests to prefetch values into fixtures before
39 * executing specs, e.g. auth tokens
40 * @return {void}
41 */
42function apiSpecs(options) {
43 options = Options.applyDefaultSpecOptions(options)
44 if (options.headers) {
45 request.defaults.headers.common = Object.assign({}, request.defaults.headers.common, options.headers)
46 }
47 const api = swaggerSpec.getSpecSync(options.api)
48 const operations = swaggerSpec.getAllOperations(api)
49 options.fixtures = getJsonFile(options.fixtures)
50 describeApi(api, operations, options)
51}
52
53function describeApi(api, operations, options) {
54 describe(api.info.title, function () {
55 this.slow(options.slowTime || 1500)
56 this.timeout(options.maxTimeout || 10000)
57
58 before(done => {
59 options.startServer(e => {
60 if (e) done(e)
61 else prefetch(operations, options).then(() => done(), e => done(e))
62 }, options)
63 })
64 after(done => {
65 fileSpecs.disableOldSpecs(operations, options)
66 options.stopServer(done, options)
67 })
68 describeOperations(operations, options)
69 })
70}
71
72function prefetch(operations, options) {
73 const prefetch = getJsonFile(options.prefetch)
74 return Promise.all(Object.keys(prefetch).map(id => {
75 const data = prefetch[id]
76 const steps = Array.isArray(data) ? data : [ data ]
77 const specInfo = getSpecInfo(id)
78 return runSteps(steps, {}, operations, specInfo, options)
79 .catch(e => { e.message = `${e.message} in '${id}'`; throw e })
80 }))
81}
82
83function describeOperations(operations, options) {
84 operations.forEach(op => {
85 const description = `${op.method.toUpperCase()}: ${op.path} (${op.id})`
86 describe(description, () => {
87 describeOperationSpecs(op, operations, options)
88 })
89 })
90}
91
92function describeOperationSpecs(op, operations, options) {
93 const specs = getSpecs(op, options)
94 normalizeSpecs(specs, options).forEach(spec => {
95 const specInfo = getSpecInfo(spec.id)
96 specInfo.it(specInfo.summary, () => {
97 return runSteps(spec.steps, op, operations, specInfo, options)
98 })
99 })
100}
101
102function normalizeSpecs(specs, options) {
103 const normSpecs = Object.keys(specs)
104 .map(id => {
105 const spec = specs[id]
106 const steps = Array.isArray(spec) ? spec : [ spec ]
107 steps.forEach(step => {
108 if (step.response && typeof step.response !== 'object') {
109 step.response = { status: step.response }
110 }
111 })
112 const lastStep = steps[steps.length - 1]
113 if (lastStep.response === undefined) throw new Error(`Missing response status for spec: '${id}`)
114 return { id, steps, lastStep }
115 })
116
117 if (options.sortByStatus) {
118 normSpecs.sort((a, b) => a.lastStep.response.status - b.lastStep.response.status)
119 }
120 return normSpecs
121}
122
123function runSteps(steps, op, operations, specInfo, options) {
124 const fixtures = { fixtures: options.fixtures }
125 return steps.reduce((prev, step, i) => {
126 return prev.then(acc => {
127 step = util.resolveSchemaRefs(step, fixtures)
128 step.index = i
129 if (!step.request) step.request = {}
130 if (!step.response) step.response = {}
131 const stepOp = getStepOperation(step, operations, op)
132 return runStep(step, stepOp, specInfo, options, acc)
133 })
134 }, Promise.resolve([]))
135}
136
137function getStepOperation(step, operations, primaryOp) {
138 return operations.find(opt => opt.id === step.request.operationId) || primaryOp
139}
140
141function runStep(step, op, specInfo, options, acc) {
142 if (!acc) acc = []
143 const req = createRequest(op, step.request, options, acc)
144
145 if (expectsValidRequest(step)) {
146 validateRequest(req, step, op)
147 }
148 return request(req)
149 .then(res => {
150 acc.push({ req, res })
151 if (specInfo.verbose) {
152 const msg = `[${step.index}]${specInfo.summary}\n` +
153 `${prettyJson('Request:', req)}\n${prettyJson('Response:', res)}`
154 console.log(msg)
155 }
156 return res
157 })
158 .then(
159 res => validateResponse(req, res, step, op, options, acc),
160 res => validateResponse(req, res, step, op, options, acc)
161 )
162 .then(() => acc)
163}
164
165function getSpecs(op, options) {
166 let specs
167 if (typeof options.specs.create === 'function') specs = options.specs.create(op)
168 if (!specs) specs = op['x-specs']
169 if (specs) disableSpecsFile(op, options)
170 else specs = requireSpecsFile(op, options)
171 return specs || {}
172}
173
174function disableSpecsFile(op, options) {
175 return fileSpecs.disableSpec(op, options)
176}
177
178function requireSpecsFile(op, options) {
179 const fileInfo = fileSpecs.enableSpec(op, options)
180 const data = getJsonFile(fileInfo.path)
181 return resolveImports(data)
182}
183
184function resolveImports(root) {
185 let imports = (root.imports || []).slice()
186 delete root.imports
187 const map = new Map()
188 const results = []
189 while (imports.length) {
190 const p = imports.shift()
191 if (!map.has(p)) {
192 const data = getJsonFile(p)
193 map.set(p, data)
194 results.push(data)
195 if (data.imports) {
196 imports = imports.concat(data.imports)
197 delete data.imports
198 }
199 }
200 }
201 results.forEach(value => Object.assign(root, value))
202 return root
203}
204
205function getSpecInfo(id) {
206 if (id.startsWith(ONLY_SPEC_MARKER)) {
207 return { it: it.only, summary: id.substr(ONLY_SPEC_MARKER.length).trim(), verbose: false }
208 } else if (id.startsWith(ONLY_VERBOSE_SPEC_MARKER)) {
209 return { it: it.only, summary: id.substr(ONLY_VERBOSE_SPEC_MARKER.length).trim(), verbose: true }
210 } else {
211 return { it, summary: id.trim(), verbose: false }
212 }
213}
214
215function createRequest(op, testReqData, options, acc) {
216 testReqData = populateProperties(testReqData, acc, options)
217
218 let pathname = op.fullPath
219 if (testReqData.path) {
220 pathname = Object.keys(testReqData.path)
221 .reduce((p, t) =>
222 p.replace(new RegExp(`{${t}}`, 'g'), testReqData.path[t]), pathname)
223 }
224 return {
225 url: url.format({
226 protocol: 'http',
227 host: options.host,
228 pathname
229 }),
230 method: op.method,
231 headers: testReqData.header || {},
232 params: testReqData.query,
233 data: testReqData.body
234 }
235}
236
237function populateProperties(source, acc, options) {
238 if (Array.isArray(source)) {
239 source.forEach((v, i) => source[i] = populateProperties(v, acc, options))
240 } else if (typeof source === 'object') {
241 Object.keys(source || {}).forEach(key => source[key] = populateProperties(source[key], acc, options))
242 } else if (typeof source === 'string') {
243 const stepAcc = { step: acc, fixtures: options.fixtures }
244 if (source.startsWith('step[')) {
245 return parseProperty(source.split('.'), stepAcc)
246 } else {
247 const TOKEN_REGEX = /\$\{((step\[\d+\]|fixtures)[\w\[\d\]\.]+)\}/g
248 let tokenMatch = source.match(TOKEN_REGEX)
249 if (tokenMatch) {
250 // if the token isn't nested in a string then we return the raw value
251 if (tokenMatch.length === 1 && tokenMatch[0] === source) {
252 const path = tokenMatch[0].slice(2, -1)
253 source = parseProperty(path.split('.'), stepAcc)
254 } else {
255 // otherwise replace each token in the string
256 while (tokenMatch) {
257 source = tokenMatch.reduce((str, token) => {
258 const path = token.slice(2, -1)
259 const value = parseProperty(path.split('.'), stepAcc)
260 return str.split(token).join(value)
261 }, source)
262 tokenMatch = source.match(TOKEN_REGEX)
263 }
264 }
265 }
266 }
267 }
268 return source
269}
270
271function parseProperty(segments, source) {
272 if (!segments.length || (typeof source !== 'object' && !Array.isArray(source))) {
273 return source
274 }
275 const segment = segments.shift()
276 const arrayMatch = segment.match(/([\w]*)\[(\d+)\]$/m)
277 if (arrayMatch) {
278 const name = arrayMatch[1]
279 const index = Number(arrayMatch[2])
280 const array = name ? source[name] : source
281
282 assert.ok(Array.isArray(array), `Expected array at ${segment}`)
283 assert.ok(index >= 0 && index < array.length, `Invalid step index '${index}', range [0-${array.length - 1}]`)
284
285 return parseProperty(segments, source[name][index])
286 } else {
287 return parseProperty(segments, source[segment])
288 }
289}
290
291function validateRequest(req, spec, op) {
292 const groupSchema = op.paramGroupSchemas
293 swaggerSpec.PARAM_GROUPS.forEach(groupId => {
294 if (groupSchema[groupId]) {
295 try {
296 jsonSchema.validate(spec.request[groupId], groupSchema[groupId], { throwError: true })
297 } catch(e) {
298 e.message = `${e.toString()}\n${prettyJson('Request:', req)}`
299 throw e
300 }
301 }
302 })
303}
304
305function validateResponse(req, res, spec, op, options, acc) {
306 const responseSpec = getResponseSpec(spec, acc, options)
307 const responseSchema = op.responseSchemas[responseSpec.status]
308
309 assert.ok(responseSchema, `No response schema found for response status '${responseSpec.status}'`)
310
311 try {
312 validateStatus(res, responseSchema.id)
313 validateHeaders(res, responseSchema.headersSchema, responseSpec)
314 validateBody(res, responseSchema.bodySchema, responseSpec)
315 validateExpectations(responseSpec)
316 validateContentType(res, op)
317 updateFixtures(responseSpec, options)
318 } catch (e) {
319 e.message = `${e.toString()}\n${prettyJson('Request:', req)}\n${prettyJson('Response:', res)}`
320 throw e
321 }
322 return res
323}
324
325function getResponseSpec(spec, acc, options) {
326 if (typeof spec.response === 'object') {
327 return populateProperties(spec.response, acc, options)
328 } else {
329 return { status: spec.response }
330 }
331}
332
333function validateStatus(res, id) {
334 const status = Number(id)
335 if (Number.isInteger(status)) {
336 assert.strictEqual(res.status, status, `HTTP response code ${res.status} was expected to be ${status}`)
337 }
338}
339
340function validateHeaders(res, headersSchema, responseSpec) {
341 if (headersSchema) {
342 jsonSchema.validate(res.headers, headersSchema, { throwError: true })
343 }
344 if (responseSpec.header) {
345 // Check that any expect header values are indeed present.
346 const h = responseSpec.header
347 Object.keys(h)
348 .map(k => k.toLowerCase())
349 .every(k => assert.equal(`${res.headers[k]}`.toLowerCase(), `${h[k]}`.toLowerCase()))
350 }
351}
352function validateBody(res, bodySchema, responseSpec) {
353 if (bodySchema) {
354 // We can't use `throwError: true` because of a bug around anyOf and oneOf
355 // See https://github.com/tdegrunt/jsonschema/issues/119
356 // Instead we capture all errors and then throw the first one found
357 const result = jsonSchema.validate(res.data, bodySchema, { throwError: false })
358 if (result && result.errors.length) {
359 throw result.errors[0]
360 }
361 }
362 if (responseSpec.body) {
363 assert.deepEqual(res.data, responseSpec.body)
364 }
365}
366
367function validateExpectations(responseSpec) {
368 if (responseSpec.expect) {
369 responseSpec.expect.forEach(expectation => {
370 const assertion = Object.keys(expectation)[0]
371 const args = expectation[assertion]
372 const scope = expect(args.shift())
373 scope[assertion].apply(scope, args)
374 })
375 }
376}
377
378function validateContentType(res, op) {
379 if (res.status === 204) return
380
381 let contentType = res.headers['content-type'] || ''
382 contentType = contentType.split(';')[0].trim()
383 assert.notEqual(op.produces.indexOf(contentType), -1, `Response content type '${contentType}' was not expected`)
384}
385
386function expectsValidRequest(step) {
387 return (step.request.valid || (hasSuccessStatus(step.response) && step.request.valid !== false))
388}
389
390function hasSuccessStatus(status) {
391 status = Number(status)
392 return (Number.isInteger(status) && status >= 200 && status < 400)
393}
394
395function updateFixtures(responseSpec, options) {
396 if (responseSpec.fixtures) {
397 Object.assign(options.fixtures, responseSpec.fixtures)
398 }
399}
400
401function getJsonFile(jsonPath) {
402 if (!jsonPath || typeof jsonPath === 'object') return jsonPath || {}
403 const p = path.resolve(jsonPath)
404 if (!p || !util.existsSync(p)) return {}
405 const contents = fs.readFileSync(p, 'utf8')
406 return util.parseFileContents(contents, p) || {}
407}
408
409function prettyJson(title, obj) {
410 const MAX_LINES = 400
411 const lines = JSON.stringify(obj, null, 2).split('\n').slice(0, MAX_LINES).join('\n')
412 return `${title}\n${lines}`
413}