Skip to content

Commit

Permalink
feat: move spectral ruleset to source code (#699)
Browse files Browse the repository at this point in the history
  • Loading branch information
magicmatatjahu authored Feb 3, 2023
1 parent b135b9c commit 066b9b4
Show file tree
Hide file tree
Showing 71 changed files with 20,874 additions and 383 deletions.
14,617 changes: 14,456 additions & 161 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 4 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,12 @@
"release": "semantic-release"
},
"dependencies": {
"@asyncapi/specs": "^4.0.0",
"@asyncapi/specs": "^4.1.0",
"@openapi-contrib/openapi-schema-to-json-schema": "^3.2.0",
"@stoplight/json-ref-resolver": "^3.1.4",
"@stoplight/spectral-core": "^1.14.2",
"@stoplight/spectral-functions": "^1.7.1",
"@stoplight/json-ref-resolver": "^3.1.5",
"@stoplight/spectral-core": "^1.16.1",
"@stoplight/spectral-functions": "^1.7.2",
"@stoplight/spectral-parsers": "^1.0.2",
"@stoplight/spectral-rulesets": "^1.14.1",
"@types/json-schema": "^7.0.11",
"@types/urijs": "^1.19.19",
"ajv": "^8.11.0",
Expand Down
4 changes: 3 additions & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import type { Spectral } from '@stoplight/spectral-core';
import type { ParseOptions, ParseOutput } from './parse';
import type { ValidateOptions } from './validate';
import type { ResolverOptions } from './resolver';
import type { RulesetOptions } from './ruleset';
import type { SchemaParser } from './schema-parser';
import type { Diagnostic, Input } from './types';

export interface ParserOptions {
ruleset?: RulesetOptions;
schemaParsers?: Array<SchemaParser>;
__unstable?: {
resolver?: ResolverOptions;
Expand All @@ -26,7 +28,7 @@ export class Parser {
constructor(
private readonly options: ParserOptions = {}
) {
this.spectral = createSpectral(this, options?.__unstable?.resolver);
this.spectral = createSpectral(this, options);
this.registerSchemaParser(AsyncAPISchemaParser());
this.options.schemaParsers?.forEach(parser => this.registerSchemaParser(parser));
}
Expand Down
2 changes: 1 addition & 1 deletion src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface ResolverOptions {
export function createResolver(options: ResolverOptions = {}): SpectralResolver {
const availableResolvers: Array<Resolver> = [
...createDefaultResolvers(),
...(options?.resolvers || [])
...(options.resolvers || [])
].map(r => ({
...r,
order: r.order || Number.MAX_SAFE_INTEGER,
Expand Down
51 changes: 51 additions & 0 deletions src/ruleset/formats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/* eslint-disable security/detect-unsafe-regex */

import { isObject } from '../utils';

import type { Format } from '@stoplight/spectral-core';
import type { MaybeAsyncAPI } from '../types';

const aas2Regex = /^2\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
const aas2_0Regex = /^2\.0(?:\.[0-9]*)?$/;
const aas2_1Regex = /^2\.1(?:\.[0-9]*)?$/;
const aas2_2Regex = /^2\.2(?:\.[0-9]*)?$/;
const aas2_3Regex = /^2\.3(?:\.[0-9]*)?$/;
const aas2_4Regex = /^2\.4(?:\.[0-9]*)?$/;
const aas2_5Regex = /^2\.5(?:\.[0-9]*)?$/;
const aas2_6Regex = /^2\.6(?:\.[0-9]*)?$/;

const isAas2 = (document: unknown): document is { asyncapi: string } & Record<string, unknown> =>
isObject(document) && 'asyncapi' in document && aas2Regex.test(String((document as MaybeAsyncAPI).asyncapi));

export const aas2: Format = isAas2;
aas2.displayName = 'AsyncAPI 2.x';

export const aas2_0: Format = (document: unknown): boolean =>
isAas2(document) && aas2_0Regex.test(String((document as MaybeAsyncAPI).asyncapi));
aas2_0.displayName = 'AsyncAPI 2.0.x';

export const aas2_1: Format = (document: unknown): boolean =>
isAas2(document) && aas2_1Regex.test(String((document as MaybeAsyncAPI).asyncapi));
aas2_1.displayName = 'AsyncAPI 2.1.x';

export const aas2_2: Format = (document: unknown): boolean =>
isAas2(document) && aas2_2Regex.test(String((document as MaybeAsyncAPI).asyncapi));
aas2_2.displayName = 'AsyncAPI 2.2.x';

export const aas2_3: Format = (document: unknown): boolean =>
isAas2(document) && aas2_3Regex.test(String((document as MaybeAsyncAPI).asyncapi));
aas2_3.displayName = 'AsyncAPI 2.3.x';

export const aas2_4: Format = (document: unknown): boolean =>
isAas2(document) && aas2_4Regex.test(String((document as MaybeAsyncAPI).asyncapi));
aas2_4.displayName = 'AsyncAPI 2.4.x';

export const aas2_5: Format = (document: unknown): boolean =>
isAas2(document) && aas2_5Regex.test(String((document as MaybeAsyncAPI).asyncapi));
aas2_5.displayName = 'AsyncAPI 2.5.x';

export const aas2_6: Format = (document: unknown): boolean =>
isAas2(document) && aas2_6Regex.test(String((document as MaybeAsyncAPI).asyncapi));
aas2_6.displayName = 'AsyncAPI 2.6.x';

export const aas2All = [aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5, aas2_6];
98 changes: 98 additions & 0 deletions src/ruleset/functions/documentStructure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import specs from '@asyncapi/specs';
import { createRulesetFunction } from '@stoplight/spectral-core';
import { schema as schemaFn } from '@stoplight/spectral-functions';
import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5, aas2_6 } from '../formats';

// import type { ErrorObject } from 'ajv';
import type { IFunctionResult, Format } from '@stoplight/spectral-core';

type AsyncAPIVersions = keyof typeof specs;

function getCopyOfSchema(version: AsyncAPIVersions): Record<string, unknown> {
return JSON.parse(JSON.stringify(specs[version])) as Record<string, unknown>;
}

const serializedSchemas = new Map<AsyncAPIVersions, Record<string, unknown>>();
function getSerializedSchema(version: AsyncAPIVersions): Record<string, unknown> {
const schema = serializedSchemas.get(version);
if (schema) {
return schema;
}

// Copy to not operate on the original json schema - between imports (in different modules) we operate on this same schema.
const copied = getCopyOfSchema(version) as { definitions: Record<string, unknown> };
// Remove the meta schemas because they are already present within Ajv, and it's not possible to add duplicated schemas.
delete copied.definitions['http://json-schema.org/draft-07/schema'];
delete copied.definitions['http://json-schema.org/draft-04/schema'];

serializedSchemas.set(version, copied);
return copied;
}

const refErrorMessage = 'Property "$ref" is not expected to be here';
function filterRefErrors(errors: IFunctionResult[], resolved: boolean) {
if (resolved) {
return errors.filter(err => err.message !== refErrorMessage);
}

return errors
.filter(err => err.message === refErrorMessage)
.map(err => {
err.message = 'Referencing in this place is not allowed';
return err;
});
}

function getSchema(formats: Set<Format>): Record<string, any> | void {
switch (true) {
case formats.has(aas2_6):
return getSerializedSchema('2.6.0');
case formats.has(aas2_5):
return getSerializedSchema('2.5.0');
case formats.has(aas2_4):
return getSerializedSchema('2.4.0');
case formats.has(aas2_3):
return getSerializedSchema('2.3.0');
case formats.has(aas2_2):
return getSerializedSchema('2.2.0');
case formats.has(aas2_1):
return getSerializedSchema('2.1.0');
case formats.has(aas2_0):
return getSerializedSchema('2.0.0');
default:
return;
}
}

export const documentStructure = createRulesetFunction<unknown, { resolved: boolean }>(
{
input: null,
options: {
type: 'object',
properties: {
resolved: {
type: 'boolean',
},
},
required: ['resolved'],
},
},
(targetVal, options, context) => {
const formats = context.document?.formats;
if (!formats) {
return;
}

const schema = getSchema(formats);
if (!schema) {
return;
}

const errors = schemaFn(targetVal, { allErrors: true, schema }, context);
if (!Array.isArray(errors)) {
return;
}

return filterRefErrors(errors, options.resolved);
},
);
12 changes: 12 additions & 0 deletions src/ruleset/functions/internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createRulesetFunction } from '@stoplight/spectral-core';

export const internal = createRulesetFunction<null, null>(
{
input: null,
options: null,
},
(_, __, { document, documentInventory }) => {
// adding document inventory in document - we need it in custom operations to resolve all circular refs
(document as any).__documentInventory = documentInventory;
}
);
31 changes: 31 additions & 0 deletions src/ruleset/functions/isAsyncAPIDocument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { createRulesetFunction } from '@stoplight/spectral-core';
import { specVersions, lastVersion } from '../../constants';
import { isObject } from '../../utils';

import { MaybeAsyncAPI } from '../../types';

export const isAsyncAPIDocument = createRulesetFunction<MaybeAsyncAPI, null>(
{
input: null,
options: null,
},
(targetVal) => {
if (!isObject(targetVal) || typeof targetVal.asyncapi !== 'string') {
return [
{
message: 'This is not an AsyncAPI document. The "asyncapi" field as string is missing.',
path: [],
}
];
}

if (!specVersions.includes(targetVal.asyncapi)) {
return [
{
message: `Version "${targetVal.asyncapi}" is not supported. Please use "${lastVersion}" (latest) version of the specification.`,
path: [],
}
];
}
}
);
50 changes: 50 additions & 0 deletions src/ruleset/functions/uniquenessTags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createRulesetFunction } from '@stoplight/spectral-core';

import type { IFunctionResult } from '@stoplight/spectral-core';

type Tags = Array<{ name: string }>;

function getDuplicateTagsIndexes(tags: Tags): number[] {
return tags
.map(item => item.name)
.reduce<number[]>((acc, item, i, arr) => {
if (arr.indexOf(item) !== i) {
acc.push(i);
}
return acc;
}, []);
}

export const uniquenessTags = createRulesetFunction<Tags, null>(
{
input: {
type: 'array',
items: {
type: 'object',
properties: {
name: {
type: 'string',
},
},
required: ['name'],
},
},
options: null,
},
(targetVal, _, ctx) => {
const duplicatedTags = getDuplicateTagsIndexes(targetVal);
if (duplicatedTags.length === 0) {
return [];
}

const results: IFunctionResult[] = [];
for (const duplicatedIndex of duplicatedTags) {
const duplicatedTag = targetVal[duplicatedIndex].name;
results.push({
message: `"tags" object contains duplicate tag name "${duplicatedTag}".`,
path: [...ctx.path, duplicatedIndex, 'name'],
});
}
return results;
},
);
42 changes: 42 additions & 0 deletions src/ruleset/functions/unusedComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { unreferencedReusableObject } from '@stoplight/spectral-functions';
import { createRulesetFunction } from '@stoplight/spectral-core';
import { isObject } from '../../utils';

import type { IFunctionResult } from '@stoplight/spectral-core';

export const unusedComponent = createRulesetFunction<{ components: Record<string, unknown> }, null>(
{
input: {
type: 'object',
properties: {
components: {
type: 'object',
},
},
required: ['components'],
},
options: null,
},
(targetVal, _, context) => {
const components = targetVal.components;

const results: IFunctionResult[] = [];
Object.keys(components).forEach(componentType => {
const value = components[componentType];
if (!isObject(value)) {
return;
}

const resultsForType = unreferencedReusableObject(
value,
{ reusableObjectsLocation: `#/components/${componentType}` },
context,
);

if (resultsForType && Array.isArray(resultsForType)) {
results.push(...resultsForType);
}
});
return results;
},
);
28 changes: 28 additions & 0 deletions src/ruleset/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { coreRuleset, recommendedRuleset } from './ruleset';
import { v2CoreRuleset, v2SchemasRuleset, v2RecommendedRuleset } from './v2';

import type { Parser } from '../parser';
import type { RulesetDefinition } from '@stoplight/spectral-core';

export type RulesetOptions =
| RulesetDefinition & { core?: boolean, recommended?: boolean }
| RulesetDefinition
| { core?: boolean, recommended?: boolean };

export function createRuleset(parser: Parser, options?: RulesetOptions): RulesetDefinition {
const { core: useCore = true, recommended: useRecommended = true, ...rest } = (options || {}) as (RulesetDefinition & { core?: boolean, recommended?: boolean });

const extendedRuleset = [
useCore && coreRuleset,
useRecommended && recommendedRuleset,
useCore && v2CoreRuleset,
useCore && v2SchemasRuleset(parser),
useRecommended && v2RecommendedRuleset,
...(options as any || {})?.extends || [],
].filter(Boolean);

return {
...rest || {},
extends: extendedRuleset,
} as RulesetDefinition;
}
Loading

0 comments on commit 066b9b4

Please sign in to comment.