1 | 'use strict'
|
2 |
|
3 | const Options = require('./options')
|
4 | const fileSpecs = require('./fileSpecs')
|
5 | const util = require('./util')
|
6 | const fs = require('fs')
|
7 | const path = require('path')
|
8 | const assert = require('assert')
|
9 | const jsonSchema = require('jsonschema')
|
10 | const url = require('url')
|
11 | const request = require('axios')
|
12 | const swaggerSpec = require('./swaggerSpec')
|
13 | const expect = require('expect')
|
14 |
|
15 | const ONLY_SPEC_MARKER = '+'
|
16 | const ONLY_VERBOSE_SPEC_MARKER = 'v+'
|
17 |
|
18 | module.exports = apiSpecs
|
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 |
|
26 |
|
27 |
|
28 |
|
29 |
|
30 |
|
31 |
|
32 |
|
33 |
|
34 |
|
35 |
|
36 |
|
37 |
|
38 |
|
39 |
|
40 |
|
41 |
|
42 | function 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 |
|
53 | function 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 |
|
72 | function 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 |
|
83 | function 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 |
|
92 | function 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 |
|
102 | function 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 |
|
123 | function 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 |
|
137 | function getStepOperation(step, operations, primaryOp) {
|
138 | return operations.find(opt => opt.id === step.request.operationId) || primaryOp
|
139 | }
|
140 |
|
141 | function 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 |
|
168 | function 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 |
|
177 | function disableSpecsFile(op, options) {
|
178 | return fileSpecs.disableSpec(op, options)
|
179 | }
|
180 |
|
181 | function requireSpecsFile(op, options) {
|
182 | const fileInfo = fileSpecs.enableSpec(op, options)
|
183 | const data = getJsonFile(fileInfo.path)
|
184 | return resolveImports(data)
|
185 | }
|
186 |
|
187 | function 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 |
|
208 | function 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 |
|
218 | function 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 |
|
240 | function 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 |
|
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 |
|
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 |
|
274 | function 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 |
|
294 | function 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 |
|
308 | function 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 |
|
328 | function getResponseSpec(spec, acc, options) {
|
329 | if (typeof spec.response === 'object') {
|
330 |
|
331 |
|
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 |
|
342 | function 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 |
|
349 | function validateHeaders(res, headersSchema, responseSpec) {
|
350 | if (headersSchema) {
|
351 | jsonSchema.validate(res.headers, headersSchema, { throwError: true })
|
352 | }
|
353 | if (responseSpec.header) {
|
354 |
|
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 | }
|
361 | function validateBody(res, bodySchema, responseSpec) {
|
362 | if (bodySchema) {
|
363 |
|
364 |
|
365 |
|
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 |
|
382 | function 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 |
|
393 | function 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 |
|
401 | function expectsValidRequest(step) {
|
402 | return (step.request.valid || (hasSuccessStatus(step.response) && step.request.valid !== false))
|
403 | }
|
404 |
|
405 | function hasSuccessStatus(status) {
|
406 | status = Number(status)
|
407 | return (Number.isInteger(status) && status >= 200 && status < 400)
|
408 | }
|
409 |
|
410 | function updateFixtures(responseSpec, options) {
|
411 | if (responseSpec.fixtures) {
|
412 | Object.assign(options.fixtures, responseSpec.fixtures)
|
413 | }
|
414 | }
|
415 |
|
416 | function 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 |
|
424 | function 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 | }
|