openapi-enforcer
Advanced tools
Comparing version 1.9.0 to 1.10.0
@@ -38,2 +38,3 @@ --- | ||
| requestBodyAllowedMethods | An `object` specifying which request methods to allow (or disallow) a request body for. The object you provide here will merge with the default value. | `object` | ` { post: true, put: true, options: true, head: true, patch: true } ` | ||
| production | A `boolean` that when set to `true` will reduce the number of validations run on your OpenAPI document. This will allow your app to load a little faster for production. Do not use this setting if you are not sure that the OpenAPI document is valid otherwise other runtime errors are sure to occur. | `boolean` | `false` | | ||
@@ -109,2 +110,17 @@ **Returns:** A Promise | ||
## Enforcer.config | ||
Set global configuration options for all Enforcer instances. | ||
| Option | Description | Default | | ||
| ------ | ----------- | ------- | | ||
| useNewRefParser | Use the custom built ref parser to resolve multi file discriminator references. This feature is currently in beta. Please report issues on github. https://github.com/byu-oit/openapi-enforcer/issues | `false` | | ||
**Example** | ||
```js | ||
const Enforcer = require('openapi-enforcer'); | ||
Enforcer.config.useNewRefParser = true; // use custom ref parser | ||
``` | ||
## Enforcer.dereference | ||
@@ -111,0 +127,0 @@ |
--- | ||
title: OpenAPI Enforcer | ||
navOrder: guide api | ||
navOrder: guide api contributing changes | ||
toc: false | ||
@@ -5,0 +5,0 @@ --- |
59
index.js
@@ -23,3 +23,4 @@ /** | ||
const Exception = require('./src/exception'); | ||
const RefParser = require('json-schema-ref-parser'); | ||
const NewRefParser = require('./src/ref-parser'); | ||
const OldRefParser = require('json-schema-ref-parser'); | ||
const Result = require('./src/result'); | ||
@@ -35,2 +36,3 @@ const Super = require('./src/super'); | ||
* @param {boolean} [options.fullResult=false] Set to true to get back a full result object with the value, warnings, and errors. | ||
* @param {boolean} [options.experimentalDereference=false] A soon to be default option for improved dereferencing. | ||
* @param {object} [options.componentOptions] Options that get sent along to components. | ||
@@ -49,22 +51,32 @@ * @returns {Promise<OpenApi|Swagger>|Promise<Result<OpenApi|Swagger>>} | ||
const refParser = new RefParser(); | ||
let exception; | ||
definition = util.copy(definition); | ||
definition = await refParser.dereference(definition); | ||
const useNewRefParser = Enforcer.config.useNewRefParser; | ||
const refParser = useNewRefParser ? new NewRefParser(definition) : new OldRefParser(); | ||
if (useNewRefParser) { | ||
const [ dereferenceValue, dereferenceErr ] = await refParser.dereference(); | ||
definition = dereferenceValue; | ||
exception = dereferenceErr; | ||
} else { | ||
definition = await refParser.dereference(definition); | ||
} | ||
let exception = Exception('One or more errors exist in the OpenAPI definition'); | ||
const hasSwagger = definition.hasOwnProperty('swagger'); | ||
if (!hasSwagger && !definition.hasOwnProperty('openapi')) { | ||
exception.message('Missing required "openapi" or "swagger" property'); | ||
if (!exception) { | ||
exception = Exception('One or more errors exist in the OpenAPI definition'); | ||
const hasSwagger = definition.hasOwnProperty('swagger'); | ||
if (!hasSwagger && !definition.hasOwnProperty('openapi')) { | ||
exception.message('Missing required "openapi" or "swagger" property'); | ||
} else { | ||
const match = /^(\d+)(?:\.(\d+))(?:\.(\d+))?$/.exec(definition.swagger || definition.openapi); | ||
if (!match) { | ||
exception.at(hasSwagger ? 'swagger' : 'openapi').message('Invalid value'); | ||
} else { | ||
const match = /^(\d+)(?:\.(\d+))(?:\.(\d+))?$/.exec(definition.swagger || definition.openapi); | ||
if (!match) { | ||
exception.at(hasSwagger ? 'swagger' : 'openapi').message('Invalid value'); | ||
} else { | ||
const major = +match[1]; | ||
const validator = major === 2 | ||
? Enforcer.v2_0.Swagger | ||
: Enforcer.v3_0.OpenApi; | ||
[ openapi, exception, warnings ] = validator(definition, refParser, options.componentOptions); | ||
} else { | ||
const major = +match[1]; | ||
const validator = major === 2 | ||
? Enforcer.v2_0.Swagger | ||
: Enforcer.v3_0.OpenApi; | ||
[ openapi, exception, warnings ] = validator(definition, refParser, options.componentOptions); | ||
} | ||
} | ||
@@ -79,5 +91,14 @@ } | ||
Enforcer.config = { | ||
useNewRefParser: false | ||
}; | ||
Enforcer.dereference = function (definition) { | ||
const refParser = new RefParser(); | ||
return refParser.dereference(definition); | ||
if (Enforcer.config.useNewRefParser) { | ||
const refParser = new NewRefParser(definition); | ||
return refParser.dereference(); | ||
} else { | ||
const refParser = new OldRefParser(); | ||
return refParser.dereference(definition); | ||
} | ||
}; | ||
@@ -84,0 +105,0 @@ |
{ | ||
"name": "openapi-enforcer", | ||
"version": "1.9.0", | ||
"version": "1.10.0", | ||
"description": "Library for validating, parsing, and formatting data against open api schemas.", | ||
@@ -56,4 +56,5 @@ "main": "index.js", | ||
"dependencies": { | ||
"axios": "^0.19.2", | ||
"json-schema-ref-parser": "^6.0.1" | ||
} | ||
} |
@@ -27,2 +27,3 @@ /** | ||
const definition = parent.definition[key]; | ||
const development = !parent.production; | ||
@@ -45,3 +46,3 @@ const definitionType = util.getDefinitionType(definition); | ||
defToInstanceMap: parent.defToInstanceMap, | ||
exception: parent.exception.at(key), | ||
exception: development ? parent.exception.at(key) : parent.exception, | ||
key, | ||
@@ -55,2 +56,3 @@ major: parent.major, | ||
plugins: parent.plugins, | ||
production: parent.production, | ||
refParser: parent.refParser, | ||
@@ -62,3 +64,3 @@ result, | ||
validator, | ||
warn: parent.warn.at(key), | ||
warn: development ? parent.warn.at(key) : parent.warn, | ||
}; | ||
@@ -68,3 +70,3 @@ } | ||
function normalize (data) { | ||
const { definitionType, exception, result } = data; | ||
const { definitionType, exception, production, result } = data; | ||
let definition = data.definition; | ||
@@ -87,159 +89,198 @@ | ||
// if enum is invalid then exit | ||
if (validator.enum) { | ||
const matches = fn(validator.enum, data); | ||
if (!matches.includes(definition)) { | ||
matches.length === 1 | ||
? exception.message('Value must be ' + util.smart(matches[0]) + '. Received: ' + util.smart(definition)) | ||
: exception.message('Value must be one of: ' + matches.join(', ') + '. Received: ' + util.smart(definition)); | ||
if (!production) { | ||
// if enum is invalid then exit | ||
if (validator.enum) { | ||
const matches = fn(validator.enum, data); | ||
if (!matches.includes(definition)) { | ||
matches.length === 1 | ||
? exception.message('Value must be ' + util.smart(matches[0]) + '. Received: ' + util.smart(definition)) | ||
: exception.message('Value must be one of: ' + matches.join(', ') + '. Received: ' + util.smart(definition)); | ||
} | ||
} | ||
} | ||
if (definitionType === 'array' && !freeForm) { | ||
definition.forEach((def, i) => { | ||
const child = childData(data, i, validator.items); | ||
result.push(runChildValidator(child)); | ||
}); | ||
Object.freeze(result); | ||
if (definitionType === 'array' && !freeForm) { | ||
definition.forEach((def, i) => { | ||
const child = childData(data, i, validator.items); | ||
result.push(runChildValidator(child)); | ||
}); | ||
Object.freeze(result); | ||
} else if (definitionType === 'object' && !freeForm) { | ||
const missingRequired = []; | ||
const notAllowed = []; | ||
const unknownKeys = []; | ||
} else if (definitionType === 'object' && !freeForm) { | ||
const missingRequired = []; | ||
const notAllowed = []; | ||
const unknownKeys = []; | ||
if (validator === true) { | ||
Object.keys(definition).forEach(key => { | ||
Object.defineProperty(result, key, { | ||
configurable: true, | ||
enumerable: true, | ||
value: definition[key] | ||
if (validator === true) { | ||
Object.keys(definition).forEach(key => { | ||
Object.defineProperty(result, key, { | ||
configurable: true, | ||
enumerable: true, | ||
value: definition[key] | ||
}); | ||
}); | ||
}); | ||
// Object.assign(result, util.copy(definition)); | ||
// Object.assign(result, util.copy(definition)); | ||
} else if (validator === false) { | ||
notAllowed.push.apply(notAllowed, Object.keys(definition)); | ||
} else if (validator === false) { | ||
notAllowed.push.apply(notAllowed, Object.keys(definition)); | ||
} else if (validator.additionalProperties) { | ||
Object.keys(definition).forEach(key => { | ||
const child = childData(data, key, validator.additionalProperties); | ||
const keyValidator = EnforcerRef.isEnforcerRef(child.validator) | ||
? child.validator.config || {} | ||
: child.validator; | ||
} else if (validator.additionalProperties) { | ||
Object.keys(definition).forEach(key => { | ||
const child = childData(data, key, validator.additionalProperties); | ||
const keyValidator = EnforcerRef.isEnforcerRef(child.validator) | ||
? child.validator.config || {} | ||
: child.validator; | ||
const allowed = keyValidator.hasOwnProperty('allowed') ? fn(keyValidator.allowed, child) : true; | ||
let valueSet = false; | ||
if (child.definition !== undefined) { | ||
if (!allowed) { | ||
notAllowed.push(key); | ||
} else if (!keyValidator.ignored || !fn(keyValidator.ignored, child)) { | ||
const allowed = keyValidator.hasOwnProperty('allowed') ? fn(keyValidator.allowed, child) : true; | ||
let valueSet = false; | ||
if (child.definition !== undefined) { | ||
if (!allowed) { | ||
notAllowed.push(key); | ||
} else if (!keyValidator.ignored || !fn(keyValidator.ignored, child)) { | ||
Object.defineProperty(result, key, { | ||
configurable: true, | ||
enumerable: true, | ||
value: runChildValidator(child) | ||
}); | ||
valueSet = true; | ||
} | ||
} | ||
if (valueSet && keyValidator.errors && keyValidator !== child.validator) { | ||
const d = Object.assign({}, child); | ||
d.definition = result[key]; | ||
fn(keyValidator.errors, d); | ||
} | ||
}); | ||
} else { | ||
// organize definition properties | ||
Object.keys(definition).forEach(key => { | ||
if (rxExtension.test(key)) { | ||
Object.defineProperty(result, key, { | ||
configurable: true, | ||
enumerable: true, | ||
value: runChildValidator(child) | ||
}); | ||
valueSet = true; | ||
value: definition[key] }); | ||
} else { | ||
unknownKeys.push(key); | ||
} | ||
} | ||
}); | ||
if (valueSet && keyValidator.errors && keyValidator !== child.validator) { | ||
const d = Object.assign({}, child); | ||
d.definition = result[key]; | ||
fn(keyValidator.errors, d); | ||
} | ||
}); | ||
// get sorted array of all properties to use | ||
const properties = mapAndSortValidatorProperties(validator, data); | ||
} else { | ||
// iterate through all known properties | ||
properties.forEach(prop => { | ||
const data = prop.data; | ||
const key = data.key; | ||
const validator = data.validator; | ||
const keyValidator = EnforcerRef.isEnforcerRef(validator) | ||
? validator.config || {} | ||
: validator; | ||
const allowed = keyValidator.hasOwnProperty('allowed') ? fn(keyValidator.allowed, data) : true; | ||
util.arrayRemoveItem(unknownKeys, key); | ||
// organize definition properties | ||
Object.keys(definition).forEach(key => { | ||
if (rxExtension.test(key)) { | ||
Object.defineProperty(result, key, { | ||
configurable: true, | ||
enumerable: true, | ||
value: definition[key] }); | ||
} else { | ||
unknownKeys.push(key); | ||
} | ||
}); | ||
// set default value | ||
if (data.definition === undefined && allowed && keyValidator.hasOwnProperty('default')) { | ||
data.definition = fn(keyValidator.default, data); | ||
data.usedDefault = true; | ||
data.parent.definition[key] = data.definition; | ||
data.definitionType = util.getDefinitionType(data.definition); | ||
} | ||
// get sorted array of all properties to use | ||
const properties = Object.keys(validator.properties || {}) | ||
.map(key => { | ||
const property = validator.properties[key]; | ||
util.arrayRemoveItem(unknownKeys, key); | ||
return { | ||
data: childData(data, key, property), | ||
weight: property.weight || 0 | ||
if (data.definition !== undefined) { | ||
if (!allowed) { | ||
notAllowed.push(key); | ||
} else if (!keyValidator.ignored || !fn(keyValidator.ignored, data)) { | ||
Object.defineProperty(result, key, { | ||
configurable: true, | ||
enumerable: true, | ||
value: runChildValidator(data) | ||
}); | ||
} | ||
} else if (allowed && keyValidator.required && fn(keyValidator.required, data)) { | ||
missingRequired.push(key); | ||
} | ||
}); | ||
properties.sort((a, b) => { | ||
if (a.weight < b.weight) return -1; | ||
if (a.weight > b.weight) return 1; | ||
return a.data.key < b.data.key ? -1 : 1; | ||
}); | ||
} | ||
// iterate through all known properties | ||
properties.forEach(prop => { | ||
const data = prop.data; | ||
const key = data.key; | ||
const validator = data.validator; | ||
const keyValidator = EnforcerRef.isEnforcerRef(validator) | ||
? validator.config || {} | ||
: validator; | ||
const allowed = keyValidator.hasOwnProperty('allowed') ? fn(keyValidator.allowed, data) : true; | ||
// report any keys that are not allowed | ||
notAllowed.push.apply(notAllowed, unknownKeys); | ||
if (notAllowed.length) { | ||
notAllowed.sort(); | ||
exception.message('Propert' + (notAllowed.length === 1 ? 'y' : 'ies') + ' not allowed: ' + notAllowed.join(', ')); | ||
} | ||
// set default value | ||
if (data.definition === undefined && allowed && keyValidator.hasOwnProperty('default')) { | ||
data.definition = fn(keyValidator.default, data); | ||
data.usedDefault = true; | ||
data.parent.definition[key] = data.definition; | ||
data.definitionType = util.getDefinitionType(data.definition); | ||
// report missing required properties | ||
if (missingRequired.length) { | ||
missingRequired.sort(); | ||
exception.message('Missing required propert' + (missingRequired.length === 1 ? 'y' : 'ies') + ': ' + missingRequired.join(', ')); | ||
} | ||
} else if (!freeForm) { | ||
switch (definitionType) { | ||
case 'boolean': | ||
case 'null': | ||
case 'number': | ||
case 'string': | ||
data.result = definition; | ||
mapSetResult(data, definition); | ||
break; | ||
default: | ||
exception.message('Unknown data type provided'); | ||
break; | ||
} | ||
} else { | ||
data.result = definition; | ||
mapSetResult(data, definition); | ||
} | ||
} else { | ||
if (definitionType === 'array') { | ||
definition.forEach((def, i) => { | ||
const child = childData(data, i, validator.items); | ||
result.push(runChildValidator(child)); | ||
}); | ||
Object.freeze(result); | ||
} else if (definitionType === 'object') { | ||
Object.keys(definition).forEach(key => { | ||
let childValidator; | ||
if (typeof validator !== 'object') { | ||
childValidator = validator; | ||
} else if (validator.properties && validator.properties.hasOwnProperty(key)) { | ||
childValidator = validator.properties[key] | ||
} else if (validator.additionalProperties) { | ||
childValidator = validator.additionalProperties; | ||
} | ||
const child = childData(data, key, childValidator); | ||
Object.defineProperty(result, key, { | ||
configurable: true, | ||
enumerable: true, | ||
value: runChildValidator(child) | ||
}); | ||
}); | ||
if (data.definition !== undefined) { | ||
if (!allowed) { | ||
notAllowed.push(key); | ||
} else if (!keyValidator.ignored || !fn(keyValidator.ignored, data)) { | ||
// set defaults where appropriate | ||
const properties = mapAndSortValidatorProperties(validator, data); | ||
properties.forEach(({ data }) => { | ||
const key = data.key; | ||
if (!result.hasOwnProperty(key)) { | ||
const cValidator = data.validator; | ||
const keyValidator = EnforcerRef.isEnforcerRef(cValidator) | ||
? cValidator.config || {} | ||
: cValidator; | ||
const allowed = keyValidator.hasOwnProperty('allowed') ? fn(keyValidator.allowed, data) : true; | ||
if (allowed && keyValidator.hasOwnProperty('default')) { | ||
const value = fn(keyValidator.default, data); | ||
Object.defineProperty(result, key, { | ||
configurable: true, | ||
enumerable: true, | ||
value: runChildValidator(data) | ||
value | ||
}); | ||
} | ||
} else if (allowed && keyValidator.required && fn(keyValidator.required, data)) { | ||
missingRequired.push(key); | ||
} | ||
}); | ||
}) | ||
} else { | ||
data.result = definition; | ||
mapSetResult(data, definition); | ||
} | ||
// report any keys that are not allowed | ||
notAllowed.push.apply(notAllowed, unknownKeys); | ||
if (notAllowed.length) { | ||
notAllowed.sort(); | ||
exception.message('Propert' + (notAllowed.length === 1 ? 'y' : 'ies') + ' not allowed: ' + notAllowed.join(', ')); | ||
} | ||
// report missing required properties | ||
if (missingRequired.length) { | ||
missingRequired.sort(); | ||
exception.message('Missing required propert' + (missingRequired.length === 1 ? 'y' : 'ies') + ': ' + missingRequired.join(', ')); | ||
} | ||
} else if (!freeForm) { | ||
switch (definitionType) { | ||
case 'boolean': | ||
case 'null': | ||
case 'number': | ||
case 'string': | ||
data.result = definition; | ||
mapSetResult(data, definition); | ||
break; | ||
default: | ||
exception.message('Unknown data type provided'); | ||
break; | ||
} | ||
} else { | ||
data.result = definition; | ||
mapSetResult(data, definition); | ||
} | ||
@@ -256,3 +297,3 @@ | ||
// run custom error validation check | ||
if (validator.errors) { | ||
if (!production && validator.errors) { | ||
const d = Object.assign({}, data); | ||
@@ -292,2 +333,19 @@ d.definition = deserialized; | ||
function mapAndSortValidatorProperties (validator, data) { | ||
const properties = Object.keys(validator.properties || {}) | ||
.map(key => { | ||
const property = validator.properties[key]; | ||
return { | ||
data: childData(data, key, property), | ||
weight: property.weight || 0 | ||
} | ||
}); | ||
properties.sort((a, b) => { | ||
if (a.weight < b.weight) return -1; | ||
if (a.weight > b.weight) return 1; | ||
return a.data.key < b.data.key ? -1 : 1; | ||
}); | ||
return properties; | ||
} | ||
function mapGetResult (data) { | ||
@@ -340,3 +398,3 @@ const { definition, map, validator } = data; | ||
const validator = fn(data.validator, data); | ||
if (validator.type && definition !== undefined) { | ||
if (!data.production && validator.type && definition !== undefined) { | ||
// get valid types | ||
@@ -343,0 +401,0 @@ let matches = fn(validator.type, data); |
@@ -20,2 +20,3 @@ /** | ||
const Exception = require('../exception'); | ||
const NewRefParser = require('../ref-parser'); | ||
const Result = require('../result'); | ||
@@ -242,2 +243,4 @@ const runDeserialize = require('../schema/deserialize'); | ||
if (major === 3 && refParser && discriminator && discriminator.mapping) { | ||
const useNewRefParser = refParser instanceof NewRefParser; | ||
const schemaDef = data.definition; | ||
plugins.push(() => { | ||
@@ -247,6 +250,15 @@ const instanceMap = this.enforcerData.defToInstanceMap; | ||
const value = discriminator.mapping[key]; | ||
const ref = rxHttp.test(value) || value.indexOf('/') !== -1 | ||
? value | ||
: '#/components/schemas/' + value; | ||
const definition = refParser.$refs.get(ref); | ||
let definition; | ||
if (useNewRefParser) { | ||
const ref = rxHttp.test(value) || value.indexOf('/') !== -1 | ||
? value | ||
: '#/components/schemas/' + value; | ||
const sourceNode = refParser.getSourceNode(schemaDef); | ||
definition = refParser.resolvePath(sourceNode, ref); | ||
} else { | ||
const ref = rxHttp.test(value) || value.indexOf('/') !== -1 | ||
? value | ||
: '#/components/schemas/' + value; | ||
definition = refParser.$refs.get(ref); | ||
} | ||
setProperty(discriminator.mapping, key, instanceMap.get(definition)); | ||
@@ -478,9 +490,24 @@ }); | ||
let schema; | ||
try { | ||
const ref = rxHttp.test(result) || result.indexOf('/') !== -1 | ||
? result | ||
: '#/components/schemas/' + result; | ||
schema = refParser.$refs.get(ref) | ||
} catch (err) { | ||
exception.message('Reference cannot be resolved: ' + result); | ||
if (refParser instanceof NewRefParser) { | ||
try { | ||
const ref = rxHttp.test(result) || result.indexOf('/') !== -1 | ||
? result | ||
: '#/components/schemas/' + result; | ||
const sourceNode = refParser.getSourceNode(parent.definition); | ||
schema = refParser.resolvePath(sourceNode, ref); | ||
} catch (err) { | ||
exception.message('Reference cannot be resolved: ' + result); | ||
} | ||
} else { | ||
try { | ||
const ref = rxHttp.test(result) || result.indexOf('/') !== -1 | ||
? result | ||
: '#/components/schemas/' + result; | ||
schema = refParser.$refs.get(ref) | ||
} catch (err) { | ||
const extra = '. If you are using multiple files to define your OpenAPI document then this ' + | ||
'may be a limitation of the original dereference function. You can try the ' + | ||
'custom reference parser (in beta) to see if this resolves the issue.'; | ||
exception.message('Reference cannot be resolved: ' + result + extra); | ||
} | ||
} | ||
@@ -487,0 +514,0 @@ |
@@ -79,4 +79,5 @@ /** | ||
function build (result, definition, refParser, options) { | ||
function build (result, definition, refParser, options = {}) { | ||
const isStart = !definitionValidator.isValidatorState(definition); | ||
const needsValidation = true; | ||
@@ -90,2 +91,3 @@ // normalize options | ||
options.apiSuggestions = options.hasOwnProperty('apiSuggestions') ? !!options.apiSuggestions : true; | ||
options.production = !!options.production; | ||
options.exceptionSkipCodes = options.hasOwnProperty('exceptionSkipCodes') | ||
@@ -122,2 +124,3 @@ ? options.exceptionSkipCodes.reduce((p, c) => { | ||
plugins: [], | ||
production: options.production, | ||
refParser, | ||
@@ -151,2 +154,3 @@ result, | ||
if (util.isPlainObject(data.definition)) { | ||
if (!needsValidation) data.validator = true; | ||
definitionValidator(data); | ||
@@ -153,0 +157,0 @@ } else { |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
1460004
173
19743
2
4
2
+ Addedaxios@^0.19.2
+ Addedaxios@0.19.2(transitive)
+ Addedfollow-redirects@1.5.10(transitive)