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 | })
|
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 |
|
165 | function 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 |
|
174 | function disableSpecsFile(op, options) {
|
175 | return fileSpecs.disableSpec(op, options)
|
176 | }
|
177 |
|
178 | function requireSpecsFile(op, options) {
|
179 | const fileInfo = fileSpecs.enableSpec(op, options)
|
180 | const data = getJsonFile(fileInfo.path)
|
181 | return resolveImports(data)
|
182 | }
|
183 |
|
184 | function 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 |
|
205 | function 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 |
|
215 | function 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 |
|
237 | function 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 |
|
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 |
|
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 |
|
271 | function 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 |
|
291 | function 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 |
|
305 | function 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 |
|
325 | function 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 |
|
333 | function 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 |
|
340 | function validateHeaders(res, headersSchema, responseSpec) {
|
341 | if (headersSchema) {
|
342 | jsonSchema.validate(res.headers, headersSchema, { throwError: true })
|
343 | }
|
344 | if (responseSpec.header) {
|
345 |
|
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 | }
|
352 | function validateBody(res, bodySchema, responseSpec) {
|
353 | if (bodySchema) {
|
354 |
|
355 |
|
356 |
|
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 |
|
367 | function 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 |
|
378 | function 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 |
|
386 | function expectsValidRequest(step) {
|
387 | return (step.request.valid || (hasSuccessStatus(step.response) && step.request.valid !== false))
|
388 | }
|
389 |
|
390 | function hasSuccessStatus(status) {
|
391 | status = Number(status)
|
392 | return (Number.isInteger(status) && status >= 200 && status < 400)
|
393 | }
|
394 |
|
395 | function updateFixtures(responseSpec, options) {
|
396 | if (responseSpec.fixtures) {
|
397 | Object.assign(options.fixtures, responseSpec.fixtures)
|
398 | }
|
399 | }
|
400 |
|
401 | function 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 |
|
409 | function 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 | }
|