Added Swagger

This commit is contained in:
2020-06-10 08:25:21 +02:00
parent 5c6f37eaf7
commit af76cbca87
257 changed files with 48861 additions and 12 deletions
@@ -0,0 +1,59 @@
## Semantic Validators
Ie: anything not covered by the json-schema validation
All the following belongs to the `validate` namespace
Eg: a plugin with an action ( to validate things ) and a selector, to get nodes and stuff.
```js
export function SomeAwesomePlugin() {
return {
statePlugins: {
validate: { // "validate" Namespace
actions: {
validateSomeNewValidateFunction() {}
},
selectors: {
someNewSelector() {}
}
}
}
}
}
```
### Make a new one
```js
// Under the validate namespace
export const validateOnlyFoos = () => (system) => {
system.validateSelectors.allSchemas().then(schemas => {
const errors = []
schemas.forEach( schema => {
if(schema.node.type === "array") { // `node` is the value at that point
errors.push({
level: "error",
message: "We can do something with this, array.",
path: schema.path // it'll figure out the line # from this
})
}
})
}))
}
```
### Make a selector, to later validate
We use a single traverser although its performance leaves a little to be desired.
The idea is that you provide a name and a filter function and
in turn your validators can then iterate over those "nodes" to validate them.
```js
export const allParameters = () => (system) => {
return system.fn.traverseOnce({ // Returns a promise
name: "allParameterSchemas",
fn: (node) => { // called for each node, you need to return the node if you want it in the collecction
if(system.validateSelectors.isParameter(node)) {
return true
}
},
})
}
```
@@ -0,0 +1,63 @@
import debounce from "lodash/debounce"
export const SOURCE = "semantic"
// the test system does not tolerate slowness!
const NODE_ENV = process.env.NODE_ENV
const CI = process.env.CI
const DEBOUNCE_MS = (NODE_ENV === "test" || CI === "true") ? 0 : 30
// System for buffering/batching errors
var errorCollector = []
const debNewSpecErrBatch = debounce(() => {
const system = errorCollector.system // Just a reference to the "latest" system
try {
errorCollector.forEach(obj => {
obj.line = obj.line || system.fn.AST.getLineNumberForPath(system.specSelectors.specStr(), obj.path)
obj.source = SOURCE
})
system.errActions.newSpecErrBatch(errorCollector)
delete errorCollector.system
errorCollector = [] // Clear stack
} catch(e) {
// eslint-disable-next-line no-console
console.error(e)
}
}, DEBOUNCE_MS)
const bufferedNewSpecErrBatch = (system, obj) => {
errorCollector.push(obj)
errorCollector.system = system
debNewSpecErrBatch()
}
export const all = () => system => {
if (!system.validateSelectors.shouldValidate()) {
return
}
system.validateActions.beforeValidate()
const errCb = (obj) => bufferedNewSpecErrBatch(system, obj)
system.validateSelectors.validators().forEach(name => {
const fn = system.validateActions[name]
// nothing about oas3 or swagger2
if(name.indexOf("validateAsync") === 0) {
fn(errCb) // Function send messages on its own, it won't be cached ( due to the nature of async operations )
} else {
Promise.resolve(fn())
.then(validationObjs => {
if(validationObjs) {
validationObjs.forEach(errCb)
}
})
}
})
}
export const beforeValidate = () => (system) => {
system.errActions.clear({
source: SOURCE
})
}
@@ -0,0 +1,10 @@
const HARD_CYCLE_LIMIT = 200
export function getRootNode(node) {
var i = 0
while(node.notRoot && i < HARD_CYCLE_LIMIT) {
node = node.parent
i++
}
return node || {}
}
@@ -0,0 +1,141 @@
import * as selectors from "./selectors"
import * as actions from "./actions"
import traverse from "traverse"
import {createSelector} from "reselect"
import debounce from "lodash/debounce"
import memoize from "lodash/memoize"
import * as formDataValidateActions from "./validators/form-data"
import * as schemaValidateActions from "./validators/schema"
import * as pathsValidateActions from "./validators/paths"
import * as securityValidateActions from "./validators/security"
import * as parametersValidateActions from "./validators/parameters"
import * as operationsOAS3ValidateActions from "./validators/oas3/operations"
import * as parametersOAS3ValidateActions from "./validators/oas3/parameters"
import * as componentsOAS3ValidateActions from "./validators/oas3/components"
import * as refsOAS3ValidateActions from "./validators/oas3/refs"
import * as refs2and3ValidateActions from "./validators/2and3/refs"
import * as parameters2and3ValidateActions from "./validators/2and3/parameters"
import * as paths2and3ValidateActions from "./validators/2and3/paths"
import * as schemas2and3ValidateActions from "./validators/2and3/schemas"
import * as operations2and3ValidateActions from "./validators/2and3/operations"
import * as security2and3ValidateActions from "./validators/2and3/security"
import * as tags2and3ValidateActions from "./validators/2and3/tags"
export default function SemanticValidatorsPlugin({getSystem}) {
const debAll = debounce((system) => system.validateActions.all(), 300)
const traverseOnce = makeTraverseOnce(getSystem)
return {
fn: {
traverse,
traverseOnce,
memoizedResolveSubtree: makeMemoizedResolveSubtree(getSystem())
},
statePlugins: {
spec: {
selectors: {
jsonAsJS: createSelector(
state => state.get("json"),
(spec) => spec ? spec.toJS() : null
)
},
wrapActions: {
validateSpec: (ori, system) => (...args) => {
// verify editor plugin already loaded and function is available (for tests)
if (system.specSelectors.specOrigin) {
const specOrigin = system.specSelectors.specOrigin()
if (specOrigin === "editor") {
ori(...args)
debAll(system)
}
}
}
}
},
validate: {
selectors,
actions: {
...actions,
...formDataValidateActions,
...schemaValidateActions,
...pathsValidateActions,
...securityValidateActions,
...parametersValidateActions,
...operations2and3ValidateActions,
...refs2and3ValidateActions,
...operationsOAS3ValidateActions,
...parametersOAS3ValidateActions,
...componentsOAS3ValidateActions,
...refsOAS3ValidateActions,
...parameters2and3ValidateActions,
...paths2and3ValidateActions,
...schemas2and3ValidateActions,
...security2and3ValidateActions,
...tags2and3ValidateActions
}
},
}
}
}
function makeTraverseOnce(getSystem) {
let traversers = {}
let results = {}
let deferred = null
const debTraverse = debounce(() => {
// Setup collections
for(let name in traversers) {
results[name] = []
}
const system = getSystem()
const json = system.specSelectors.jsonAsJS()
getSystem().fn.traverse(json)
.forEach(function() { // Remember: this cannot be a fat-arrow function, because we need to read "this"
for(let name in traversers) {
const fn = traversers[name]
const fnRes = fn(this)
if(fnRes) {
results[name].push(fnRes)
}
}
})
deferred.resolve(results)
deferred = null
traversers = {}
results = {}
}, 20) // 20ms might be more than enough, since most of these are called immediately (within a tick)
const defer = () => {
let d = {}
d.promise = new Promise((resolve, reject) => {
d.resolve = resolve
d.reject = reject
})
return d
}
return ({fn, name}) => {
traversers[name] = fn
deferred = deferred || defer()
debTraverse()
return deferred.promise.then( a => a[name] )
}
}
function makeMemoizedResolveSubtree(system) {
const cacheKeymaker = (obj, path) => {
return `${obj.toString()} ${path.join("<>")}`
}
return memoize(async (obj, path, opts) => {
const res = await system.fn.resolveSubtree(obj.toJS(), path, opts)
return res
}, cacheKeymaker)
}
@@ -0,0 +1,452 @@
import flatten from "lodash/flatten"
export const isVendorExt = (state,node) => node.path.some(a => a.indexOf("x-") === 0)
export const isDefinition = (state,node) => node.path[0] == "definitions" && node.path.length == 2
export const isTag = (state, node) => node.path[0] === "tags" && node.path.length === 2
export const isRootParameter = (state, node) => node.path[0] === "parameters" && node.path.length === 2
export const isPathItemParameter = (state, node) => node.path[2] === "parameters" && node.path.length === 4
export const isRootParameters = (state, node) => node.path[0] === "parameters" && node.path.length === 1
export const isPathItemParameters = (state, node) => node.path[2] === "parameters" && node.path.length === 3
export const isOperationParameters = (state, node) => node.path[3] === "parameters" && node.path.length === 4
export const isRootResponse = (state, node) => node.path[0] === "responses" && node.path.length === 2
export const isRootHeader = (state, node) => node.path[0] === "headers" && node.path.length === 2
export const isRef = (state, node) => node.key === "$ref" && typeof node.node === "string" // This selector can be fooled.
export const isRefArtifact = (state, node) => node.key === "$$ref" && typeof node.node === "string"
export const isOAS3RootRequestBody = (state, node) => node.path.length === 3 && node.path[1] === "requestBodies"
export const isOAS3OperationRequestBody = (state, node) => node.path.length === 4 && node.path[3] === "requestBody"
export const isOAS3OperationCallbackRequestBody = (state, node) => node.path.length === 8 && node.path[7] === "requestBody"
export const isOAS3RootParameter = (state, node) => node.path[0] === "components" && node.path[1] === "parameters" && node.path.length === 3
export const isOAS3RootResponse = (state, node) => node.path[0] === "components" && node.path[1] === "responses" && node.path.length === 3
export const isOAS3RootSchema = (state, node) => node.path[0] === "components" && node.path[1] === "schemas" && node.path.length === 3
export const isSubSchema = (state, node) => (sys) => {
const path = node.path
if(path.length < 3) {
return false
}
if(node.parent.key == "properties") {
if(node.parent.parent && node.parent.parent.node && node.parent.parent.node.type === "object") {
return !sys.validateSelectors.isVendorExt(node)
}
} else if(node.key === "additionalProperties") {
if(node.parent && node.parent.node && node.parent.node.type === "object") {
return !sys.validateSelectors.isVendorExt(node)
}
} else if(node.key == "items") {
if(node.parent.node && node.parent.node.type === "array") {
return !sys.validateSelectors.isVendorExt(node)
}
}
}
export const isParameter = (state, node) => (sys) => {
if(sys.validateSelectors.isVendorExt(node)) {
return false
}
return (
sys.validateSelectors.isRootParameter(node)
|| sys.validateSelectors.isOAS3RootParameter(node)
|| sys.validateSelectors.isPathItemParameter(node)
|| (node.path[0] === "paths"
&& node.path[3] === "parameters"
&& node.path.length === 5)
)
}
export const isOAS3RequestBody = (state, node) => (sys) => {
if(sys.validateSelectors.isVendorExt(node)) {
return false
}
return (
sys.validateSelectors.isOAS3RootRequestBody(node)
|| sys.validateSelectors.isOAS3OperationRequestBody(node)
|| sys.validateSelectors.isOAS3OperationCallbackRequestBody(node)
)
}
export const isParameterSchema = (state, node) => (sys) => {
if(sys.specSelectors.isOAS3 && sys.specSelectors.isOAS3()) {
// OAS3
return node.key === "schema" && sys.validateSelectors.isParameter(node.parent)
}
// parameter.x.in != body
if(sys.validateSelectors.isParameter(node) && node.node.in !== "body") {
return true
}
// parameter.x.in == body
if(node.key === "schema" && node.parent && sys.validateSelectors.isParameter(node.parent) && node.parent.node.in === "body") {
return true
}
}
export const isOAS3RequestBodySchema = (state, node) => () => {
const [key,, gpKey, ggpKey] = node.path.slice().reverse()
return key === "schema"
&& gpKey === "content"
&& ggpKey === "requestBody"
}
export const isOAS3ResponseSchema = (state, node) => () => {
const [key,, gpKey,, gggpKey] = node.path.slice().reverse()
return key === "schema"
&& gpKey === "content"
&& gggpKey === "responses"
}
export const isResponse = (state, node) => (sys) => {
const isOperationResponse = (
node.path[0] === "paths"
&& node.path[3] === "responses"
&& node.path.length === 5
&& !sys.validateSelectors.isVendorExt(node)
)
return (
isOperationResponse
|| sys.validateSelectors.isRootResponse(node)
|| sys.validateSelectors.isOAS3RootResponse(node)
)
}
export const allResponses = () => (system) => {
return system.fn.traverseOnce({
name: "allResponses",
fn: (node) => {
if(system.validateSelectors.isResponse(node)) {
return node
}
},
})
}
export const isHeader = (state, node) => (sys) => {
if(sys.validateSelectors.isVendorExt(node)) {
return false
}
return (
sys.validateSelectors.isRootHeader(node)
|| ( node.path[0] === "paths"
&& node.path[3] === "responses"
&& node.path[5] === "headers"
&& node.path.length === 7)
)
}
export const isResponseSchema = (state, node) => (sys) => {
// paths.<operation>.<method>.responses.XXX.schema
// respones.<response>.schema
if(node.key === "schema" && node.parent && sys.validateSelectors.isResponse(node.parent)) {
return true
}
}
export const allSchemas = () => (system) => {
const { validateSelectors } = system
const selectors = [
validateSelectors.allParameterSchemas(),
validateSelectors.allResponseSchemas(),
validateSelectors.allDefinitions(),
validateSelectors.allHeaders(),
validateSelectors.allSubSchemas(),
validateSelectors.allOAS3OperationSchemas()
]
return Promise.all(selectors)
.then((schemasAr) => {
return flatten(schemasAr)
})
}
export const allParameters = () => (system) => {
return system.fn.traverseOnce({
name: "allParameters",
fn: (node) => {
if(system.validateSelectors.isParameter(node)) {
return node
}
},
})
}
export const allOAS3RequestBodies = () => (system) => {
return system.fn.traverseOnce({
name: "allOAS3RequestBodies",
fn: (node) => {
if(system.validateSelectors.isOAS3RequestBody(node)) {
return node
}
},
})
}
export const allParameterArrays = () => (system) => {
return system.validateSelectors.allParameters()
.then(parameters => {
return parameters.map(node => node.parent)
.filter((node, i, arr) => {
return Array.isArray(node.node) && arr.indexOf(node) === i
})
})
}
export const allTags = () => (system) => {
return system.fn.traverseOnce({
name: "allTags",
fn: (node) => {
if(system.validateSelectors.isTag(node)) {
return node
}
},
})
}
export const allSubSchemas = () => (system) => {
return system.fn.traverseOnce({
name: "allSubSchemas",
fn: (node) => {
if(system.validateSelectors.isSubSchema(node)) {
return node
}
},
})
}
export const all$refs = () => (system) => {
return system.fn.traverseOnce({
name: "all$refs",
fn: (node) => {
if(system.validateSelectors.isRef(node)) {
return node
}
},
})
}
export const all$refArtifacts = () => (system) => {
return system.fn.traverseOnce({
name: "all$refArtifacts",
fn: (node) => {
if(system.validateSelectors.isRefArtifact(node)) {
return node
}
},
})
}
export const allDefinitions = () => (system) => {
return system.fn.traverseOnce({
name: "allDefinitions",
fn: (node) => {
if(
system.validateSelectors.isDefinition(node)
|| system.validateSelectors.isOAS3RootSchema(node)
) {
return node
}
},
})
}
export const allParameterSchemas = () => (system) => {
return system.fn.traverseOnce({
name: "allParameterSchemas",
fn: (node) => {
if(system.validateSelectors.isParameterSchema(node)) {
return node
}
},
})
}
export const allOAS3OperationSchemas = () => (system) => {
return system.fn.traverseOnce({
name: "allOAS3OperationSchemas",
fn: (node) => {
if(
system.validateSelectors.isOAS3RequestBodySchema(node)
|| system.validateSelectors.isOAS3ResponseSchema(node)
) {
return node
}
},
})
}
export const allOAS3RequestBodySchemas = () => (system) => {
return system.fn.traverseOnce({
name: "allOAS3RequestBodySchemas",
fn: (node) => {
if(
system.validateSelectors.isOAS3RequestBodySchema(node)
) {
return node
}
},
})
}
export const allHeaders = () => (system) => {
return system.fn.traverseOnce({
name: "allHeader",
fn: (node) => {
if(system.validateSelectors.isHeader(node)) {
return node
}
},
})
}
export const allResponseSchemas = () => (system) => {
return system.fn.traverseOnce({
name: "allResponseSchemas",
fn: (node) => {
if(system.validateSelectors.isResponseSchema(node)) {
return node
}
},
})
}
export const allOperations = () => (system) => {
return system.fn.traverseOnce({
name: "allOperations",
fn: (node) => {
const isOperation = (
node.path[0] == "paths"
&& node.path.length === 3
&& !system.validateSelectors.isVendorExt(node)
)
if(isOperation) {
return node
}
}
})
}
export const allPathItems = () => (system) => {
return system.fn.traverseOnce({
name: "allPathItems",
fn: (node) => {
const isPathItem = (
node.path[0] == "paths"
&& node.path.length === 2
&& !system.validateSelectors.isVendorExt(node)
)
if(isPathItem) {
return node
}
}
})
}
export const allSecurityDefinitions = () => (system) => {
return system.fn.traverseOnce({
name: "allSecurityDefinitions",
fn: (node) => {
const isSecurityDefinition = (
node.path[0] == "securityDefinitions"
&& node.path.length === 2
)
const isOAS3SecurityScheme = (
node.path[0] == "components"
&& node.path[1] == "securitySchemes"
&& node.path.length === 3
)
if(isSecurityDefinition || isOAS3SecurityScheme) {
return node
}
}
})
}
export const allSecurityRequirements = () => (system) => {
return system.fn.traverseOnce({
name: "allSecurityRequirements",
fn: (node) => {
const isGlobalSecurityRequirement = (
node.path[0] == "security"
&& node.path.length === 2
)
const isOperationSecurityRequirement = (
node.path[0] == "paths"
&& node.path[3] == "security"
&& node.path.length === 5
&& !system.validateSelectors.isVendorExt(node.parent) // ignore extension keys in path items
&& !system.validateSelectors.isVendorExt(node.parent.parent.parent) // ignore extension keys in "paths"
)
if(isGlobalSecurityRequirement || isOperationSecurityRequirement) {
return node
}
}
})
}
export const allOAS3Components = () => (system) => {
return system.fn.traverseOnce({
name: "allOAS3Components",
fn: (node) => {
const isComponent = (
node.path[0] === "components"
&& node.path.length === 3
&& !system.validateSelectors.isVendorExt(node.parent)
)
if(isComponent) {
return node
}
}
})
}
// List of validators to run...
export const validators = () => (system) => {
return Object.keys(system.validateActions)
.filter(name => {
// The action needs to start with the prefix "validate..."
if(name.indexOf("validate") !== 0)
return false
// This is for both types...
if(name.startsWith("validate2And3"))
return true
// Now for the exclusive validations...
if(system.specSelectors.isOAS3())
return name.startsWith("validateOAS3")
// Swagger2 only...
return !name.startsWith("validateOAS3")
//TODO: This doesn't account for validateAsync with oas3 or swagger2...
})
}
// Should we validate at all?
export const shouldValidate = () => (system) => {
// don't run validation if spec is empty
if(system.specSelectors.specStr().trim().length === 0) {
return
}
// Don't validate if ambiguous version...
const { specSelectors: { isSwagger2=Function.prototype, isOAS3=Function.prototype } } = system
// Can't handle TWO versions!
if(isSwagger2() && isOAS3())
return false
// Can't handle no version!
if(!isSwagger2() && !isOAS3())
return false
return true
}
@@ -0,0 +1,24 @@
export const validate2And3OperationHasUniqueId = () => sys => {
return sys.validateSelectors
.allOperations()
.then(nodes => {
const seen = []
return nodes.reduce((acc, node) => {
const value = node.node
const id = value.operationId
if (id) {
if (seen.indexOf(id) > -1) {
acc.push({
level: "error",
message: "Operations must have unique operationIds.",
path: [...node.path, "operationId"]
})
}
seen.push(id)
}
return acc
}, [])
})
}
@@ -0,0 +1,75 @@
export const validate2And3ParametersHaveUniqueNameAndInCombinations = () => (system) => {
return system.validateSelectors
.allParameterArrays()
.then(nodes => {
return nodes.reduce((acc, node) => {
const parameters = node.node || []
const seen = []
parameters.forEach((param, i) => {
const { name: paramName, in: paramIn } = param
if(!paramName || !paramIn) {
// name or in is missing, so we can't match the param to anything else
return
}
const key = `${paramName}::${paramIn}`
if(seen.indexOf(key) > -1) {
acc.push({
level: "error",
message: "Sibling parameters must have unique name + in values",
path: [
...node.path,
(param.__i || i).toString()
]
})
}
seen.push(key)
})
return acc
}, [])
})
}
export const validate2And3PathParameterIsDefinedInPath = () => (system) => {
const refArray = []
return system.validateSelectors
.allParameters()
.then(nodes => {
return nodes.reduce((acc, node) => {
const parameter = node.node || {}
const path = node.path
const isFromPath = path[0] === "paths" ? true : false
const pathString = path[1]
const paramName = parameter.name
const paramInPath = `{${paramName}}`
const ref = parameter.$ref
const pathStringIncludesParamInPath = pathString && !pathString.toUpperCase().includes("" + paramInPath.toUpperCase())
if (parameter.in === "path") {
if (isFromPath && pathStringIncludesParamInPath) {
acc.push({
message: `Path parameter "${paramName}" must have the corresponding ${paramInPath} segment in the "${pathString}" path`,
path: [...node.path, "name"],
level: "error"
})
} else {
const paramReference = refArray.find(({ referenceParamName }) => referenceParamName === node.key)
if (paramReference && paramReference.pathString && !paramReference.pathString.toUpperCase().includes("" + paramInPath.toUpperCase())) {
acc.push({
message: `Path parameter "${paramName}" must have the corresponding ${paramInPath} segment in the "${paramReference.pathString}" path`,
path: [...paramReference.node.path, "name"],
level: "error"
})
}
}
} else if (ref !== undefined) {
const refStrings = ref.split("/")
refArray.push({referenceParamName:refStrings[refStrings.length-1], pathString:pathString, node: node})
}
return acc
}, [])
})
}
@@ -0,0 +1,65 @@
import {
checkForDefinition,
PATH_TEMPLATES_REGEX
} from "../helpers"
export const validate2And3PathParameterKeysDontContainQuestionMarks = () => system => {
return system.validateSelectors
.allPathItems()
.then(nodes => {
return nodes.reduce((acc, node) => {
if(node.key.indexOf("?") > -1) {
acc.push({
message: `Query strings in paths are not allowed.`,
path: [...node.path],
level: "error",
})
}
return acc
}, [])
})
}
export const validate2And3PathParameterDeclarationHasMatchingDefiniton = () => async system => {
const nodes = await system.validateSelectors.allPathItems()
return nodes.reduce(async (prev, node) => {
const acc = await prev
const pathTemplates = (node.key.match(PATH_TEMPLATES_REGEX) || [])
.map(str => str.replace("{", "").replace("}", ""))
if(pathTemplates.length) {
for (let paramName of pathTemplates) {
if(paramName.length === 0) {
// don't validate empty param names... they're invalid anyway
continue
}
const resolverResult = await system.fn.memoizedResolveSubtree(system.specSelectors.specJson(), node.path)
const res = checkForDefinition(paramName, resolverResult.spec)
if(res.inOperation && res.missingFromOperations.length) {
const missingStr = res.missingFromOperations
.map(str => `"${str}"`)
.join(", ")
acc.push({
message: `Declared path parameter "${paramName}" needs to be defined within every operation in the path (missing in ${missingStr}), or moved to the path-level parameters object`,
path: [...node.path],
level: "error",
})
} else if(res.caseMatch) {
acc.push({
message: `Parameter names are case-sensitive. The parameter named "${res.paramCase}" does not match the case used in the path "${node.key}".`,
path: [...node.path],
level: "error",
})
} else if(!res.found) {
acc.push({
message: `Declared path parameter "${paramName}" needs to be defined as a path parameter at either the path or operation level`,
path: [...node.path],
level: "error",
})
}
}
}
return acc
}, Promise.resolve([]))
}
@@ -0,0 +1,139 @@
import get from "lodash/get"
import { escapeJsonPointerToken } from "../../../refs-util"
import qs from "querystring-browser"
import { pathFromPtr } from "json-refs"
export const validate2And3RefHasNoSiblings = () => system => {
return system.validateSelectors.all$refs()
.then((nodes) => {
const immSpecJson = system.specSelectors.specJson()
const specJson = immSpecJson.toJS ? immSpecJson.toJS() : {}
return nodes.reduce((acc, node) => {
const unresolvedValue = get(specJson, node.parent.path) || {}
const unresolvedKeys = Object.keys(unresolvedValue) || []
const isPathItem = node.parent.key === "paths" && node.path.length === 2
unresolvedKeys.forEach(k => {
if(!isPathItem && k !== "$ref" && unresolvedKeys.indexOf("$ref") > -1) {
acc.push({
message: `Sibling values alongside $refs are ignored.\nTo add properties to a $ref, wrap the $ref into allOf, or move the extra properties into the referenced definition (if applicable).`,
path: [...node.path.slice(0, -1), k],
level: "warning"
})
}
})
return acc
}, [])
})
}
// Add warnings for unused definitions
export const validate2And3UnusedDefinitions = () => (system) => {
return system.validateSelectors.all$refs()
.then((nodes) => {
const references = nodes.map(node => node.node)
const errors = []
const basePath = system.specSelectors.isOAS3() ?
["components", "schemas"] :
["definitions"]
system.specSelectors.definitions()
.forEach((val, key) => {
const escapedKey = escapeJsonPointerToken(key)
if(references.indexOf(`#/${basePath.join("/")}/${escapedKey}`) < 0) {
const path = [...basePath, key]
errors.push({
level: "warning",
path,
message: "Definition was declared but never used in document"
})
}
})
return errors
})
}
export const validate2And3RefPathFormatting = () => (system) => {
return system.validateSelectors.all$refs()
.then((refArtifacts) => {
const errors = []
refArtifacts.forEach((node) => {
const value = node.node
if(typeof value === "string") {
// eslint-disable-next-line no-unused-vars
const [refUrl, refPath] = value.split("#")
if(refPath && refPath[0] !== "/") {
errors.push({
path: [...node.path.slice(0, -1), "$ref"],
message: "$ref paths must begin with `#/`",
level: "error"
})
}
}
})
return errors
})
}
export const validate2And3RefPointersExist = () => (system) => {
const json = system.specSelectors.specJson()
return system.validateSelectors.all$refs()
.then((refs) => {
const errors = []
refs.forEach((node) => {
const value = node.node
if(typeof value === "string" && value[0] === "#") {
// if pointer starts with "#", it is a local ref
const path = pathFromPtr(qs.unescape(value))
if(json.getIn(path) === undefined) {
errors.push({
path: [...node.path.slice(0, -1), "$ref"],
message: "$refs must reference a valid location in the document",
level: "error"
})
}
}
})
return errors
})
}
// from RFC3986: https://tools.ietf.org/html/rfc3986#section-2.2
// plus "%", since it is needed for encoding.
const RFC3986_UNRESERVED_CHARACTERS = /[A-Za-z0-9\-_\.~%]/g
export const validate2And3RefPointersAreProperlyEscaped = () => (system) => {
return system.validateSelectors.all$refs()
.then((refs) => {
const errors = []
refs.forEach((node) => {
const value = node.node
const hashIndex = value.indexOf("#")
const fragment = hashIndex > -1 ? value.slice(hashIndex + 1) : null
if(typeof fragment === "string") {
const rawPath = fragment.split("/")
const hasReservedChars = rawPath
.some(p => p.replace(RFC3986_UNRESERVED_CHARACTERS, "").length > 0)
if(hasReservedChars) {
errors.push({
path: [...node.path.slice(0, -1), "$ref"],
message: "$ref values must be RFC3986-compliant percent-encoded URIs",
level: "error"
})
}
}
})
return errors
})
}
@@ -0,0 +1,137 @@
export const validate2And3TypeArrayRequiresItems = () => (system) => {
return system.validateSelectors
.allSchemas()
.then(nodes => {
return nodes.reduce((acc, node) => {
const schemaObj = node.node
const { type, items } = schemaObj || {}
if(type === "array" && typeof items === "undefined") {
acc.push({
message: "Schemas with 'type: array', require a sibling 'items: ' field",
path: node.path,
level: "error",
})
} else if(type === "array" && (typeof items !== "object" || Array.isArray(items))) {
acc.push({
message: "`items` must be an object",
path: [...node.path, "items"],
level: "error",
})
}
return acc
}, [])
})
}
export const validate2And3TypesInDefaultValuesMatchesWithEnum = () => (system) => {
return system.validateSelectors
.allSchemas()
.then(nodes => {
return nodes.reduce((acc, node) => {
const schemaObj = node.node
const { type } = schemaObj || {}
const isNullable = !!schemaObj.nullable
const enumeration = schemaObj.enum
if (enumeration !== null && typeof enumeration !== "undefined") {
var enumIndex = 0
enumeration.forEach((element, index) => {
var isValidFormat = true
if (element === null && isNullable) {
return
}
if (type === "array" && (!Array.isArray(element) || element === null)) {
isValidFormat = false
enumIndex = index
} else if ((type === "number" || type === "string" || type === "boolean") && !(typeof element === type)) {
isValidFormat = false
enumIndex = index
} else if (type === "integer" && !Number.isInteger(element)) {
isValidFormat = false
enumIndex = index
} else if (type === "object" && ((element === null) || !(typeof element === type) || Array.isArray(element))) {
isValidFormat = false
enumIndex = index
}
if (!isValidFormat) {
acc.push({
message: "enum value should conform to its schema's `type`",
path: [...node.path, "enum", enumIndex],
level: "warning",
})
}
})
}
return acc
}, [])
})
}
export const validate2And3SchemasDefaultsMatchAnEnum = () => (system) => {
return system.validateSelectors
.allSchemas()
.then(nodes => {
return nodes.reduce((acc, node) => {
const element = node.node || {}
if(!element || element.enum === undefined || element.default === undefined) {
// nothing to do
return acc
}
if(element.enum.indexOf(element.default) === -1) {
acc.push({
message: "Default values must be present in `enum`",
path: [...node.path, "default"]
})
}
return acc
}, [])
})
}
export const validate2And3MinAndMax = () => (system) => {
return system.validateSelectors
.allSchemas()
.then(nodes => {
return nodes.reduce((acc, node) => {
const schemaObj = node.node
const {minimum, maximum, minLength, maxLength, minProperties, maxProperties, minItems, maxItems} = schemaObj
if(typeof minimum === "number" && typeof maximum === "number" && (minimum > maximum)) {
acc.push({
message: "'minimum' must be lower value than 'maximum'",
path: [...node.path, "minimum"],
level: "error",
})
}
if(typeof minLength === "number" && typeof maxLength === "number" && (minLength > maxLength)) {
acc.push({
message: "'minLength' must be lower value than 'maxLength'",
path: [...node.path, "minLength"],
level: "error",
})
}
if(typeof minProperties === "number" && typeof maxProperties === "number" && (minProperties > maxProperties)) {
acc.push({
message: "'minProperties' must be lower value than 'maxProperties'",
path: [...node.path, "minProperties"],
level: "error",
})
}
if(typeof minItems === "number" && typeof maxItems === "number" && (minItems > maxItems)) {
acc.push({
message: "'minItems' must be lower value than 'maxItems'",
path: [...node.path, "minItems"],
level: "error",
})
}
return acc
}, [])
})
}
@@ -0,0 +1,51 @@
export const validate2And3SecurityRequirementsHaveDefinitions = () => (system) => {
const { allSecurityRequirements, allSecurityDefinitions } = system.validateSelectors
return Promise.all([allSecurityRequirements(), allSecurityDefinitions()])
.then(([requirementNodes, definitionNodes]) => {
const definedSecuritySchemes = definitionNodes
.map(node => node.key)
return requirementNodes.reduce((acc, node) => {
const value = node.node
const requiredSecurityDefinitions = Object.keys(value) || []
requiredSecurityDefinitions.forEach(name => {
if(definedSecuritySchemes.indexOf(name) < 0) {
acc.push({
message: "Security requirements must match a security definition",
path: [...node.path],
level: "error",
})
}
})
return acc
}, [])
})
}
export const validate2And3UnusedSecuritySchemes = () => (system) => {
const { allSecurityRequirements, allSecurityDefinitions } = system.validateSelectors
return Promise.all([allSecurityRequirements(), allSecurityDefinitions()])
.then(([securityRequirements, securitySchemes]) => {
// Get just the names of security schemes used in `security`
const usedSecurities = securityRequirements
.map(node => Object.keys(node.node) || [])
.reduce(function(a, b) {
// flatten!
return a.concat(b)
}, [])
return securitySchemes.reduce((acc, node) => {
if(usedSecurities.indexOf(node.key) < 0) {
acc.push({
message: "Security scheme was defined but never used. To apply security, use the `security` section in operations or on the root level of your API definition.",
path: node.path,
level: "warning",
})
}
return acc
}, [])
})
}
@@ -0,0 +1,21 @@
export const validate2And3TagObjectsHaveUniqueNames = () => (system) => {
return system.validateSelectors
.allTags()
.then(nodes => {
const seenNames = []
return nodes.reduce((acc, node) => {
const tagObj = node.node
const { name } = tagObj || {}
if(!name || seenNames.indexOf(name) > -1) {
acc.push({
message: "Tag Objects must have unique `name` field values.",
path: node.path,
level: "error",
})
} else {
seenNames.push(name)
}
return acc
}, [])
})
}
@@ -0,0 +1,117 @@
import { SOURCE } from "../actions"
import { getRootNode } from "src/plugins/validate-semantic/helpers"
const operationKeys = ["get", "post", "put", "delete", "options", "head", "patch", "trace"]
export const validateParameterFormDataCaseTypo = () => system => {
return system.validateSelectors
.allParameters()
.then(nodes => {
return nodes.reduce((acc, node) => {
const value = node.node
if(
value.in &&
typeof value.in === "string" &&
value.in.toLowerCase() === "formdata" &&
value.in !== "formData"
) {
acc.push({
message: `Parameter "in: ${value.in}" is invalid, did you mean "in: formData"?`,
path: [...node.path],
level: "error",
source: SOURCE
})
}
return acc
}, [])
})
}
export const validateParameterFormDataForFileTypes = () => system => {
return system.validateSelectors
.allParameters()
.then(nodes => {
return nodes.reduce((acc, node) => {
const value = node.node
if(value.type === "file" && value.in !== "formData") {
acc.push({
message: `Parameters with "type: file" must have "in: formData"`,
path: [...node.path],
level: "error",
source: SOURCE
})
}
return acc
}, [])
})
}
export const validateParameterFormDataConsumesType = () => system => {
return system.validateSelectors
.allPathItems()
.then(nodes => {
return nodes.reduce((acc, node) => {
const pathItemValue = node.node
const globalConsumes = getRootNode(node).node.consumes
const pathItemParameters = pathItemValue.parameters
const hasPathItemFormDataParameter = pathItemParameters != null && pathItemParameters.find(parameter => parameter.in === "formData")
const hasPathItemFileParameter = pathItemParameters != null && pathItemParameters.find(parameter => parameter.type === "file")
for (const method of operationKeys) {
const operationValue = pathItemValue[method]
if (operationValue) {
const effectiveConsumes = operationValue.consumes || globalConsumes || []
const operationParameters = operationValue.parameters || []
const hasOperationFormDataParameter = operationParameters.find(parameter => parameter.in === "formData")
const hasOperationFileParameter = operationParameters.find(parameter => parameter.type === "file")
if(hasPathItemFileParameter || hasOperationFileParameter){
if (!effectiveConsumes.includes("multipart/form-data")) {
acc.push({
message: `Operations with parameters of "type: file" must include "multipart/form-data" in their "consumes" property`,
path: [...node.path, method],
level: "error",
source: SOURCE
})
}
} else if (hasPathItemFormDataParameter || hasOperationFormDataParameter) {
if (!effectiveConsumes.includes("application/x-www-form-urlencoded") && !effectiveConsumes.includes("multipart/form-data")) {
acc.push({
message: `Operations with parameters of "in: formData" must include "application/x-www-form-urlencoded" or "multipart/form-data" in their "consumes" property`,
path: [...node.path, method],
level: "error",
source: SOURCE
})
}
}
}
}
return acc
}, [])
})
}
export const validateParameterArraysDontContainBodyAndFormData = () => system => {
return system.validateSelectors
.allParameterArrays()
.then(paramArrays => {
return paramArrays.reduce((acc, node) => {
const bodyParams = node.node.filter(param => param.in === "body")
const formDataParams = node.node.filter(param => param.in === "formData")
if(bodyParams.length && formDataParams.length) {
acc.push({
message: `Parameters cannot have both a "in: body" and "in: formData", as "formData" _will_ be the body`,
path: [...node.path],
level: "error",
source: SOURCE
})
}
return acc
}, [])
})
}
@@ -0,0 +1,61 @@
const operationKeys = ["get", "post", "put", "delete", "options", "head", "patch", "trace"]
export const PATH_TEMPLATES_REGEX = /\{(.*?)\}/g
export function checkForDefinition(paramName, pathItem) {
const pathItemParameters = pathItem.parameters
const operationsInPathItem = (Object.keys(pathItem) || [])
.filter(key => operationKeys.indexOf(key) > -1)
.map(key => {
const obj = pathItem[key]
obj.method = key
return obj
})
const res = {
found: false,
inPath: false,
inOperation: false,
caseMatch: false,
paramCase: "",
missingFromOperations: []
}
// Look at the path parameters
if(Array.isArray(pathItemParameters)) {
pathItemParameters.forEach(param => {
if(param.name === paramName && param.in === "path") {
res.found = true
res.inPath = true
}
})
}
// Next, look at the operations...
if(!res.found && operationsInPathItem.length) {
operationsInPathItem
.forEach(op => {
const inThisOperation = (op.parameters || [])
.some(param => param.name === paramName && param.in === "path")
const caseMatch = (op.parameters || [])
.find(param => param.name && !(param.name === paramName) && (param.name.toLowerCase() === paramName.toLowerCase()) && param.in === "path")
if(inThisOperation) {
res.found = true
res.inOperation = true
}
if(caseMatch) {
res.caseMatch = true
res.paramCase = caseMatch.name
}
if(!inThisOperation) {
res.missingFromOperations.push(op.method)
}
})
}
return res
}
@@ -0,0 +1,18 @@
export const COMPONENT_NAME_REGEX = /^[A-Za-z0-9\-\._]+$/
export const validateOAS3ComponentNames = () => (system) => {
return system.validateSelectors
.allOAS3Components()
.then(nodes => {
return nodes.reduce((acc, node) => {
if(!COMPONENT_NAME_REGEX.test(node.key)) {
acc.push({
level: "error",
message: "Component names can only contain the characters A-Z a-z 0-9 - . _",
path: node.path
})
}
return acc
}, [])
})
}
@@ -0,0 +1,19 @@
export const validateOAS3GetAndDeleteOpsHaveNoRequestBody = () => sys => {
return sys.validateSelectors
.allOperations()
.then(nodes => {
return nodes.reduce((acc, node) => {
const key = (node.key || "").toLowerCase()
const value = node.node
if((key === "get" || key === "delete") && value.requestBody !== undefined) {
acc.push({
level: "error",
message: `${key.toUpperCase()} operations cannot have a requestBody.`,
path: [...node.path, "requestBody"]
})
}
return acc
}, [])
})
}
@@ -0,0 +1,31 @@
export const validateOAS3HeaderParameterNames = () => (system) => {
return system.validateSelectors
.allParameters()
.then(nodes => {
return nodes.reduce((acc, node) => {
if(node.node.in === "header") {
const name = (node.node.name || "").toLowerCase()
if(name === "authorization") {
acc.push({
level: "warning",
message: "Header parameters named \"Authorization\" are ignored. Use the `securitySchemes` and `security` sections instead to define authorization.",
path: [...node.path, "name"]
})
} else if(name === "content-type") {
acc.push({
level: "warning",
message: "Header parameters named \"Content-Type\" are ignored. The values for the \"Content-Type\" header are defined by `requestBody.content.<media-type>`.",
path: [...node.path, "name"]
})
} else if(name === "accept") {
acc.push({
level: "warning",
message: "Header parameters named \"Accept\" are ignored. The values for the \"Accept\" header are defined by `responses.<code>.content.<media-type>`.",
path: [...node.path, "name"]
})
}
}
return acc
}, [])
})
}
@@ -0,0 +1,128 @@
export const validateOAS3RefsForRequestBodiesReferenceRequestBodyPositions = () => sys => {
return sys.validateSelectors
.allOAS3RequestBodies()
.then(nodes => {
return nodes.reduce((acc, node) => {
const value = node.node
const ref = value.$ref
if (!ref) {
return acc
}
const [refPath = ""] = ref.split("#")
const pathArr = refPath.split("/") || []
// Ignore external references
if (ref.startsWith("#/")) {
// Local cases
if (refPath.endsWith("requestBody") && (refPath.startsWith("/paths") || refPath.startsWith("/components"))){
return acc
}
// Starting with #/compontents/schemas is not allowed
if (ref.startsWith("#/components/schemas")) {
acc.push({
level: "error",
message: `requestBody $refs cannot point to '#/components/schemas/…', they must point to '#/components/requestBodies/…'`,
path: [...node.path, "$ref"]
})
} else
if (ref.startsWith("#/components") && !ref.startsWith("#/components/requestBodies/")) {
acc.push({
level: "error",
message: `requestBody $refs must point to a position where a requestBody can be legally placed`,
path: [...node.path, "$ref"]
})
}
// Extensions are valid
if (ref.startsWith("#/") && pathArr.some(element => element.startsWith("x-"))){
return acc
}
}
return acc
}, [])
})
}
export const validateOAS3RequestBodyRefsReferenceAllowableSchemaPositions = () => sys => {
return sys.validateSelectors
.allOAS3RequestBodySchemas()
.then(nodes => {
return nodes.reduce((acc, node) => {
const value = node.node
const ref = value.$ref
if(!ref) {
return acc
}
const [, refPath = ""] = ref.split("#")
const pathArr = refPath.split("/") || []
const parentRefKey = pathArr.slice(-2)[0]
const targetRefKey = pathArr.slice(-1)[0]
if(
targetRefKey !== "schema"
&& parentRefKey !== "schemas"
&& ref.startsWith("#/")
) {
acc.push({
level: "error",
message: `requestBody schema $refs must point to a position where a Schema Object can be legally placed`,
path: [...node.path, "$ref"]
})
}
return acc
}, [])
})
}
export const validateOAS3ParameterRefsReferenceParameterPositions = () => sys => {
return sys.validateSelectors
.allParameters()
.then(nodes => {
return nodes.reduce((acc, node) => {
const value = node.node
const ref = value.$ref
if(!ref) {
return acc
}
if (ref.startsWith("#/components/headers")) {
acc.push({
level: "error",
message: `OAS3 parameter $refs should point to #/components/parameters/... and not #/components/headers/...`,
path: [...node.path, "$ref"]
})
}
return acc
}, [])
})
}
export const validateOAS3RefsForHeadersReferenceHeadersPositions = () => sys => {
return sys.validateSelectors
.allHeaders()
.then(nodes => {
return nodes.reduce((acc, node) => {
const value = node.node
const ref = value.$ref
if(!ref) {
return acc
}
if (ref.startsWith("#/components/parameters")) {
acc.push({
level: "error",
message: `OAS3 header $refs should point to #/components/headers/... and not #/components/parameters/...`,
path: [...node.path, "$ref"]
})
}
return acc
}, [])
})
}
@@ -0,0 +1,41 @@
export const validateParameterBadKeys = () => (system) => {
return system.validateSelectors
.allParameters()
.then(nodes => {
return nodes.reduce((acc, node) => {
if(node.node.required !== true && node.node.in === "path") {
acc.push({
level: "error",
message: "Path parameters must have 'required: true'. You can always create another path/operation without this parameter to get the same behaviour.",
path: node.path
})
}
return acc
}, [])
})
}
export const validateParametersHasOnlyOneBody = () => (system) => {
return system.validateSelectors
.allParameterArrays()
.then(nodes => {
return nodes.reduce((acc, node) => {
const parameters = node.node || []
let bodyParamSeen = false
parameters.forEach((param) => {
if(param.in === "body" && bodyParamSeen) {
acc.push({
level: "error",
message: "Multiple body parameters are not allowed.",
path: node.path
})
}
if(param.in === "body") {
bodyParamSeen = true
}
})
return acc
}, [])
})
}
@@ -0,0 +1,45 @@
import {
PATH_TEMPLATES_REGEX
} from "./helpers"
export const validatePathParameterDeclarationIsNotEmpty = () => system => {
return system.validateSelectors
.allPathItems()
.then(nodes => {
return nodes.reduce((acc, node) => {
const pathTemplates = (node.key.match(PATH_TEMPLATES_REGEX) || [])
.map(str => str.replace("{", "").replace("}", ""))
const emptyPathTemplates = pathTemplates.filter(v => !v.length)
if(emptyPathTemplates.length) {
acc.push({
message: `Empty path parameter declarations are not valid`,
path: [...node.path],
level: "error",
})
}
return acc
}, [])
})
}
export const validatePathParameterKeysAreDifferent = () => system => {
return system.validateSelectors
.allPathItems()
.then(nodes => {
const seen = []
return nodes.reduce((acc, node) => {
const realPath = node.key.replace(PATH_TEMPLATES_REGEX, "~~")
if(seen.indexOf(realPath) > -1) {
acc.push({
message: `Equivalent paths are not allowed.`,
path: [...node.path],
level: "error",
})
}
seen.push(realPath)
return acc
}, [])
})
}
@@ -0,0 +1,60 @@
export const validateTypeKeyShouldBeString = () => (system) => {
return system.validateSelectors
.allSchemas()
.then(nodes => {
return nodes.reduce((acc, node) => {
const schemaObj = node.node
if(schemaObj.type !== undefined && typeof schemaObj.type !== "string") {
acc.push({
message: `Schema "type" key must be a string`,
path: [...node.path, "type"],
level: "error",
})
}
return acc
}, [])
})
}
export const validateReadOnlyPropertiesNotRequired = () => (system) => {
return system.validateSelectors
.allSchemas()
.then(nodes => {
return nodes.reduce((acc, node) => {
const schemaObj = node.node
if(Array.isArray(schemaObj.required) && typeof schemaObj.properties === "object") {
schemaObj.required.forEach((prop, i) => {
if(schemaObj.properties[prop] && schemaObj.properties[prop].readOnly) {
acc.push({
message: `Read only properties cannot be marked as required by a schema.`,
path: [...node.path, "required", i.toString()],
level: "error",
})
}
})
}
return acc
}, [])
})
}
// See https://github.com/swagger-api/swagger-editor/issues/1601
export const validateSchemaPatternHasNoZAnchors = () => (system) => {
return system.validateSelectors
.allSchemas()
.then(nodes => {
return nodes.reduce((acc, node) => {
const schemaObj = node.node
const { pattern } = schemaObj || {}
if(typeof pattern === "string" && pattern.indexOf("\\Z") > -1) {
acc.push({
message: `"\\Z" anchors are not allowed in regular expression patterns`,
path: [...node.path, "pattern"],
level: "error",
})
}
return acc
}, [])
})
}
@@ -0,0 +1,32 @@
export const validateSecurityRequirementReferenceExistingScopes = () => (system) => {
const { allSecurityRequirements, allSecurityDefinitions } = system.validateSelectors
return Promise.all([allSecurityRequirements(), allSecurityDefinitions()])
.then(([requirementNodes, definitionNodes]) => {
const definedSecuritySchemes = definitionNodes
.reduce((p, node) => Object.assign(p, { [node.key]: node.node }), {})
return requirementNodes.reduce((acc, node) => {
const value = node.node
const requiredSecurityDefinitions = Object.keys(value) || []
requiredSecurityDefinitions.forEach(name => {
const scopes = value[name]
const definition = definedSecuritySchemes[name]
if(Array.isArray(scopes) && scopes.length && definition) {
scopes.forEach((scope, i) => {
if(!definition.scopes || definition.scopes[scope] === undefined) {
acc.push({
message: `Security scope definition ${scope} could not be resolved`,
path: [...node.path, i.toString()],
level: "error",
})
}
})
}
})
return acc
}, [])
})
}