Skip to content

Commit

Permalink
fix(rulesets): check ids in traits
Browse files Browse the repository at this point in the history
  • Loading branch information
magicmatatjahu committed Sep 26, 2022
1 parent 13d8ed5 commit d9a6cad
Show file tree
Hide file tree
Showing 7 changed files with 257 additions and 33 deletions.
6 changes: 0 additions & 6 deletions packages/formats/src/asyncapi.ts
Expand Up @@ -39,9 +39,3 @@ aas2_3.displayName = 'AsyncAPI 2.3.x';
export const aas2_4: Format = (document: unknown): boolean =>
isAas2(document) && aas2_4Regex.test(String((document as MaybeAAS2).asyncapi));
aas2_4.displayName = 'AsyncAPI 2.4.x';

export const all_aas2: Format[] = [aas2_0, aas2_1, aas2_2, aas2_3, aas2_4];
export const from_aas2_1: Format[] = [aas2_1, aas2_2, aas2_3, aas2_4];
export const from_aas2_2: Format[] = [aas2_2, aas2_3, aas2_4];
export const from_aas2_3: Format[] = [aas2_3, aas2_4];
export const from_aas2_4: Format[] = [aas2_4];
Expand Up @@ -42,6 +42,33 @@ testRule('asyncapi-message-messageId', [
errors: [],
},

{
name: 'valid case (with traits)',
document: {
asyncapi: '2.4.0',
channels: {
one: {
publish: {
message: {
traits: [
{},
{
messageId: 'firstId',
},
],
},
},
subscribe: {
message: {
messageId: 'secondId',
},
},
},
},
},
errors: [],
},

{
name: 'invalid case',
document: {
Expand Down Expand Up @@ -89,6 +116,14 @@ testRule('asyncapi-message-messageId', [
messageId: 'someId',
},
{},
{
traits: [
{},
{
messageId: 'anotherId',
},
],
},
],
},
},
Expand All @@ -113,4 +148,32 @@ testRule('asyncapi-message-messageId', [
},
],
},

{
name: 'invalid case (with traits)',
document: {
asyncapi: '2.4.0',
channels: {
one: {
publish: {
message: {
traits: [{}, {}],
},
},
subscribe: {
message: {
messageId: 'secondId',
},
},
},
},
},
errors: [
{
message: 'Message should have a "messageId" field defined.',
path: ['channels', 'one', 'publish', 'message'],
severity: DiagnosticSeverity.Warning,
},
],
},
]);
@@ -1,39 +1,94 @@
import { DiagnosticSeverity } from '@stoplight/types';
import produce from 'immer';
import testRule from './__helpers__/tester';

const document = {
asyncapi: '2.0.0',
channels: {
one: {
publish: {
operationId: 'onePubId',
},
subscribe: {
operationId: 'oneSubId',
testRule('asyncapi-operation-operationId', [
{
name: 'valid case',
document: {
asyncapi: '2.4.0',
channels: {
one: {
publish: {
operationId: 'firstId',
},
subscribe: {
operationId: 'secondId',
},
},
},
},
errors: [],
},
};

testRule('asyncapi-operation-operationId', [
{
name: 'valid case',
document,
name: 'valid case (with traits)',
document: {
asyncapi: '2.4.0',
channels: {
one: {
publish: {
traits: [
{},
{
operationId: 'firstId',
},
],
},
subscribe: {
operationId: 'secondId',
},
},
},
},
errors: [],
},

...['publish', 'subscribe'].map(property => ({
name: `channels.{channel}.${property}.operationId property is missing`,
document: produce(document, draft => {
delete draft.channels.one[property].operationId;
}),
{
name: 'invalid case',
document: {
asyncapi: '2.4.0',
channels: {
one: {
publish: {},
subscribe: {},
},
},
},
errors: [
{
message: 'Operation should have a "operationId" field defined.',
path: ['channels', 'one', 'publish'],
severity: DiagnosticSeverity.Warning,
},
{
message: 'Operation should have a "operationId" field defined.',
path: ['channels', 'one', 'subscribe'],
severity: DiagnosticSeverity.Warning,
},
],
},

{
name: 'invalid case (with traits)',
document: {
asyncapi: '2.4.0',
channels: {
one: {
publish: {
traits: [{}, {}],
},
subscribe: {
operationId: 'secondId',
},
},
},
},
errors: [
{
message: 'Operation should have a "operationId" field defined.',
path: ['channels', 'one', property],
path: ['channels', 'one', 'publish'],
severity: DiagnosticSeverity.Warning,
},
],
})),
},
]);
34 changes: 34 additions & 0 deletions packages/rulesets/src/asyncapi/functions/asyncApi2CheckId.ts
@@ -0,0 +1,34 @@
import { createRulesetFunction } from '@stoplight/spectral-core';
import { truthy } from '@stoplight/spectral-functions';
import { mergeTraits } from './utils/mergeTraits';

import type { MaybeHaveTraits } from './utils/mergeTraits';

export default createRulesetFunction<MaybeHaveTraits, { idField: 'operationId' | 'messageId' }>(
{
input: {
type: 'object',
properties: {
traits: {
type: 'array',
items: {
type: 'object',
},
},
},
},
options: {
type: 'object',
properties: {
idField: {
type: 'string',
enum: ['operationId', 'messageId'],
},
},
},
},
function asyncApi2CheckId(targetVal, options, ctx) {
const mergedValue = mergeTraits(targetVal);
return truthy(mergedValue[options.idField], null, ctx);
},
);
@@ -0,0 +1,32 @@
import { mergeTraits } from '../mergeTraits';

describe('mergeTraits', () => {
test('should merge one trait', () => {
const result = mergeTraits({ payload: {}, traits: [{ payload: { someKey: 'someValue' } }] });
expect(result.payload).toEqual({ someKey: 'someValue' });
});

test('should merge two or more traits', () => {
const result = mergeTraits({
payload: {},
traits: [
{ payload: { someKey1: 'someValue1' } },
{ payload: { someKey2: 'someValue2' } },
{ payload: { someKey3: 'someValue3' } },
],
});
expect(result.payload).toEqual({ someKey1: 'someValue1', someKey2: 'someValue2', someKey3: 'someValue3' });
});

test('should override fields', () => {
const result = mergeTraits({
payload: { someKey: 'someValue' },
traits: [
{ payload: { someKey: 'someValue1' } },
{ payload: { someKey: 'someValue2' } },
{ payload: { someKey: 'someValue3' } },
],
});
expect(result.payload).toEqual({ someKey: 'someValue3' });
});
});
36 changes: 36 additions & 0 deletions packages/rulesets/src/asyncapi/functions/utils/mergeTraits.ts
@@ -0,0 +1,36 @@
import { isPlainObject } from '@stoplight/json';

export type MaybeHaveTraits = { traits?: any[] } & Record<string, any>;

export function mergeTraits<T extends MaybeHaveTraits>(data: T): T {
if (Array.isArray(data.traits)) {
data = { ...data }; // shallow copy
for (const trait of data.traits as T[]) {
for (const key in trait) {
data[key] = merge(data[key], trait[key]);
}
}
}
return data;
}

function merge<T>(origin: unknown, patch: unknown): T {
// If the patch is not an object, it replaces the origin.
if (!isPlainObject(patch)) {
return patch as T;
}

const result = !isPlainObject(origin)
? {} // Non objects are being replaced.
: Object.assign({}, origin); // Make sure we never modify the origin.

Object.keys(patch).forEach(key => {
const patchVal = patch[key];
if (patchVal === null) {
delete result[key];
} else {
result[key] = merge(result[key], patchVal);
}
});
return result as T;
}
22 changes: 16 additions & 6 deletions packages/rulesets/src/asyncapi/index.ts
@@ -1,4 +1,4 @@
import { all_aas2, from_aas2_4 } from '@stoplight/spectral-formats';
import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4 } from '@stoplight/spectral-formats';
import {
truthy,
pattern,
Expand All @@ -10,15 +10,21 @@ import {

import asyncApi2ChannelParameters from './functions/asyncApi2ChannelParameters';
import asyncApi2ChannelServers from './functions/asyncApi2ChannelServers';
import asyncApi2CheckId from './functions/asyncApi2CheckId';
import asyncApi2DocumentSchema, { latestAsyncApiVersion } from './functions/asyncApi2DocumentSchema';
import asyncApi2MessageExamplesValidation from './functions/asyncApi2MessageExamplesValidation';
import asyncApi2MessageIdUniqueness from './functions/asyncApi2MessageIdUniqueness';
import asyncApi2OperationIdUniqueness from './functions/asyncApi2OperationIdUniqueness';
import asyncApi2SchemaValidation from './functions/asyncApi2SchemaValidation';
import asyncApi2PayloadValidation from './functions/asyncApi2PayloadValidation';
import asyncApi2ServerVariables from './functions/asyncApi2ServerVariables';
import { uniquenessTags } from '../shared/functions';
import asyncApi2Security from './functions/asyncApi2Security';
import { uniquenessTags } from '../shared/functions';

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

const all_aas2: Format[] = [aas2_0, aas2_1, aas2_2, aas2_3, aas2_4];
const from_aas2_4: Format[] = [aas2_4];

export default {
documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/asyncapi-rules.md',
Expand Down Expand Up @@ -236,8 +242,10 @@ export default {
'$.components.messages.*',
],
then: {
field: 'messageId',
function: truthy,
function: asyncApi2CheckId,
functionOptions: {
idField: 'messageId',
},
},
},
'asyncapi-operation-description': {
Expand Down Expand Up @@ -266,8 +274,10 @@ export default {
type: 'style',
given: ['$.channels[*][publish,subscribe]', '$.components.channels[*][publish,subscribe]'],
then: {
field: 'operationId',
function: truthy,
function: asyncApi2CheckId,
functionOptions: {
idField: 'operationId',
},
},
},
'asyncapi-operation-security': {
Expand Down

0 comments on commit d9a6cad

Please sign in to comment.