Setup spotlight project
This commit is contained in:
38
.stoplight/custom-functions/oasDiscriminator.js
Normal file
38
.stoplight/custom-functions/oasDiscriminator.js
Normal file
@@ -0,0 +1,38 @@
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
|
||||
export const oasDiscriminator = (schema, _opts, { path }) => {
|
||||
/**
|
||||
* This function verifies:
|
||||
*
|
||||
* 1. The discriminator property name is defined at this schema.
|
||||
* 2. The discriminator property is in the required property list.
|
||||
*/
|
||||
|
||||
if (!isObject(schema)) return;
|
||||
|
||||
if (typeof schema.discriminator !== 'string') return;
|
||||
|
||||
const discriminatorName = schema.discriminator;
|
||||
|
||||
const results = [];
|
||||
|
||||
if (!isObject(schema.properties) || !Object.keys(schema.properties).some(k => k === discriminatorName)) {
|
||||
results.push({
|
||||
message: `The discriminator property must be defined in this schema.`,
|
||||
path: [...path, 'properties'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(schema.required) || !schema.required.some(n => n === discriminatorName)) {
|
||||
results.push({
|
||||
message: `The discriminator property must be in the required property list.`,
|
||||
path: [...path, 'required'],
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export default oasDiscriminator;
|
||||
4274
.stoplight/custom-functions/oasDocumentSchema.js
Normal file
4274
.stoplight/custom-functions/oasDocumentSchema.js
Normal file
File diff suppressed because it is too large
Load Diff
228
.stoplight/custom-functions/oasExample.js
Normal file
228
.stoplight/custom-functions/oasExample.js
Normal file
@@ -0,0 +1,228 @@
|
||||
import { isPlainObject, pointerToPath } from '@stoplight/json';
|
||||
import { createRulesetFunction } from '@stoplight/spectral-core';
|
||||
import { oas2, oas3_1, extractDraftVersion, oas3_0 } from '@stoplight/spectral-formats';
|
||||
import { schema as schemaFn } from '@stoplight/spectral-functions';
|
||||
import traverse from 'json-schema-traverse';
|
||||
|
||||
const MEDIA_VALIDATION_ITEMS = {
|
||||
2: [
|
||||
{
|
||||
field: 'examples',
|
||||
multiple: true,
|
||||
keyed: false,
|
||||
},
|
||||
],
|
||||
3: [
|
||||
{
|
||||
field: 'example',
|
||||
multiple: false,
|
||||
keyed: false,
|
||||
},
|
||||
{
|
||||
field: 'examples',
|
||||
multiple: true,
|
||||
keyed: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const SCHEMA_VALIDATION_ITEMS = {
|
||||
2: ['example', 'x-example', 'default'],
|
||||
3: ['example', 'default'],
|
||||
};
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
|
||||
function rewriteNullable(schema, errors) {
|
||||
for (const error of errors) {
|
||||
if (error.keyword !== 'type') continue;
|
||||
const value = getSchemaProperty(schema, error.schemaPath);
|
||||
if (isPlainObject(value) && value.nullable === true) {
|
||||
error.message += ',null';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const visitOAS2 = schema => {
|
||||
if (schema['x-nullable'] === true) {
|
||||
schema.nullable = true;
|
||||
delete schema['x-nullable'];
|
||||
}
|
||||
};
|
||||
|
||||
function getSchemaProperty(schema, schemaPath) {
|
||||
const path = pointerToPath(schemaPath);
|
||||
let value = schema;
|
||||
|
||||
for (const fragment of path.slice(0, -1)) {
|
||||
if (!isPlainObject(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
value = value[fragment];
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
const oasSchema = createRulesetFunction(
|
||||
{
|
||||
input: null,
|
||||
options: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
function oasSchema(targetVal, opts, context) {
|
||||
const formats = context.document.formats;
|
||||
|
||||
let { schema } = opts;
|
||||
|
||||
let dialect = 'draft4';
|
||||
let prepareResults;
|
||||
|
||||
if (!formats) {
|
||||
dialect = 'auto';
|
||||
} else if (formats.has(oas3_1)) {
|
||||
if (isPlainObject(context.document.data) && typeof context.document.data.jsonSchemaDialect === 'string') {
|
||||
dialect = extractDraftVersion(context.document.data.jsonSchemaDialect) ?? 'draft2020-12';
|
||||
} else {
|
||||
dialect = 'draft2020-12';
|
||||
}
|
||||
} else if (formats.has(oas3_0)) {
|
||||
prepareResults = rewriteNullable.bind(null, schema);
|
||||
} else if (formats.has(oas2)) {
|
||||
const clonedSchema = JSON.parse(JSON.stringify(schema));
|
||||
traverse(clonedSchema, visitOAS2);
|
||||
schema = clonedSchema;
|
||||
prepareResults = rewriteNullable.bind(null, clonedSchema);
|
||||
}
|
||||
|
||||
return schemaFn(
|
||||
targetVal,
|
||||
{
|
||||
...opts,
|
||||
schema,
|
||||
prepareResults,
|
||||
dialect,
|
||||
},
|
||||
context,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function* getMediaValidationItems(items, targetVal, givenPath, oasVersion) {
|
||||
for (const { field, keyed, multiple } of items) {
|
||||
if (!(field in targetVal)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = targetVal[field];
|
||||
|
||||
if (multiple) {
|
||||
if (!isObject(value)) continue;
|
||||
|
||||
for (const exampleKey of Object.keys(value)) {
|
||||
const exampleValue = value[exampleKey];
|
||||
if (oasVersion === 3 && keyed && (!isObject(exampleValue) || 'externalValue' in exampleValue)) {
|
||||
// should be covered by oas3-examples-value-or-externalValue
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetPath = [...givenPath, field, exampleKey];
|
||||
|
||||
if (keyed) {
|
||||
targetPath.push('value');
|
||||
}
|
||||
|
||||
yield {
|
||||
value: keyed && isObject(exampleValue) ? exampleValue.value : exampleValue,
|
||||
path: targetPath,
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
} else {
|
||||
return yield {
|
||||
value,
|
||||
path: [...givenPath, field],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function* getSchemaValidationItems(fields, targetVal, givenPath) {
|
||||
for (const field of fields) {
|
||||
if (!(field in targetVal)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield {
|
||||
value: targetVal[field],
|
||||
path: [...givenPath, field],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default createRulesetFunction(
|
||||
{
|
||||
input: {
|
||||
type: 'object',
|
||||
},
|
||||
options: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
oasVersion: {
|
||||
enum: ['2', '3'],
|
||||
},
|
||||
schemaField: {
|
||||
type: 'string',
|
||||
},
|
||||
type: {
|
||||
enum: ['media', 'schema'],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
function oasExample(targetVal, opts, context) {
|
||||
const formats = context.document.formats;
|
||||
const schemaOpts = {
|
||||
schema: opts.schemaField === '$' ? targetVal : targetVal[opts.schemaField],
|
||||
};
|
||||
|
||||
let results = void 0;
|
||||
let oasVersion = parseInt(opts.oasVersion);
|
||||
|
||||
const validationItems =
|
||||
opts.type === 'schema'
|
||||
? getSchemaValidationItems(SCHEMA_VALIDATION_ITEMS[oasVersion], targetVal, context.path)
|
||||
: getMediaValidationItems(MEDIA_VALIDATION_ITEMS[oasVersion], targetVal, context.path, oasVersion);
|
||||
|
||||
if (formats?.has(oas2) && 'required' in schemaOpts.schema && typeof schemaOpts.schema.required === 'boolean') {
|
||||
schemaOpts.schema = { ...schemaOpts.schema };
|
||||
delete schemaOpts.schema.required;
|
||||
}
|
||||
|
||||
for (const validationItem of validationItems) {
|
||||
const result = oasSchema(validationItem.value, schemaOpts, {
|
||||
...context,
|
||||
path: validationItem.path,
|
||||
});
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
if (results === void 0) results = [];
|
||||
results.push(...result);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
);
|
||||
28
.stoplight/custom-functions/oasOpFormDataConsumeCheck.js
Normal file
28
.stoplight/custom-functions/oasOpFormDataConsumeCheck.js
Normal file
@@ -0,0 +1,28 @@
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
|
||||
const validConsumeValue = /(application\/x-www-form-urlencoded|multipart\/form-data)/;
|
||||
|
||||
export const oasOpFormDataConsumeCheck = targetVal => {
|
||||
if (!isObject(targetVal)) return;
|
||||
|
||||
const parameters = targetVal.parameters;
|
||||
const consumes = targetVal.consumes;
|
||||
|
||||
if (!Array.isArray(parameters) || !Array.isArray(consumes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parameters.some(p => isObject(p) && p.in === 'formData') && !validConsumeValue.test(consumes?.join(','))) {
|
||||
return [
|
||||
{
|
||||
message: 'Consumes must include urlencoded, multipart, or form-data media type when using formData parameter.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
export default oasOpFormDataConsumeCheck;
|
||||
76
.stoplight/custom-functions/oasOpIdUnique.js
Normal file
76
.stoplight/custom-functions/oasOpIdUnique.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { isPlainObject } from '@stoplight/json';
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
|
||||
const validOperationKeys = ['get', 'head', 'post', 'put', 'patch', 'delete', 'options', 'trace'];
|
||||
|
||||
function* getAllOperations(paths) {
|
||||
if (!isPlainObject(paths)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = {
|
||||
path: '',
|
||||
operation: '',
|
||||
value: null,
|
||||
};
|
||||
|
||||
for (const path of Object.keys(paths)) {
|
||||
const operations = paths[path];
|
||||
if (!isPlainObject(operations)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
item.path = path;
|
||||
|
||||
for (const operation of Object.keys(operations)) {
|
||||
if (!isPlainObject(operations[operation]) || !validOperationKeys.includes(operation)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
item.operation = operation;
|
||||
item.value = operations[operation];
|
||||
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const oasOpIdUnique = targetVal => {
|
||||
if (!isObject(targetVal) || !isObject(targetVal.paths)) return;
|
||||
|
||||
const results = [];
|
||||
|
||||
const { paths } = targetVal;
|
||||
|
||||
const seenIds = [];
|
||||
|
||||
for (const { path, operation } of getAllOperations(paths)) {
|
||||
const pathValue = paths[path];
|
||||
|
||||
if (!isObject(pathValue)) continue;
|
||||
|
||||
const operationValue = pathValue[operation];
|
||||
|
||||
if (!isObject(operationValue) || !('operationId' in operationValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { operationId } = operationValue;
|
||||
|
||||
if (seenIds.includes(operationId)) {
|
||||
results.push({
|
||||
message: 'operationId must be unique.',
|
||||
path: ['paths', path, operation, 'operationId'],
|
||||
});
|
||||
} else {
|
||||
seenIds.push(operationId);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export default oasOpIdUnique;
|
||||
81
.stoplight/custom-functions/oasOpParams.js
Normal file
81
.stoplight/custom-functions/oasOpParams.js
Normal file
@@ -0,0 +1,81 @@
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
|
||||
function computeFingerprint(param) {
|
||||
return `${String(param.in)}-${String(param.name)}`;
|
||||
}
|
||||
|
||||
export const oasOpParams = (params, _opts, { path }) => {
|
||||
/**
|
||||
* This function verifies:
|
||||
*
|
||||
* 1. Operations must have unique `name` + `in` parameters.
|
||||
* 2. Operation cannot have both `in:body` and `in:formData` parameters
|
||||
* 3. Operation must have only one `in:body` parameter.
|
||||
*/
|
||||
|
||||
if (!Array.isArray(params)) return;
|
||||
|
||||
if (params.length < 2) return;
|
||||
|
||||
const results = [];
|
||||
|
||||
const count = {
|
||||
body: [],
|
||||
formData: [],
|
||||
};
|
||||
const list = [];
|
||||
const duplicates = [];
|
||||
|
||||
let index = -1;
|
||||
|
||||
for (const param of params) {
|
||||
index++;
|
||||
|
||||
if (!isObject(param)) continue;
|
||||
|
||||
// skip params that are refs
|
||||
if ('$ref' in param) continue;
|
||||
|
||||
// Operations must have unique `name` + `in` parameters.
|
||||
const fingerprint = computeFingerprint(param);
|
||||
if (list.includes(fingerprint)) {
|
||||
duplicates.push(index);
|
||||
} else {
|
||||
list.push(fingerprint);
|
||||
}
|
||||
|
||||
if (typeof param.in === 'string' && param.in in count) {
|
||||
count[param.in].push(index);
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
for (const i of duplicates) {
|
||||
results.push({
|
||||
message: 'A parameter in this operation already exposes the same combination of "name" and "in" values.',
|
||||
path: [...path, i],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (count.body.length > 0 && count.formData.length > 0) {
|
||||
results.push({
|
||||
message: 'Operation must not have both "in:body" and "in:formData" parameters.',
|
||||
});
|
||||
}
|
||||
|
||||
if (count.body.length > 1) {
|
||||
for (let i = 1; i < count.body.length; i++) {
|
||||
results.push({
|
||||
message: 'Operation must not have more than a single instance of the "in:body" parameter.',
|
||||
path: [...path, count.body[i]],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export default oasOpParams;
|
||||
141
.stoplight/custom-functions/oasOpSecurityDefined.js
Normal file
141
.stoplight/custom-functions/oasOpSecurityDefined.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import { isPlainObject } from '@stoplight/json';
|
||||
import { createRulesetFunction } from '@stoplight/spectral-core';
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
|
||||
const validOperationKeys = ['get', 'head', 'post', 'put', 'patch', 'delete', 'options', 'trace'];
|
||||
|
||||
function* getAllOperations(paths) {
|
||||
if (!isPlainObject(paths)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = {
|
||||
path: '',
|
||||
operation: '',
|
||||
value: null,
|
||||
};
|
||||
|
||||
for (const path of Object.keys(paths)) {
|
||||
const operations = paths[path];
|
||||
if (!isPlainObject(operations)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
item.path = path;
|
||||
|
||||
for (const operation of Object.keys(operations)) {
|
||||
if (!isPlainObject(operations[operation]) || !validOperationKeys.includes(operation)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
item.operation = operation;
|
||||
item.value = operations[operation];
|
||||
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _get(value, path) {
|
||||
for (const segment of path) {
|
||||
if (!isObject(value)) {
|
||||
break;
|
||||
}
|
||||
|
||||
value = value[segment];
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export default createRulesetFunction(
|
||||
{
|
||||
input: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
paths: {
|
||||
type: 'object',
|
||||
},
|
||||
security: {
|
||||
type: 'array',
|
||||
},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
schemesPath: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: ['string', 'number'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
function oasOpSecurityDefined(targetVal, { schemesPath }) {
|
||||
const { paths } = targetVal;
|
||||
|
||||
const results = [];
|
||||
|
||||
const schemes = _get(targetVal, schemesPath);
|
||||
const allDefs = isObject(schemes) ? Object.keys(schemes) : [];
|
||||
|
||||
// Check global security requirements
|
||||
|
||||
const { security } = targetVal;
|
||||
|
||||
if (Array.isArray(security)) {
|
||||
for (const [index, value] of security.entries()) {
|
||||
if (!isObject(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const securityKeys = Object.keys(value);
|
||||
|
||||
for (const securityKey of securityKeys) {
|
||||
if (!allDefs.includes(securityKey)) {
|
||||
results.push({
|
||||
message: `API "security" values must match a scheme defined in the "${schemesPath.join('.')}" object.`,
|
||||
path: ['security', index, securityKey],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { path, operation, value } of getAllOperations(paths)) {
|
||||
if (!isObject(value)) continue;
|
||||
|
||||
const { security } = value;
|
||||
|
||||
if (!Array.isArray(security)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [index, value] of security.entries()) {
|
||||
if (!isObject(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const securityKeys = Object.keys(value);
|
||||
|
||||
for (const securityKey of securityKeys) {
|
||||
if (!allDefs.includes(securityKey)) {
|
||||
results.push({
|
||||
message: `Operation "security" values must match a scheme defined in the "${schemesPath.join(
|
||||
'.',
|
||||
)}" object.`,
|
||||
path: ['paths', path, operation, 'security', index, securityKey],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
);
|
||||
32
.stoplight/custom-functions/oasOpSuccessResponse.js
Normal file
32
.stoplight/custom-functions/oasOpSuccessResponse.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createRulesetFunction } from '@stoplight/spectral-core';
|
||||
import { oas3 } from '@stoplight/spectral-formats';
|
||||
|
||||
export const oasOpSuccessResponse = createRulesetFunction(
|
||||
{
|
||||
input: {
|
||||
type: 'object',
|
||||
},
|
||||
options: null,
|
||||
},
|
||||
(input, opts, context) => {
|
||||
const isOAS3X = context.document.formats?.has(oas3) === true;
|
||||
|
||||
for (const response of Object.keys(input)) {
|
||||
if (isOAS3X && (response === '2XX' || response === '3XX')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(response) >= 200 && Number(response) < 400) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
message: 'Operation must define at least a single 2xx or 3xx response',
|
||||
},
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
export default oasOpSuccessResponse;
|
||||
162
.stoplight/custom-functions/oasPathParam.js
Normal file
162
.stoplight/custom-functions/oasPathParam.js
Normal file
@@ -0,0 +1,162 @@
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
|
||||
const pathRegex = /(\{;?\??[a-zA-Z0-9_-]+\*?\})/g;
|
||||
|
||||
const isNamedPathParam = p => {
|
||||
return p.in !== void 0 && p.in === 'path' && p.name !== void 0;
|
||||
};
|
||||
|
||||
const isUnknownNamedPathParam = (p, path, results, seen) => {
|
||||
if (!isNamedPathParam(p)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (p.required !== true) {
|
||||
results.push(generateResult(requiredMessage(p.name), path));
|
||||
}
|
||||
|
||||
if (p.name in seen) {
|
||||
results.push(generateResult(uniqueDefinitionMessage(p.name), path));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const ensureAllDefinedPathParamsAreUsedInPath = (path, params, expected, results) => {
|
||||
for (const p of Object.keys(params)) {
|
||||
if (!params[p]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!expected.includes(p)) {
|
||||
const resPath = params[p];
|
||||
results.push(generateResult(`Parameter "${p}" must be used in path "${path}".`, resPath));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ensureAllExpectedParamsInPathAreDefined = (path, params, expected, operationPath, results) => {
|
||||
for (const p of expected) {
|
||||
if (!(p in params)) {
|
||||
results.push(
|
||||
generateResult(`Operation must define parameter "{${p}}" as expected by path "${path}".`, operationPath),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const oasPathParam = targetVal => {
|
||||
/**
|
||||
* This rule verifies:
|
||||
*
|
||||
* 1. for every param referenced in the path string ie /users/{userId}, var must be defined in either
|
||||
* path.parameters, or operation.parameters object
|
||||
* 2. every path.parameters + operation.parameters property must be used in the path string
|
||||
*/
|
||||
|
||||
if (!isObject(targetVal) || !isObject(targetVal.paths)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
// keep track of normalized paths for verifying paths are unique
|
||||
const uniquePaths = {};
|
||||
const validOperationKeys = ['get', 'head', 'post', 'put', 'patch', 'delete', 'options', 'trace'];
|
||||
|
||||
for (const path of Object.keys(targetVal.paths)) {
|
||||
const pathValue = targetVal.paths[path];
|
||||
if (!isObject(pathValue)) continue;
|
||||
|
||||
// verify normalized paths are functionally unique (ie `/path/{one}` vs `/path/{two}` are
|
||||
// different but equivalent within the context of OAS)
|
||||
const normalized = path.replace(pathRegex, '%'); // '%' is used here since its invalid in paths
|
||||
if (normalized in uniquePaths) {
|
||||
results.push(
|
||||
generateResult(`Paths "${String(uniquePaths[normalized])}" and "${path}" must not be equivalent.`, [
|
||||
'paths',
|
||||
path,
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
uniquePaths[normalized] = path;
|
||||
}
|
||||
|
||||
// find all templated path parameters
|
||||
const pathElements = [];
|
||||
let match;
|
||||
|
||||
while ((match = pathRegex.exec(path))) {
|
||||
const p = match[0].replace(/[{}?*;]/g, '');
|
||||
if (pathElements.includes(p)) {
|
||||
results.push(generateResult(`Path "${path}" must not use parameter "{${p}}" multiple times.`, ['paths', path]));
|
||||
} else {
|
||||
pathElements.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
// find parameters set within the top-level 'parameters' object
|
||||
const topParams = {};
|
||||
if (Array.isArray(pathValue.parameters)) {
|
||||
for (const [i, value] of pathValue.parameters.entries()) {
|
||||
if (!isObject(value)) continue;
|
||||
|
||||
const fullParameterPath = ['paths', path, 'parameters', i];
|
||||
|
||||
if (isUnknownNamedPathParam(value, fullParameterPath, results, topParams)) {
|
||||
topParams[value.name] = fullParameterPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(targetVal.paths[path])) {
|
||||
// find parameters set within the operation's 'parameters' object
|
||||
for (const op of Object.keys(pathValue)) {
|
||||
const operationValue = pathValue[op];
|
||||
if (!isObject(operationValue)) continue;
|
||||
|
||||
if (op === 'parameters' || !validOperationKeys.includes(op)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const operationParams = {};
|
||||
const { parameters } = operationValue;
|
||||
const operationPath = ['paths', path, op];
|
||||
|
||||
if (Array.isArray(parameters)) {
|
||||
for (const [i, p] of parameters.entries()) {
|
||||
if (!isObject(p)) continue;
|
||||
|
||||
const fullParameterPath = [...operationPath, 'parameters', i];
|
||||
|
||||
if (isUnknownNamedPathParam(p, fullParameterPath, results, operationParams)) {
|
||||
operationParams[p.name] = fullParameterPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const definedParams = { ...topParams, ...operationParams };
|
||||
ensureAllDefinedPathParamsAreUsedInPath(path, definedParams, pathElements, results);
|
||||
ensureAllExpectedParamsInPathAreDefined(path, definedParams, pathElements, operationPath, results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
function generateResult(message, path) {
|
||||
return {
|
||||
message,
|
||||
path,
|
||||
};
|
||||
}
|
||||
|
||||
const requiredMessage = name => `Path parameter "${name}" must have "required" property that is set to "true".`;
|
||||
|
||||
const uniqueDefinitionMessage = name => `Path parameter "${name}" must not be defined multiple times.`;
|
||||
|
||||
export default oasPathParam;
|
||||
88
.stoplight/custom-functions/oasSchema.js
Normal file
88
.stoplight/custom-functions/oasSchema.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import traverse from 'json-schema-traverse';
|
||||
import { schema as schemaFn } from '@stoplight/spectral-functions';
|
||||
import { createRulesetFunction } from '@stoplight/spectral-core';
|
||||
import { oas2, oas3_1, extractDraftVersion, oas3_0 } from '@stoplight/spectral-formats';
|
||||
import { isPlainObject, pointerToPath } from '@stoplight/json';
|
||||
|
||||
function rewriteNullable(schema, errors) {
|
||||
for (const error of errors) {
|
||||
if (error.keyword !== 'type') continue;
|
||||
const value = getSchemaProperty(schema, error.schemaPath);
|
||||
if (isPlainObject(value) && value.nullable === true) {
|
||||
error.message += ',null';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default createRulesetFunction(
|
||||
{
|
||||
input: null,
|
||||
options: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
function oasSchema(targetVal, opts, context) {
|
||||
const formats = context.document.formats;
|
||||
|
||||
let { schema } = opts;
|
||||
|
||||
let dialect = 'draft4';
|
||||
let prepareResults;
|
||||
|
||||
if (!formats) {
|
||||
dialect = 'auto';
|
||||
} else if (formats.has(oas3_1)) {
|
||||
if (isPlainObject(context.document.data) && typeof context.document.data.jsonSchemaDialect === 'string') {
|
||||
dialect = extractDraftVersion(context.document.data.jsonSchemaDialect) ?? 'draft2020-12';
|
||||
} else {
|
||||
dialect = 'draft2020-12';
|
||||
}
|
||||
} else if (formats.has(oas3_0)) {
|
||||
prepareResults = rewriteNullable.bind(null, schema);
|
||||
} else if (formats.has(oas2)) {
|
||||
const clonedSchema = JSON.parse(JSON.stringify(schema));
|
||||
traverse(clonedSchema, visitOAS2);
|
||||
schema = clonedSchema;
|
||||
prepareResults = rewriteNullable.bind(null, clonedSchema);
|
||||
}
|
||||
|
||||
return schemaFn(
|
||||
targetVal,
|
||||
{
|
||||
...opts,
|
||||
schema,
|
||||
prepareResults,
|
||||
dialect,
|
||||
},
|
||||
context,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const visitOAS2 = schema => {
|
||||
if (schema['x-nullable'] === true) {
|
||||
schema.nullable = true;
|
||||
delete schema['x-nullable'];
|
||||
}
|
||||
};
|
||||
|
||||
function getSchemaProperty(schema, schemaPath) {
|
||||
const path = pointerToPath(schemaPath);
|
||||
let value = schema;
|
||||
|
||||
for (const fragment of path.slice(0, -1)) {
|
||||
if (!isPlainObject(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
value = value[fragment];
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
81
.stoplight/custom-functions/oasTagDefined.js
Normal file
81
.stoplight/custom-functions/oasTagDefined.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// This function will check an API doc to verify that any tag that appears on
|
||||
// an operation is also present in the global tags array.
|
||||
import { isPlainObject } from '@stoplight/json';
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
|
||||
const validOperationKeys = ['get', 'head', 'post', 'put', 'patch', 'delete', 'options', 'trace'];
|
||||
|
||||
function* getAllOperations(paths) {
|
||||
if (!isPlainObject(paths)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = {
|
||||
path: '',
|
||||
operation: '',
|
||||
value: null,
|
||||
};
|
||||
|
||||
for (const path of Object.keys(paths)) {
|
||||
const operations = paths[path];
|
||||
if (!isPlainObject(operations)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
item.path = path;
|
||||
|
||||
for (const operation of Object.keys(operations)) {
|
||||
if (!isPlainObject(operations[operation]) || !validOperationKeys.includes(operation)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
item.operation = operation;
|
||||
item.value = operations[operation];
|
||||
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const oasTagDefined = targetVal => {
|
||||
if (!isObject(targetVal)) return;
|
||||
const results = [];
|
||||
|
||||
const globalTags = [];
|
||||
|
||||
if (Array.isArray(targetVal.tags)) {
|
||||
for (const tag of targetVal.tags) {
|
||||
if (isObject(tag) && typeof tag.name === 'string') {
|
||||
globalTags.push(tag.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { paths } = targetVal;
|
||||
|
||||
for (const { path, operation, value } of getAllOperations(paths)) {
|
||||
if (!isObject(value)) continue;
|
||||
|
||||
const { tags } = value;
|
||||
|
||||
if (!Array.isArray(tags)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [i, tag] of tags.entries()) {
|
||||
if (!globalTags.includes(tag)) {
|
||||
results.push({
|
||||
message: 'Operation tags must be defined in global tags.',
|
||||
path: ['paths', path, operation, 'tags', i],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export default oasTagDefined;
|
||||
50
.stoplight/custom-functions/oasUnusedComponent.js
Normal file
50
.stoplight/custom-functions/oasUnusedComponent.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { unreferencedReusableObject } from '@stoplight/spectral-functions';
|
||||
import { createRulesetFunction } from '@stoplight/spectral-core';
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
|
||||
export default createRulesetFunction(
|
||||
{
|
||||
input: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
components: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
required: ['components'],
|
||||
},
|
||||
options: null,
|
||||
},
|
||||
function oasUnusedComponent(targetVal, opts, context) {
|
||||
const results = [];
|
||||
const componentTypes = [
|
||||
'schemas',
|
||||
'responses',
|
||||
'parameters',
|
||||
'examples',
|
||||
'requestBodies',
|
||||
'headers',
|
||||
'links',
|
||||
'callbacks',
|
||||
];
|
||||
|
||||
for (const type of componentTypes) {
|
||||
const value = targetVal.components[type];
|
||||
if (!isObject(value)) continue;
|
||||
|
||||
const resultsForType = unreferencedReusableObject(
|
||||
value,
|
||||
{ reusableObjectsLocation: `#/components/${type}` },
|
||||
context,
|
||||
);
|
||||
if (resultsForType !== void 0 && Array.isArray(resultsForType)) {
|
||||
results.push(...resultsForType);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
);
|
||||
51
.stoplight/custom-functions/refSiblings.js
Normal file
51
.stoplight/custom-functions/refSiblings.js
Normal file
@@ -0,0 +1,51 @@
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
|
||||
function getParentValue(document, path) {
|
||||
if (path.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let piece = document;
|
||||
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
if (!isObject(piece)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
piece = piece[path[i]];
|
||||
}
|
||||
|
||||
return piece;
|
||||
}
|
||||
|
||||
const refSiblings = (targetVal, opts, { document, path }) => {
|
||||
const value = getParentValue(document.data, path);
|
||||
|
||||
if (!isObject(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const actualObjPath = path.slice(0, -1);
|
||||
|
||||
for (const key of keys) {
|
||||
if (key === '$ref') {
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
message: '$ref must not be placed next to any other properties',
|
||||
path: [...actualObjPath, key],
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export default refSiblings;
|
||||
92
.stoplight/custom-functions/typedEnum.js
Normal file
92
.stoplight/custom-functions/typedEnum.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { oas2, oas3_0 } from '@stoplight/spectral-formats';
|
||||
import { printValue } from '@stoplight/spectral-runtime';
|
||||
import { createRulesetFunction } from '@stoplight/spectral-core';
|
||||
|
||||
function getDataType(input, checkForInteger) {
|
||||
const type = typeof input;
|
||||
switch (type) {
|
||||
case 'string':
|
||||
case 'boolean':
|
||||
return type;
|
||||
case 'number':
|
||||
if (checkForInteger && Number.isInteger(input)) {
|
||||
return 'integer';
|
||||
}
|
||||
|
||||
return 'number';
|
||||
case 'object':
|
||||
if (input === null) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
return Array.isArray(input) ? 'array' : 'object';
|
||||
default:
|
||||
throw TypeError('Unknown input type');
|
||||
}
|
||||
}
|
||||
|
||||
function getTypes(input, formats) {
|
||||
const { type } = input;
|
||||
|
||||
if (
|
||||
(input.nullable === true && formats?.has(oas3_0) === true) ||
|
||||
(input['x-nullable'] === true && formats?.has(oas2) === true)
|
||||
) {
|
||||
return Array.isArray(type) ? [...type, 'null'] : [type, 'null'];
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
export const typedEnum = createRulesetFunction(
|
||||
{
|
||||
input: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
enum: {
|
||||
type: 'array',
|
||||
},
|
||||
type: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
required: ['enum', 'type'],
|
||||
},
|
||||
options: null,
|
||||
},
|
||||
function (input, opts, context) {
|
||||
const { enum: enumValues } = input;
|
||||
const type = getTypes(input, context.document.formats);
|
||||
const checkForInteger = type === 'integer' || (Array.isArray(type) && type.includes('integer'));
|
||||
|
||||
let results;
|
||||
|
||||
enumValues.forEach((value, i) => {
|
||||
const valueType = getDataType(value, checkForInteger);
|
||||
|
||||
if (valueType === type || (Array.isArray(type) && type.includes(valueType))) {
|
||||
return;
|
||||
}
|
||||
|
||||
results ??= [];
|
||||
results.push({
|
||||
message: `Enum value ${printValue(enumValues[i])} must be "${String(type)}".`,
|
||||
path: [...context.path, 'enum', i],
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
},
|
||||
);
|
||||
|
||||
export default typedEnum;
|
||||
2868
.stoplight/styleguide.json
Normal file
2868
.stoplight/styleguide.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user