/**
* @copyright Copyright 2017-2019 Kevin Locke <kevin@kevinlocke.name>
* @license MIT
* @module modulename
*/
'use strict';
const fs = require('fs');
const http = require('http');
const https = require('https');
// stream.Writable (and therefore http.ClientRequest) accept any Uint8Array
const { types: { isUint8Array } } = require('util');
const packageJson = require('./package.json');
/** @exports swagger-spec-validator */
const swaggerSpecValidator = {};
/** JSON Content-Type accepted by validator.swagger.io.
*
* @constant
* @private
*/
const JSON_CONTENT_TYPE = 'application/json';
/** YAML Content-Type accepted by validator.swagger.io.
* See https://github.com/swagger-api/validator-badge/issues/136
*
* @constant
* @private
*/
const YAML_CONTENT_TYPE = 'application/yaml';
/** Default URL to which validation requests are sent.
*
* @constant
*/
const DEFAULT_URL = 'https://validator.swagger.io/validator/debug';
swaggerSpecValidator.DEFAULT_URL = DEFAULT_URL;
/** Default headers sent with API requests.
*
* @constant
*/
const DEFAULT_HEADERS = Object.freeze({
Accept: JSON_CONTENT_TYPE,
'User-Agent': `${packageJson.name}/${packageJson.version} `
+ `Node.js/${process.version.slice(1)}`,
});
swaggerSpecValidator.DEFAULT_HEADERS = DEFAULT_HEADERS;
/** Combines HTTP headers objects.
* With the capitalization and value of the last occurrence.
*
* @private
*/
function combineHeaders(...args) {
const combinedLower = {};
const combined = {};
args.reverse();
for (const headers of args) {
if (headers) {
for (const name of Object.keys(headers)) {
const nameLower = name.toLowerCase();
if (!hasOwnProperty.call(combinedLower, nameLower)) {
combinedLower[nameLower] = true;
combined[name] = headers[name];
}
}
}
}
return combined;
}
/** Reads all data from a stream.Readable.
*
* @private
* @param {!module:stream.Readable} stream Stream from which to read all data.
* @returns {string|Buffer} Data from stream, if any.
*/
function getStreamData(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream
.on('data', (chunk) => chunks.push(chunk))
.once('error', reject)
.once(
'end',
() => resolve(
chunks.length === 0 ? undefined
: Buffer.isBuffer(chunks[0]) ? Buffer.concat(chunks)
: typeof chunks[0] === 'string' ? chunks.join('')
: chunks,
),
);
});
}
/** Makes an HTTP(S) request and parses the JSON response.
*
* @private
*/
function requestJson(url, options, callback) {
const protocol = options.protocol || url.protocol;
const proto = protocol === 'https:' ? https
: protocol === 'http:' ? http
: undefined;
if (!proto) {
callback(
new Error(`Unsupported protocol "${protocol}" for validator URL`),
);
return;
}
// http.request and https.request only accept string or URL as url argument.
// This module allows url object since it is unambiguous in named options.
// If url is not a URL, combine with options.
const req =
url instanceof URL ? proto.request(url, options)
: proto.request({
...url,
...options,
});
req
.once('error', callback)
.once('response', (res) => {
res.on('error', callback);
const bodyData = [];
res.on('data', (data) => { bodyData.push(data); });
res.on('end', () => {
const resBody = Buffer.concat(bodyData);
let err, resBodyObj;
try {
resBodyObj = JSON.parse(resBody.toString());
} catch (errJson) {
err = new SyntaxError(
`Error parsing server response as JSON: ${errJson.message}`,
);
}
// Note: Could redirect using follow-redirects, axios, got, node-fetch
// No known use case, since user should update -u to avoid overhead.
// If you have a use case, feel free to open an issue.
if (res.statusCode >= 300) {
let errMessage = `HTTP ${res.statusCode}`;
if (res.statusMessage) {
errMessage += `: ${res.statusMessage}`;
}
const { location } = res.headers;
if (location) {
errMessage += `: ${location}`;
}
err = new Error(errMessage);
}
if (err) {
err.statusCode = res.statusCode;
err.statusMessage = res.statusMessage;
err.headers = res.headers;
err.trailers = res.trailers;
err.body = resBodyObj !== undefined ? resBodyObj : resBody;
callback(err);
} else {
// Use null to preserve current API.
// eslint-disable-next-line unicorn/no-null
callback(null, resBodyObj);
}
});
});
const { body } = options;
if (typeof body === 'string' || isUint8Array(body)) {
req.end(body);
} else {
body.on('error', (err) => {
req.abort();
callback(err);
});
body.pipe(req);
}
}
/** Guesses Content-Type of OpenAPI/Swagger spec data.
*
* @private
* @param {string|!Uint8Array} spec OpenAPI/Swagger API specification content.
* @returns {string} Content type of spec.
*/
function guessSpecDataContentType(spec, options) {
try {
JSON.parse(spec);
return JSON_CONTENT_TYPE;
} catch {
if (options.verbosity >= 1) {
options.err.write(
'Unable to parse spec content as JSON. Assuming YAML.\n',
);
}
return YAML_CONTENT_TYPE;
}
}
/** Guesses Content-Type of OpenAPI/Swagger spec data or stream.
*
* Note: All current versions of the OpenAPI Specification require OpenAPI
* documents to be JSON or YAML, so this function attempts to distinguish
* between only those two types.
*
* @private
* @param {string|!Uint8Array|!module:stream.Readable} spec OpenAPI/Swagger API
* specification content.
*/
function guessSpecContentType(spec, options) {
let contentType;
if (typeof spec === 'string' || isUint8Array(spec)) {
contentType = guessSpecDataContentType(spec, options);
} else if (spec.path) {
// fs.ReadStream#path is string or Buffer with file path.
if (/\.json$/i.test(spec.path)) {
contentType = JSON_CONTENT_TYPE;
} else if (/\.ya?ml$/i.test(spec.path)) {
contentType = YAML_CONTENT_TYPE;
}
}
if (contentType) {
return Promise.resolve({ contentType });
}
if (options.verbosity >= 1) {
options.err.write(
'Content-Type not specified and can\'t be inferred from filename.\n'
+ 'Reading spec content...\n',
);
}
return getStreamData(spec)
.then((specContent) => ({
contentType: guessSpecDataContentType(specContent, options),
specContent,
}));
}
/** Validation options
*
* @typedef {{
* err: (module:stream.Writable|undefined),
* request: (object|undefined),
* url: (URL|object|string|undefined),
* verbosity: (number|undefined)
* }} ValidateOptions
* @property {module:stream.Writable=} err Stream to which errors (and
* non-output status messages) are written.
* (default: <code>process.stderr</code>)
* @property {object=} request Options passed to <code>http.request()</code>.
* @property {URL|object|string=} url URL passed to <code>http.request()</code>.
* @property {number=} verbosity Amount of output to produce. Larger numbers
* produce more output.
*/
// var ValidateOptions;
/** Validates an OpenAPI/Swagger API specification.
*
* @param {string|!Uint8Array|!module:stream.Readable} spec OpenAPI/Swagger API
* specification content.
* @param {ValidateOptions=} options Validation options.
* @param {?function(Error, object=)=} callback Callback for the validation
* results object.
* @returns {Promise<object>|undefined} If <code>callback</code> is not given,
* a <code>Promise</code> with the validation results or <code>Error</code>.
*/
swaggerSpecValidator.validate =
function validate(spec, options, callback) {
if (!callback && typeof options === 'function') {
callback = options;
options = undefined;
}
if (!callback) {
return new Promise((resolve, reject) => {
validate(spec, options, (err, result) => {
if (err) { reject(err); } else { resolve(result); }
});
});
}
if (typeof callback !== 'function') {
throw new TypeError('callback must be a function');
}
try {
if (spec === undefined
|| spec === null
|| (typeof spec !== 'string'
&& !isUint8Array(spec)
&& typeof spec.pipe !== 'function')) {
throw new TypeError('spec must be a string, Uint8Array, or Readable');
}
if (options !== undefined && options !== null) {
if (typeof options !== 'object') {
throw new TypeError('options must be an object');
}
if (options.err !== undefined
&& options.err !== null
&& typeof options.err.write !== 'function') {
throw new TypeError('options.err must be a stream.Writable');
}
}
} catch (err) {
queueMicrotask(() => callback(err));
return undefined;
}
options = { ...options };
if (!options.err) {
options.err = process.stderr;
}
// Note: Options on URL object are ignored by https.request()
// Don't combine into single options object without conversion to generic obj.
const reqUrl =
!options.url ? new URL(DEFAULT_URL)
: typeof options.url === 'object' ? options.url
: new URL(options.url);
const reqOpts = {
method: 'POST',
...options.request,
body: spec,
};
reqOpts.headers =
reqOpts.headers
? combineHeaders(DEFAULT_HEADERS, reqOpts.headers || reqUrl.headers)
: { ...DEFAULT_HEADERS };
let calledBack = false;
function callbackOnce(...args) {
if (!calledBack) {
calledBack = true;
callback.apply(this, args);
}
}
let contentInfoP;
if (!Object.keys(reqOpts.headers)
.some((name) => name.toLowerCase() === 'content-type')) {
contentInfoP = guessSpecContentType(spec, options);
} else {
// Use null to preserve current API.
// eslint-disable-next-line unicorn/no-null
contentInfoP = Promise.resolve(null);
}
contentInfoP
.then((contentInfo) => {
if (contentInfo) {
const { contentType, specContent } = contentInfo;
reqOpts.headers['Content-Type'] = contentType;
if (specContent) {
reqOpts.body = specContent;
}
}
requestJson(reqUrl, reqOpts, callbackOnce);
})
.catch(callbackOnce);
return undefined;
};
/** Validates an OpenAPI/Swagger API specification file.
*
* If not specified, the Content-Type header will be set for <code>.json</code>
* and <code>.yaml</code>/<code>.yml</code> files.
*
* @param {string} specPath Path of OpenAPI/Swagger API specification file.
* @param {ValidateOptions=} options Validation options.
* @param {?function(Error, object=)=} callback Callback for the validation
* results object.
* @returns {Promise<object>|undefined} If <code>callback</code> is not given,
* a <code>Promise</code> with the validation results or <code>Error</code>.
*/
swaggerSpecValidator.validateFile =
function validateFile(specPath, options, callback) {
if (!callback && typeof options === 'function') {
callback = options;
options = undefined;
}
const specStream = fs.createReadStream(specPath);
return swaggerSpecValidator.validate(specStream, options, callback);
};
module.exports = swaggerSpecValidator;