Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(rulesets): validate unresolved AsyncAPI document #2262

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/reference/asyncapi-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,12 @@ Validate structure of AsyncAPI v2 specification.

**Recommended:** Yes

### asyncapi-schema-unresolved

Validate unresolved (all "$ref" have not been replaced with the objects they point to) structure of AsyncAPI v2 specification.

**Recommended:** Yes

### asyncapi-server-no-empty-variable

Server URL variable declarations cannot be empty, ex.`gigantic-server.com/{}` is invalid.
Expand Down
6 changes: 6 additions & 0 deletions packages/functions/src/optionSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ export const optionSchemas: Record<string, CustomFunctionOptionsSchema> = {
default: false,
description: 'Returns all errors when true; otherwise only returns the first error.',
},
uniqueId: {
type: 'object',
description:
'Assigns a unique id (by reference to the JS object) to a schema. It is used to optimize the creation of a validation function for a given schema; a given function will be stored by given id and retrieved between execution of validation.',
'x-internal': true,
},
prepareResults: {
'x-internal': true,
},
Expand Down
11 changes: 8 additions & 3 deletions packages/functions/src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@ export type Options = {
schema: Record<string, unknown> | JSONSchema;
allErrors?: boolean;
dialect?: 'auto' | 'draft4' | 'draft6' | 'draft7' | 'draft2019-09' | 'draft2020-12';
uniqueId?: object;
prepareResults?(errors: ErrorObject[]): void;
};

const instances = new WeakMap<RulesetFunctionContext['documentInventory'], ReturnType<typeof createAjvInstances>>();
const instances = new WeakMap<
object | RulesetFunctionContext['documentInventory'],
ReturnType<typeof createAjvInstances>
>();

export default createRulesetFunction<unknown, Options>(
{
Expand All @@ -32,10 +36,11 @@ export default createRulesetFunction<unknown, Options>(
];
}

const uniqueId = opts.uniqueId ?? documentInventory;
const assignAjvInstance =
instances.get(documentInventory) ??
instances.get(uniqueId) ??
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
instances.set(documentInventory, createAjvInstances()).get(documentInventory)!;
instances.set(uniqueId, createAjvInstances()).get(uniqueId)!;

const results: IFunctionResult[] = [];

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { DiagnosticSeverity } from '@stoplight/types';
import testRule from './__helpers__/tester';

testRule('asyncapi-schema-unresolved', [
{
name: 'valid case',
document: {
asyncapi: '2.0.0',
info: {
title: 'Valid AsyncApi document',
version: '1.0',
},
channels: {
someChannel: {
publish: {
message: {
$ref: '#/components/messages/someMessage',
},
},
},
},
components: {
messages: {
someMessage: {},
},
},
},
errors: [],
},

{
name: 'invalid case (reference for operation object is not allowed)',
document: {
asyncapi: '2.0.0',
info: {
title: 'Valid AsyncApi document',
version: '1.0',
},
channels: {
someChannel: {
publish: {
$ref: '#/components/x-operations/someOperation',
},
},
},
components: {
'x-operations': {
someOperation: {},
},
},
},
errors: [
{
message: 'Referencing here is not allowed',
path: ['channels', 'someChannel', 'publish', '$ref'],
severity: DiagnosticSeverity.Error,
},
],
},

{
name: 'invalid case (case when other errors should also occur but we filter them out - required info field is omitted)',
document: {
asyncapi: '2.0.0',
channels: {
someChannel: {
publish: {
$ref: '#/components/x-operations/someOperation',
},
},
},
components: {
'x-operations': {
someOperation: {},
},
},
},
errors: [
{
message: 'Referencing here is not allowed',
path: ['channels', 'someChannel', 'publish', '$ref'],
severity: DiagnosticSeverity.Error,
},
],
},
]);
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,45 @@ testRule('asyncapi-schema', [
},
errors: [],
},

{
name: 'channels property is missing',
name: 'invalid case (channels property is missing)',
document: {
asyncapi: '2.0.0',
info: {
title: 'Valid AsyncApi document',
version: '1.0',
},
},
errors: [{ message: 'Object must have required property "channels"', severity: DiagnosticSeverity.Error }],
errors: [
{
message: 'Object must have required property "channels"',
severity: DiagnosticSeverity.Error,
},
],
},

{
name: 'valid case (case when other errors should also occur but we filter them out)',
document: {
asyncapi: '2.0.0',
info: {
title: 'Valid AsyncApi document',
version: '1.0',
},
channels: {
someChannel: {
publish: {
$ref: '#/components/x-operations/someOperation',
},
},
},
components: {
'x-operations': {
someOperation: {},
},
},
},
errors: [],
},
]);
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ function applyManualReplacements(errors: IFunctionResult[]): void {
}
}

function filterAndSerializeRefErrors(errors: IFunctionResult[]) {
return errors
.filter(err => err.message === 'Property "$ref" is not expected to be here')
.map(err => {
err.message = 'Referencing here is not allowed';
if (err.path && err.path[err.path.length - 1] !== '$ref') {
err.path.push('$ref');
}
return err;
});
}

function getSchema(formats: Set<Format>): Record<string, unknown> | void {
switch (true) {
case formats.has(aas2_0):
Expand All @@ -92,24 +104,49 @@ function getSchema(formats: Set<Format>): Record<string, unknown> | void {
}
}

export default createRulesetFunction<unknown, null>(
// For optimizing the retrieving/creation of AJV's validation function for a given AsyncAPI version.
const UNIQUE_AJV_ID = {};

export default createRulesetFunction<unknown, { resolved: boolean }>(
{
input: null,
options: null,
options: {
type: 'object',
properties: {
resolved: {
type: 'boolean',
},
},
required: ['resolved'],
},
},
function asyncApi2DocumentSchema(targetVal, _, context) {
const formats = context.document.formats;
function asyncApi2DocumentSchema(targetVal, options, ctx) {
const formats = ctx.document.formats;
if (formats === null || formats === void 0) return;

const schema = getSchema(formats);
if (schema === void 0) return;

const errors = schemaFn(targetVal, { allErrors: true, schema, prepareResults }, context);
const errors = schemaFn(
targetVal,
{
allErrors: true,
schema,
prepareResults: options.resolved ? prepareResults : undefined,
uniqueId: UNIQUE_AJV_ID,
},
ctx,
);

if (!Array.isArray(errors)) {
return;
}

if (Array.isArray(errors)) {
if (options.resolved) {
applyManualReplacements(errors);
return errors;
} else {
return filterAndSerializeRefErrors(errors);
}

return errors;
},
);
19 changes: 19 additions & 0 deletions packages/rulesets/src/asyncapi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,25 @@ export default {
given: '$',
then: {
function: asyncApi2DocumentSchema,
functionOptions: {
resolved: true,
},
},
},
'asyncapi-schema-unresolved': {
description:
'Validate unresolved (all "$ref" have not been replaced with the objects they point to) structure of AsyncAPI v2 specification.',
message: '{{error}}',
severity: 'error',
recommended: true,
type: 'validation',
resolved: false,
given: '$',
then: {
function: asyncApi2DocumentSchema,
functionOptions: {
resolved: false,
},
},
},
'asyncapi-server-variables': {
Expand Down