Skip to content

Commit

Permalink
refactor: add iterator for traversing an AsyncAPI doc (asyncapi#604)
Browse files Browse the repository at this point in the history
  • Loading branch information
smoya authored and magicmatatjahu committed Oct 3, 2022
1 parent 565d8ea commit a56ea22
Show file tree
Hide file tree
Showing 3 changed files with 379 additions and 1 deletion.
1 change: 0 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ rules:
no-empty-character-class: 2
no-self-compare: 2
valid-typeof: 2
no-unused-vars: [2, { "args": "none" }]
handle-callback-err: 2
no-shadow-restricted-names: 2
no-new-require: 2
Expand Down
249 changes: 249 additions & 0 deletions src/iterator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import { AsyncAPIDocumentInterface } from 'models';
import { ChannelInterface } from 'models/channel';
import { ChannelParameterInterface } from 'models/channel-parameter';
import { MessageInterface } from 'models/message';
import { MessageTraitInterface } from 'models/message-trait';
import { SchemaInterface } from 'models/schema';

/**
* The different kind of stages when crawling a schema.
*/
export enum SchemaIteratorCallbackType {
NEW_SCHEMA = 'NEW_SCHEMA', // The crawler just started crawling a schema.
END_SCHEMA = 'END_SCHEMA', // The crawler just finished crawling a schema.
}

/**
* The different types of schemas you can iterate
*/
export enum SchemaTypesToIterate {
Parameters = 'parameters', // Crawl all schemas in payloads
Payloads = 'payloads', // Crawl all schemas in payloads
Headers = 'headers', // Crawl all schemas in headers
Components = 'components', // Crawl all schemas in components
Objects = 'objects', // Crawl all schemas of type object
Arrays = 'arrays', // Crawl all schemas of type array
OneOfs = 'oneOfs', // Crawl all schemas in oneOf's
AllOfs = 'allOfs', // Crawl all schemas in allOf's
AnyOfs = 'anyOfs', // Crawl all schemas in anyOf's
Nots = 'nots', // Crawl all schemas in not field
PropertyNames = 'propertyNames', // Crawl all schemas in propertyNames field
PatternProperties = 'patternProperties', // Crawl all schemas in patternProperties field
Contains = 'contains', // Crawl all schemas in contains field
Ifs = 'ifs', // Crawl all schemas in if field
Thenes = 'thenes', // Crawl all schemas in then field
Elses = 'elses', // Crawl all schemas in else field
Dependencies = 'dependencies', // Crawl all schemas in dependencies field
Definitions = 'definitions', // Crawl all schemas in definitions field
}

export type TraverseOptions = {
callback: Function
schemaTypesToIterate: SchemaTypesToIterate[]
seenSchemas: Set<any>
}

export type TraverseCallback = (schema: SchemaInterface, propOrIndex: string | number | null, callbackType: SchemaIteratorCallbackType) => void

/**
* Go through each channel and for each parameter, and message payload and headers recursively call the callback for each schema.
*/
export function traverseAsyncApiDocument(doc: AsyncAPIDocumentInterface, callback: TraverseCallback, schemaTypesToIterate: SchemaTypesToIterate[]) {
if (schemaTypesToIterate.length === 0) {
schemaTypesToIterate = Object.values(SchemaTypesToIterate);
}
const options: TraverseOptions = { callback, schemaTypesToIterate, seenSchemas: new Set() };

if (!doc.channels().isEmpty()) {
doc.channels().all().forEach(channel => {
traverseChannel(channel, options);
});
}
if (schemaTypesToIterate.includes(SchemaTypesToIterate.Components) && !doc.components().isEmpty()) {
const components = doc.components();
Object.values(components.messages().all() || {}).forEach(message => {
traverseMessage(message, options);
});
Object.values(components.schemas().all() || {}).forEach(schema => {
traverseSchema(schema, null, options);
});
if (schemaTypesToIterate.includes(SchemaTypesToIterate.Parameters)) {
Object.values(components.channelParameters().filterBy((param: ChannelParameterInterface) => { return param.hasSchema(); })).forEach(parameter => {
traverseSchema(parameter.schema() as SchemaInterface, null, options);
});
}
Object.values(components.messageTraits().all() || {}).forEach(messageTrait => {
traverseMessageTrait(messageTrait, options);
});
}
}

/* eslint-disable sonarjs/cognitive-complexity */
/**
* Traverse current schema and all nested schemas.
*/
function traverseSchema(schema: SchemaInterface, propOrIndex: string | number | null, options: TraverseOptions) { // NOSONAR
if (!schema) return;

const { schemaTypesToIterate, callback, seenSchemas } = options;

// handle circular references
const jsonSchema = schema.json();
if (seenSchemas.has(jsonSchema)) return;
seenSchemas.add(jsonSchema);

// `type` isn't required so save type as array in the fallback
let types = schema.type() || [];
// change primitive type to array of types for easier handling
if (!Array.isArray(types)) {
types = [types];
}

if (!schemaTypesToIterate.includes(SchemaTypesToIterate.Objects) && types.includes('object')) return;
if (!schemaTypesToIterate.includes(SchemaTypesToIterate.Arrays) && types.includes('array')) return;

// check callback `NEW_SCHEMA` case
if (callback(schema, propOrIndex, SchemaIteratorCallbackType.NEW_SCHEMA) === false) return;

if (schemaTypesToIterate.includes(SchemaTypesToIterate.Objects) && types.includes('object')) {
recursiveSchemaObject(schema, options);
}
if (schemaTypesToIterate.includes(SchemaTypesToIterate.Arrays) && types.includes('array')) {
recursiveSchemaArray(schema, options);
}
if (schemaTypesToIterate.includes(SchemaTypesToIterate.OneOfs)) {
(schema.oneOf() || []).forEach((combineSchema, idx) => {
traverseSchema(combineSchema, idx, options);
});
}
if (schemaTypesToIterate.includes(SchemaTypesToIterate.AnyOfs)) {
(schema.anyOf() || []).forEach((combineSchema, idx) => {
traverseSchema(combineSchema, idx, options);
});
}
if (schemaTypesToIterate.includes(SchemaTypesToIterate.AllOfs)) {
(schema.allOf() || []).forEach((combineSchema, idx) => {
traverseSchema(combineSchema, idx, options);
});
}
if (schemaTypesToIterate.includes(SchemaTypesToIterate.Nots) && schema.not()) {
traverseSchema(schema.not() as SchemaInterface, null, options);
}
if (schemaTypesToIterate.includes(SchemaTypesToIterate.Ifs) && schema.if()) {
traverseSchema(schema.if() as SchemaInterface, null, options);
}
if (schemaTypesToIterate.includes(SchemaTypesToIterate.Thenes) && schema.then()) {
traverseSchema(schema.then() as SchemaInterface, null, options);
}
if (schemaTypesToIterate.includes(SchemaTypesToIterate.Elses) && schema.else()) {
traverseSchema(schema.else() as SchemaInterface, null, options);
}
if (schemaTypesToIterate.includes(SchemaTypesToIterate.Dependencies)) {
Object.entries(schema.dependencies() || {}).forEach(([depName, dep]) => {
// do not iterate dependent required
if (dep && !Array.isArray(dep)) {
traverseSchema(dep, depName, options);
}
});
}
if (schemaTypesToIterate.includes(SchemaTypesToIterate.Definitions)) {
Object.entries(schema.definitions() || {}).forEach(([defName, def]) => {
traverseSchema(def, defName, options);
});
}

callback(schema, propOrIndex, SchemaIteratorCallbackType.END_SCHEMA);
seenSchemas.delete(jsonSchema);
}
/* eslint-enable sonarjs/cognitive-complexity */

/**
* Recursively go through schema of object type and execute callback.
*/
function recursiveSchemaObject(schema: SchemaInterface, options: TraverseOptions) {
Object.entries(schema.properties() || {}).forEach(([propertyName, property]) => {
traverseSchema(property, propertyName, options);
});

const additionalProperties = schema.additionalProperties();
if (typeof additionalProperties === 'object') {
traverseSchema(additionalProperties, null, options);
}

const schemaTypesToIterate = options.schemaTypesToIterate;
if (schemaTypesToIterate.includes(SchemaTypesToIterate.PropertyNames) && schema.propertyNames()) {
traverseSchema(schema.propertyNames() as SchemaInterface, null, options);
}
if (schemaTypesToIterate.includes(SchemaTypesToIterate.PatternProperties)) {
Object.entries(schema.patternProperties() || {}).forEach(([propertyName, property]) => {
traverseSchema(property, propertyName, options);
});
}
}

/**
* Recursively go through schema of array type and execute callback.
*/
function recursiveSchemaArray(schema: SchemaInterface, options: any) {
const items = schema.items();
if (items) {
if (Array.isArray(items)) {
items.forEach((item, idx) => {
traverseSchema(item, idx, options);
});
} else {
traverseSchema(items, null, options);
}
}

const additionalItems = schema.additionalItems();
if (typeof additionalItems === 'object') {
traverseSchema(additionalItems, null, options);
}

if (options.schemaTypesToIterate.includes('contains') && schema.contains()) {
traverseSchema(schema.contains() as SchemaInterface, null, options);
}
}

/**
* Go through each schema in channel
*/
function traverseChannel(channel: ChannelInterface, options: TraverseOptions) {
if (!channel) return;
const { schemaTypesToIterate } = options;
if (schemaTypesToIterate.includes(SchemaTypesToIterate.Parameters)) {
Object.values(channel.parameters().filterBy((param: ChannelParameterInterface) => { return param.hasSchema(); }) || {}).forEach(parameter => {
traverseSchema(parameter.schema() as SchemaInterface, null, options);
});
}

channel.messages().all().forEach(message => {
traverseMessage(message, options);
});
}

/**
* Go through each schema in a message
*/
function traverseMessage(message: MessageInterface, options: TraverseOptions) {
if (!message) return;
const { schemaTypesToIterate } = options;
if (schemaTypesToIterate.includes(SchemaTypesToIterate.Headers) && message.hasHeaders()) {
traverseSchema(message.headers() as SchemaInterface, null, options);
}
if (schemaTypesToIterate.includes(SchemaTypesToIterate.Payloads) && message.hasPayload()) {
traverseSchema(message.payload() as SchemaInterface, null, options);
}
}

/**
* Go through each schema in a messageTrait
*/
function traverseMessageTrait(messageTrait: MessageTraitInterface, options: TraverseOptions) {
if (!messageTrait) return;
const { schemaTypesToIterate } = options;
if (schemaTypesToIterate.includes(SchemaTypesToIterate.Headers) && messageTrait.hasHeaders()) {
traverseSchema(messageTrait.headers() as SchemaInterface, null, options);
}
}
130 changes: 130 additions & 0 deletions test/iterator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { SchemaIteratorCallbackType, SchemaTypesToIterate, traverseAsyncApiDocument, TraverseCallback } from '../src/iterator';
import { Parser } from '../src/parser';
import { parse } from '../src/parse';
import { AsyncAPIDocumentInterface, AsyncAPIDocumentV2, SchemaV2 } from '../src/models';
import { SchemaInterface } from '../src/models/schema';

const document = {
asyncapi: '2.0.0',
info: {
title: 'Valid AsyncApi document',
version: '1.0',
},
channels: {
myChannel: {
publish: {
operationId: 'MyOperation',
message: {
payload: {
type: 'object',
properties: {
exampleField: {
type: 'string'
},
exampleNumber: {
type: 'number'
}
}
}
}
}
}
},
components: {
schemas: {
anotherSchema: {
type: 'object',
properties: {
anotherExampleField: {
type: 'string'
},
anotherExampleNumber: {
type: 'number'
}
}
}
}
}
};

type expectedCallback = {
schema: SchemaInterface,
propOrIndex: string | number | null,
callbackType: SchemaIteratorCallbackType,
}

describe('Traverse AsyncAPI document', function() {
const parser = new Parser();
describe('traverseAsyncApiDocument()', function() {
it('should traverse all possible schemas from a valid document', async function() {
const { parsed } = await parse(parser, document);
expect(parsed).toBeInstanceOf(AsyncAPIDocumentV2);

const payload = parsed?.messages().all()[0].payload() as SchemaV2;
const componentsSchema = parsed?.components().schemas().all()[0] as SchemaV2;
const expectedCalls = [
// Schema from channels
call(payload, SchemaIteratorCallbackType.NEW_SCHEMA),
call(payload, SchemaIteratorCallbackType.NEW_SCHEMA, 'exampleField'),
call(payload, SchemaIteratorCallbackType.END_SCHEMA, 'exampleField'),
call(payload, SchemaIteratorCallbackType.NEW_SCHEMA, 'exampleNumber'),
call(payload, SchemaIteratorCallbackType.END_SCHEMA, 'exampleNumber'),
call(payload, SchemaIteratorCallbackType.END_SCHEMA),
// Schema from components
call(componentsSchema, SchemaIteratorCallbackType.NEW_SCHEMA),
call(componentsSchema, SchemaIteratorCallbackType.NEW_SCHEMA, 'anotherExampleField'),
call(componentsSchema, SchemaIteratorCallbackType.END_SCHEMA, 'anotherExampleField'),
call(componentsSchema, SchemaIteratorCallbackType.NEW_SCHEMA, 'anotherExampleNumber'),
call(componentsSchema, SchemaIteratorCallbackType.END_SCHEMA, 'anotherExampleNumber'),
call(componentsSchema, SchemaIteratorCallbackType.END_SCHEMA)
];

testCallback(expectedCalls, parsed as AsyncAPIDocumentInterface, []);
});

it('should traverse few schemas from a valid document', async function() {
const { parsed } = await parse(parser, document);
expect(parsed).toBeInstanceOf(AsyncAPIDocumentV2);

const componentsSchema = parsed?.components().schemas().all()[0] as SchemaV2;
const expectedCalls = [
// Schema from components
call(componentsSchema, SchemaIteratorCallbackType.NEW_SCHEMA),
call(componentsSchema, SchemaIteratorCallbackType.NEW_SCHEMA, 'anotherExampleField'),
call(componentsSchema, SchemaIteratorCallbackType.END_SCHEMA, 'anotherExampleField'),
call(componentsSchema, SchemaIteratorCallbackType.NEW_SCHEMA, 'anotherExampleNumber'),
call(componentsSchema, SchemaIteratorCallbackType.END_SCHEMA, 'anotherExampleNumber'),
call(componentsSchema, SchemaIteratorCallbackType.END_SCHEMA)
];

testCallback(expectedCalls, parsed as AsyncAPIDocumentInterface, [SchemaTypesToIterate.Components, SchemaTypesToIterate.Objects]);
});
});
});

function testCallback(expectedCalls: expectedCallback[], document: AsyncAPIDocumentInterface, schemaTypesToIterate: SchemaTypesToIterate[]) {
let callsLeft = expectedCalls.length;
const callback = function(schema: SchemaInterface, propOrIndex: string | number | null, callbackType: SchemaIteratorCallbackType): void {
callsLeft--;
const expected = expectedCalls.shift();
expect(schema).toEqual(expected?.schema);
expect(propOrIndex).toEqual(expected?.propOrIndex);
expect(callbackType).toEqual(expected?.callbackType);
};

traverseAsyncApiDocument(document, callback, schemaTypesToIterate);
expect(callsLeft).toEqual(0);
}

function call(schema: SchemaInterface, callbackType: SchemaIteratorCallbackType, propOrIndex: string | number | null = null): expectedCallback {
let schemaProperties = schema;
if (propOrIndex) {
schemaProperties = (schema.properties() as Record<string, SchemaInterface>)[propOrIndex as string];
}

return {
schema: schemaProperties,
propOrIndex,
callbackType,
};
}

0 comments on commit a56ea22

Please sign in to comment.