swagger-routes
Version:
Generate Express or Restify route handlers from a Swagger specification
404 lines (362 loc) • 13.2 kB
JavaScript
const Options = require('./options')
const fileSpecs = require('./fileSpecs')
const util = require('./util')
const fs = require('fs')
const path = require('path')
const assert = require('assert')
const jsonSchema = require('jsonschema')
const url = require('url')
const request = require('axios')
const swaggerSpec = require('./swaggerSpec')
const expect = require('expect')
const ONLY_SPEC_MARKER = '+'
const ONLY_VERBOSE_SPEC_MARKER = 'v+'
module.exports = apiSpecs
/**
* Generates a suite of Mocha test specifications for each of your Swagger api operations.
*
* Both request and response of an operation call are validated for conformity
* with your Swagger document.
*
* You'll need to depend on and set up Mocha in your project yourself.
*
* @param {object} options
* - `api` path to your Swagger spec, or the loaded spec reference.
* - `host` server host + port where your tests will run e.g. `localhost:3453`.
* - `specs` path to specs dir, or function to return the set of specs for an operation.
* - `maxTimeout` maximum time a test can take to complete.
* - `slowTime` time taken before a test is marked slow. Defaults to 1 second.
* - `startServer(done)` function called before all tests where you can start your local server.
* - `stopServer(done)`function called after all tests where you can stop your local server.
* - `fixtures`: path to a yaml file with test fixtures.
* - `sortByStatus`: Sort specs by response status, lowest to highest. Defaults to true.
* - `prefetch`: path to a yaml file with requests to prefetch values into fixtures before
* executing specs, e.g. auth tokens
* @return {void}
*/
function apiSpecs(options) {
options = Options.applyDefaultSpecOptions(options)
const api = swaggerSpec.getSpecSync(options.api)
const operations = swaggerSpec.getAllOperations(api)
options.fixtures = getJsonFile(options.fixtures)
describeApi(api, operations, options)
}
function describeApi(api, operations, options) {
describe(api.info.title, function () {
this.slow(options.slowTime || 1500)
this.timeout(options.maxTimeout || 10000)
before(done => {
options.startServer(e => {
if (e) done(e)
else prefetch(operations, options).then(() => done(), e => done(e))
}, options)
})
after(done => {
fileSpecs.disableOldSpecs(operations, options)
options.stopServer(done, options)
})
describeOperations(operations, options)
})
}
function prefetch(operations, options) {
const prefetch = getJsonFile(options.prefetch)
return Promise.all(Object.keys(prefetch).map(id => {
const data = prefetch[id]
const steps = Array.isArray(data) ? data : [ data ]
const specInfo = getSpecInfo(id)
return runSteps(steps, {}, operations, specInfo, options)
.catch(e => { e.message = `${e.message} in '${id}'`; throw e })
}))
}
function describeOperations(operations, options) {
operations.forEach(op => {
const description = `${op.method.toUpperCase()}: ${op.path} (${op.id})`
describe(description, () => {
describeOperationSpecs(op, operations, options)
})
})
}
function describeOperationSpecs(op, operations, options) {
const specs = getSpecs(op, options)
normalizeSpecs(specs, options).forEach(spec => {
const specInfo = getSpecInfo(spec.id)
specInfo.it(specInfo.summary, () => {
return runSteps(spec.steps, op, operations, specInfo, options)
})
})
}
function normalizeSpecs(specs, options) {
const normSpecs = Object.keys(specs)
.map(id => {
const spec = specs[id]
const steps = Array.isArray(spec) ? spec : [ spec ]
steps.forEach(step => {
if (step.response && typeof step.response !== 'object') {
step.response = { status: step.response }
}
})
const lastStep = steps[steps.length - 1]
if (lastStep.response === undefined) throw new Error(`Missing response status for spec: '${id}`)
return { id, steps, lastStep }
})
if (options.sortByStatus) {
normSpecs.sort((a, b) => a.lastStep.response.status - b.lastStep.response.status)
}
return normSpecs
}
function runSteps(steps, op, operations, specInfo, options) {
const fixtures = { fixtures: options.fixtures }
return steps.reduce((prev, step, i) => {
return prev.then(acc => {
step = util.resolveSchemaRefs(step, fixtures)
step.index = i
if (!step.request) step.request = {}
if (!step.response) step.response = {}
const stepOp = getStepOperation(step, operations, op)
return runStep(step, stepOp, specInfo, options, acc)
})
}, Promise.resolve([]))
}
function getStepOperation(step, operations, primaryOp) {
return operations.find(opt => opt.id === step.request.operationId) || primaryOp
}
function runStep(step, op, specInfo, options, acc) {
if (!acc) acc = []
const req = createRequest(op, step.request, options, acc)
if (expectsValidRequest(step)) {
validateRequest(req, step, op)
}
return request(req)
.then(res => {
acc.push({ req, res })
if (specInfo.verbose) {
const msg = `[${step.index}]${specInfo.summary}\n` +
`${prettyJson('Request:', req)}\n${prettyJson('Response:', res)}`
console.log(msg)
}
return res
})
.then(
res => validateResponse(req, res, step, op, options, acc),
res => validateResponse(req, res, step, op, options, acc)
)
.then(() => acc)
}
function getSpecs(op, options) {
let specs
if (typeof options.specs.create === 'function') specs = options.specs.create(op)
if (!specs) specs = op['x-specs']
if (specs) disableSpecsFile(op, options)
else specs = requireSpecsFile(op, options)
return specs || {}
}
function disableSpecsFile(op, options) {
return fileSpecs.disableSpec(op, options)
}
function requireSpecsFile(op, options) {
const fileInfo = fileSpecs.enableSpec(op, options)
const data = getJsonFile(fileInfo.path)
return resolveImports(data)
}
function resolveImports(root) {
let imports = (root.imports || []).slice()
delete root.imports
const map = new Map()
const results = []
while (imports.length) {
const p = imports.shift()
if (!map.has(p)) {
const data = getJsonFile(p)
map.set(p, data)
results.push(data)
if (data.imports) {
imports = imports.concat(data.imports)
delete data.imports
}
}
}
results.forEach(value => Object.assign(root, value))
return root
}
function getSpecInfo(id) {
if (id.startsWith(ONLY_SPEC_MARKER)) {
return { it: it.only, summary: id.substr(ONLY_SPEC_MARKER.length).trim(), verbose: false }
} else if (id.startsWith(ONLY_VERBOSE_SPEC_MARKER)) {
return { it: it.only, summary: id.substr(ONLY_VERBOSE_SPEC_MARKER.length).trim(), verbose: true }
} else {
return { it, summary: id.trim(), verbose: false }
}
}
function createRequest(op, testReqData, options, acc) {
testReqData = populateProperties(testReqData, acc, options)
let pathname = op.fullPath
if (testReqData.path) {
pathname = Object.keys(testReqData.path)
.reduce((p, t) =>
p.replace(new RegExp(`{${t}}`, 'g'), testReqData.path[t]), pathname)
}
return {
url: url.format({
protocol: 'http',
host: options.host,
pathname
}),
method: op.method,
headers: testReqData.header || {},
params: testReqData.query,
data: testReqData.body
}
}
function populateProperties(source, acc, options) {
if (Array.isArray(source)) {
source.forEach((v, i) => source[i] = populateProperties(v, acc, options))
} else if (typeof source === 'object') {
Object.keys(source || {}).forEach(key => source[key] = populateProperties(source[key], acc, options))
} else if (typeof source === 'string') {
const stepAcc = { step: acc, fixtures: options.fixtures }
if (source.startsWith('step[')) {
return parseProperty(source.split('.'), stepAcc)
} else {
const TOKEN_REGEX = /\$\{((step\[\d+\]|fixtures)[\w\[\d\]\.]+)\}/g
let tokenMatch = source.match(TOKEN_REGEX)
if (tokenMatch) {
// if the token isn't nested in a string then we return the raw value
if (tokenMatch.length === 1 && tokenMatch[0] === source) {
const path = tokenMatch[0].slice(2, -1)
source = parseProperty(path.split('.'), stepAcc)
} else {
// otherwise replace each token in the string
while (tokenMatch) {
source = tokenMatch.reduce((str, token) => {
const path = token.slice(2, -1)
const value = parseProperty(path.split('.'), stepAcc)
return str.split(token).join(value)
}, source)
tokenMatch = source.match(TOKEN_REGEX)
}
}
}
}
}
return source
}
function parseProperty(segments, source) {
if (!segments.length || (typeof source !== 'object' && !Array.isArray(source))) {
return source
}
const segment = segments.shift()
const arrayMatch = segment.match(/([\w]*)\[(\d+)\]$/m)
if (arrayMatch) {
const name = arrayMatch[1]
const index = Number(arrayMatch[2])
const array = name ? source[name] : source
assert.ok(Array.isArray(array), `Expected array at ${segment}`)
assert.ok(index >= 0 && index < array.length, `Invalid step index '${index}', range [0-${array.length - 1}]`)
return parseProperty(segments, source[name][index])
} else {
return parseProperty(segments, source[segment])
}
}
function validateRequest(req, spec, op) {
const groupSchema = op.paramGroupSchemas
swaggerSpec.PARAM_GROUPS.forEach(groupId => {
if (groupSchema[groupId]) {
try {
jsonSchema.validate(spec.request[groupId], groupSchema[groupId], { throwError: true })
} catch(e) {
e.message = `${e.toString()}\n${prettyJson('Request:', req)}`
throw e
}
}
})
}
function validateResponse(req, res, spec, op, options, acc) {
const responseSpec = getResponseSpec(spec, acc, options)
const responseSchema = op.responseSchemas[responseSpec.status]
assert.ok(responseSchema, `No response schema found for response status '${responseSpec.status}'`)
try {
validateStatus(res, responseSchema.id)
validateHeaders(res, responseSchema.headersSchema, responseSpec)
validateBody(res, responseSchema.bodySchema, responseSpec)
validateExpectations(responseSpec)
validateContentType(res, op)
updateFixtures(responseSpec, options)
} catch (e) {
e.message = `${e.toString()}\n${prettyJson('Request:', req)}\n${prettyJson('Response:', res)}`
throw e
}
return res
}
function getResponseSpec(spec, acc, options) {
if (typeof spec.response === 'object') {
return populateProperties(spec.response, acc, options)
} else {
return { status: spec.response }
}
}
function validateStatus(res, id) {
const status = Number(id)
if (Number.isInteger(status)) {
assert.strictEqual(res.status, status, `HTTP response code ${res.status} was expected to be ${status}`)
}
}
function validateHeaders(res, headersSchema, responseSpec) {
if (headersSchema) {
jsonSchema.validate(res.headers, headersSchema, { throwError: true })
}
if (responseSpec.header) {
// Check that any expect header values are indeed present.
const h = responseSpec.header
Object.keys(h)
.map(k => k.toLowerCase())
.every(k => assert.equal(`${res.headers[k]}`.toLowerCase(), `${h[k]}`.toLowerCase()))
}
}
function validateBody(res, bodySchema, responseSpec) {
if (bodySchema) {
jsonSchema.validate(res.data, bodySchema, { throwError: true })
}
if (responseSpec.body) {
assert.deepEqual(res.data, responseSpec.body)
}
}
function validateExpectations(responseSpec) {
if (responseSpec.expect) {
responseSpec.expect.forEach(expectation => {
const assertion = Object.keys(expectation)[0]
const args = expectation[assertion]
const scope = expect(args.shift())
scope[assertion].apply(scope, args)
})
}
}
function validateContentType(res, op) {
if (res.status === 204) return
const contentType = res.headers['content-type']
assert.notEqual(op.produces.indexOf(contentType), -1, `Response content type '${contentType}' was not expected`)
}
function expectsValidRequest(step) {
return (step.request.valid || (hasSuccessStatus(step.response) && step.request.valid !== false))
}
function hasSuccessStatus(status) {
status = Number(status)
return (Number.isInteger(status) && status >= 200 && status < 400)
}
function updateFixtures(responseSpec, options) {
if (responseSpec.fixtures) {
Object.assign(options.fixtures, responseSpec.fixtures)
}
}
function getJsonFile(jsonPath) {
if (!jsonPath || typeof jsonPath === 'object') return jsonPath || {}
const p = path.resolve(jsonPath)
if (!p || !util.existsSync(p)) return {}
const contents = fs.readFileSync(p, 'utf8')
return util.parseFileContents(contents, p) || {}
}
function prettyJson(title, obj) {
const MAX_LINES = 400
const lines = JSON.stringify(obj, null, 2).split('\n').slice(0, MAX_LINES).join('\n')
return `${title}\n${lines}`
}