UNPKG

14.2 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 }, res => {
158 acc.push({ req, res })
159 return res
160 })
161 .then(
162 res => validateResponse(req, res, step, op, options, acc),
163 res => validateResponse(req, res, step, op, options, acc)
164 )
165 .then(() => acc)
166}
167
168function getSpecs(op, options) {
169 let specs
170 if (typeof options.specs.create === 'function') specs = options.specs.create(op)
171 if (!specs) specs = op['x-specs']
172 if (specs) disableSpecsFile(op, options)
173 else specs = requireSpecsFile(op, options)
174 return specs || {}
175}
176
177function disableSpecsFile(op, options) {
178 return fileSpecs.disableSpec(op, options)
179}
180
181function requireSpecsFile(op, options) {
182 const fileInfo = fileSpecs.enableSpec(op, options)
183 const data = getJsonFile(fileInfo.path)
184 return resolveImports(data)
185}
186
187function resolveImports(root) {
188 let imports = (root.imports || []).slice()
189 delete root.imports
190 const map = new Map()
191 const results = []
192 while (imports.length) {
193 const p = imports.shift()
194 if (!map.has(p)) {
195 const data = getJsonFile(p)
196 map.set(p, data)
197 results.push(data)
198 if (data.imports) {
199 imports = imports.concat(data.imports)
200 delete data.imports
201 }
202 }
203 }
204 results.forEach(value => Object.assign(root, value))
205 return root
206}
207
208function getSpecInfo(id) {
209 if (id.startsWith(ONLY_SPEC_MARKER)) {
210 return { it: it.only, summary: id.substr(ONLY_SPEC_MARKER.length).trim(), verbose: false }
211 } else if (id.startsWith(ONLY_VERBOSE_SPEC_MARKER)) {
212 return { it: it.only, summary: id.substr(ONLY_VERBOSE_SPEC_MARKER.length).trim(), verbose: true }
213 } else {
214 return { it, summary: id.trim(), verbose: false }
215 }
216}
217
218function createRequest(op, testReqData, options, acc) {
219 testReqData = populateProperties(testReqData, acc, options)
220
221 let pathname = op.fullPath
222 if (testReqData.path) {
223 pathname = Object.keys(testReqData.path)
224 .reduce((p, t) =>
225 p.replace(new RegExp(`{${t}}`, 'g'), testReqData.path[t]), pathname)
226 }
227 return {
228 url: url.format({
229 protocol: 'http',
230 host: options.host,
231 pathname
232 }),
233 method: op.method,
234 headers: testReqData.header || {},
235 params: testReqData.query,
236 data: testReqData.body
237 }
238}
239
240function populateProperties(source, acc, options) {
241 if (Array.isArray(source)) {
242 source.forEach((v, i) => source[i] = populateProperties(v, acc, options))
243 } else if (typeof source === 'object') {
244 Object.keys(source || {}).forEach(key => source[key] = populateProperties(source[key], acc, options))
245 } else if (typeof source === 'string') {
246 const stepAcc = { step: acc, fixtures: options.fixtures }
247 if (source.startsWith('step[')) {
248 return parseProperty(source.split('.'), stepAcc)
249 } else {
250 const TOKEN_REGEX = /\$\{((step\[\d+\]|fixtures)[\w\[\d\]\.]+)\}/g
251 let tokenMatch = source.match(TOKEN_REGEX)
252 if (tokenMatch) {
253 // if the token isn't nested in a string then we return the raw value
254 if (tokenMatch.length === 1 && tokenMatch[0] === source) {
255 const path = tokenMatch[0].slice(2, -1)
256 source = parseProperty(path.split('.'), stepAcc)
257 } else {
258 // otherwise replace each token in the string
259 while (tokenMatch) {
260 source = tokenMatch.reduce((str, token) => {
261 const path = token.slice(2, -1)
262 const value = parseProperty(path.split('.'), stepAcc)
263 return str.split(token).join(value)
264 }, source)
265 tokenMatch = source.match(TOKEN_REGEX)
266 }
267 }
268 }
269 }
270 }
271 return source
272}
273
274function parseProperty(segments, source) {
275 if (!segments.length || (typeof source !== 'object' && !Array.isArray(source))) {
276 return source
277 }
278 const segment = segments.shift()
279 const arrayMatch = segment.match(/([\w]*)\[(\d+)\]$/m)
280 if (arrayMatch) {
281 const name = arrayMatch[1]
282 const index = Number(arrayMatch[2])
283 const array = name ? source[name] : source
284
285 assert.ok(Array.isArray(array), `Expected array at ${segment}`)
286 assert.ok(index >= 0 && index < array.length, `Invalid step index '${index}', range [0-${array.length - 1}]`)
287
288 return parseProperty(segments, source[name][index])
289 } else {
290 return parseProperty(segments, source[segment])
291 }
292}
293
294function validateRequest(req, spec, op) {
295 const groupSchema = op.paramGroupSchemas
296 swaggerSpec.PARAM_GROUPS.forEach(groupId => {
297 if (groupSchema[groupId]) {
298 try {
299 jsonSchema.validate(spec.request[groupId], groupSchema[groupId], { throwError: true })
300 } catch(e) {
301 e.message = `${e.toString()}\n${prettyJson('Request:', req)}`
302 throw e
303 }
304 }
305 })
306}
307
308function validateResponse(req, res, spec, op, options, acc) {
309 const responseSpec = getResponseSpec(spec, acc, options)
310 const responseSchema = op.responseSchemas[responseSpec.status]
311
312 assert.ok(responseSchema, `No response schema found for response status '${responseSpec.status}'`)
313
314 try {
315 validateStatus(res, responseSchema.id)
316 validateHeaders(res, responseSchema.headersSchema, responseSpec)
317 validateBody(res, responseSchema.bodySchema, responseSpec)
318 validateExpectations(responseSpec)
319 validateContentType(res, op)
320 updateFixtures(responseSpec, options)
321 } catch (e) {
322 e.message = `${e.toString()}\n${prettyJson('Request:', req)}\n${prettyJson('Response:', res)}`
323 throw e
324 }
325 return res
326}
327
328function getResponseSpec(spec, acc, options) {
329 if (typeof spec.response === 'object') {
330 // Don't attempt to parse properties if we didn't get the expected
331 // response as things will tend to blow up
332 if (spec.response.status === (acc[acc.length - 1] || { res: {} }).res.status) {
333 return populateProperties(spec.response, acc, options)
334 } else {
335 return spec.response
336 }
337 } else {
338 return { status: spec.response }
339 }
340}
341
342function validateStatus(res, id) {
343 const status = Number(id)
344 if (Number.isInteger(status)) {
345 assert.strictEqual(res.status, status, `HTTP response code ${res.status} was expected to be ${status}`)
346 }
347}
348
349function validateHeaders(res, headersSchema, responseSpec) {
350 if (headersSchema) {
351 jsonSchema.validate(res.headers, headersSchema, { throwError: true })
352 }
353 if (responseSpec.header) {
354 // Check that any expect header values are indeed present.
355 const h = responseSpec.header
356 Object.keys(h)
357 .map(k => k.toLowerCase())
358 .every(k => assert.equal(`${res.headers[k]}`.toLowerCase(), `${h[k]}`.toLowerCase()))
359 }
360}
361function validateBody(res, bodySchema, responseSpec) {
362 if (bodySchema) {
363 // We can't use `throwError: true` because of a bug around anyOf and oneOf
364 // See https://github.com/tdegrunt/jsonschema/issues/119
365 // Instead we capture all errors and then throw the first one found
366 const result = jsonSchema.validate(res.data, bodySchema, { throwError: false })
367 if (result && result.errors.length) {
368 const details = result.errors
369 .slice(1)
370 .map( e => `\n\t - ${e.property} - ${e.message}`)
371 .join('')
372
373 result.errors[0].message += details
374 throw result.errors[0]
375 }
376 }
377 if (responseSpec.body) {
378 assert.deepEqual(res.data, responseSpec.body)
379 }
380}
381
382function validateExpectations(responseSpec) {
383 if (responseSpec.expect) {
384 responseSpec.expect.forEach(expectation => {
385 const assertion = Object.keys(expectation)[0]
386 const args = expectation[assertion]
387 const scope = expect(args.shift())
388 scope[assertion].apply(scope, args)
389 })
390 }
391}
392
393function validateContentType(res, op) {
394 if (res.status === 204) return
395
396 let contentType = res.headers['content-type'] || ''
397 contentType = contentType.split(';')[0].trim()
398 assert.notEqual(op.produces.indexOf(contentType), -1, `Response content type '${contentType}' was not expected`)
399}
400
401function expectsValidRequest(step) {
402 return (step.request.valid || (hasSuccessStatus(step.response) && step.request.valid !== false))
403}
404
405function hasSuccessStatus(status) {
406 status = Number(status)
407 return (Number.isInteger(status) && status >= 200 && status < 400)
408}
409
410function updateFixtures(responseSpec, options) {
411 if (responseSpec.fixtures) {
412 Object.assign(options.fixtures, responseSpec.fixtures)
413 }
414}
415
416function getJsonFile(jsonPath) {
417 if (!jsonPath || typeof jsonPath === 'object') return jsonPath || {}
418 const p = path.resolve(jsonPath)
419 if (!p || !util.existsSync(p)) return {}
420 const contents = fs.readFileSync(p, 'utf8')
421 return util.parseFileContents(contents, p) || {}
422}
423
424function prettyJson(title, obj) {
425 const MAX_LINES = 400
426 const lines = JSON.stringify(obj, null, 2).split('\n').slice(0, MAX_LINES).join('\n')
427 return `${title}\n${lines}`
428}