diff --git a/README.md b/README.md index b791f8c1a0..01400f49e0 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,10 @@ The following table provides a short summary of available features for supported AsyncAPI We support the following AsyncAPI versions: 2.0.0 -> 2.6.0, which generates models for all the defined message payloads. It supports the following schemaFormats AsyncAPI Schema object, JSON Schema draft 7, AVRO 1.9, RAML 1.0 data type, and OpenAPI 3.0 Schema. + + Avro Schema + We support the following Avro versions: v1.x + JSON Schema We support the following JSON Schema versions: Draft-4, Draft-6 and Draft-7 diff --git a/docs/usage.md b/docs/usage.md index c0b710a6de..b456cad689 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -15,6 +15,7 @@ For more specific integration options, please check out the [integration documen - [Generate models from AsyncAPI documents](#generate-models-from-asyncapi-documents) * [Limitations and Compatibility](#limitations-and-compatibility) + [Polymorphism](#polymorphism) +- [Generate models from Avro Schema documents](#generate-models-from-avro-schema-documents) - [Generate models from JSON Schema documents](#generate-models-from-json-schema-documents) - [Generate models from Swagger 2.0 documents](#generate-models-from-swagger-20-documents) * [Limitations and Compatibility](#limitations-and-compatibility-1) @@ -107,6 +108,16 @@ There are three ways to generate models for a JSON Schema document. The library expects the `$schema` property for the document to be set in order to understand the input format. By default, if no other inputs are detected, it defaults to `JSON Schema draft 7`. The process of interpreting a JSON Schema to a model can be read [here](./inputs/JSON_Schema.md). +## Generate models from Avro Schema documents + +See the below example to get started with Avro Schema for generating models. + +- [Generate from an Avro Schema JS Object](../examples/avro-schema-from-object) + +The Avro input processor expects the `name` and `type` property, as per [Avro Schema specs](https://avro.apache.org/docs/1.11.1/specification/#schema-declaration), in the input object in order to proceed successfully. + +> Note: Currently, we do not have a support for `map`, `fixed` and `byte` data type. It would be introduced soon. + ## Generate models from Swagger 2.0 documents There are one way to generate models from a Swagger 2.0 document. diff --git a/examples/README.md b/examples/README.md index 15783225cb..6a0f7056e5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,6 +43,7 @@ These examples show a specific input and how they can be used: - [asyncapi-raml-schema](./asyncapi-raml-schema) - A basic example of how to use Modelina with an AsyncAPI document using RAML 1.0 data types as payload format. - [asyncapi-from-parser](./asyncapi-from-parser) - A basic example where an AsyncAPI JS object from the [parser-js](https://github.com/asyncapi/parser-js) is used to generate models. - [asyncapi-from-v1-parser](./asyncapi-from-v1-parser) - A basic example where an AsyncAPI JS object from the old v1 [parser-js](https://github.com/asyncapi/parser-js) is used to generate models. +- [avro-schema-from-object](./avro-schema-from-object) - A basic example where an Avro Schema JS Object is used to generate models. - [json-schema-draft7-from-object](./json-schema-draft7-from-object) - A basic example where a JSON Schema draft 7 JS object is used to generate models. - [json-schema-draft6-from-object](./json-schema-draft6-from-object) - A basic example where a JSON Schema draft 6 JS object is used to generate models. - [json-schema-draft4-from-object](./json-schema-draft4-from-object) - A basic example where a JSON Schema draft 4 JS object is used to generate models. diff --git a/examples/avro-schema-from-object/README.md b/examples/avro-schema-from-object/README.md new file mode 100644 index 0000000000..5d9185a34c --- /dev/null +++ b/examples/avro-schema-from-object/README.md @@ -0,0 +1,17 @@ +# Avro Schema Input + +A basic example of how to use Modelina with an Avro Schema JS object to generate models. + +## How to run this example + +Run this example using: + +```sh +npm i && npm run start +``` + +If you are on Windows, use the `start:windows` script instead: + +```sh +npm i && npm run start:windows +``` diff --git a/examples/avro-schema-from-object/__snapshots__/index.spec.ts.snap b/examples/avro-schema-from-object/__snapshots__/index.spec.ts.snap new file mode 100644 index 0000000000..9a10481a44 --- /dev/null +++ b/examples/avro-schema-from-object/__snapshots__/index.spec.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should be able to render model using Avro Schema and should log expected output to console 1`] = ` +Array [ + "class Person { + private _reservedName: string; + + constructor(input: { + reservedName: string, + }) { + this._reservedName = input.reservedName; + } + + get reservedName(): string { return this._reservedName; } + set reservedName(reservedName: string) { this._reservedName = reservedName; } +}", +] +`; diff --git a/examples/avro-schema-from-object/index.spec.ts b/examples/avro-schema-from-object/index.spec.ts new file mode 100644 index 0000000000..514eda6705 --- /dev/null +++ b/examples/avro-schema-from-object/index.spec.ts @@ -0,0 +1,15 @@ +const spy = jest.spyOn(global.console, 'log').mockImplementation(() => { + return; +}); +import { generate } from './index'; + +describe('Should be able to render model using Avro Schema', () => { + afterAll(() => { + jest.restoreAllMocks(); + }); + test('and should log expected output to console', async () => { + await generate(); + expect(spy.mock.calls.length).toEqual(1); + expect(spy.mock.calls[0]).toMatchSnapshot(); + }); +}); diff --git a/examples/avro-schema-from-object/index.ts b/examples/avro-schema-from-object/index.ts new file mode 100644 index 0000000000..2a07a0b10f --- /dev/null +++ b/examples/avro-schema-from-object/index.ts @@ -0,0 +1,27 @@ +import { TypeScriptGenerator } from '../../src'; + +const generator = new TypeScriptGenerator(); +const AvroSchemaDoc = { + name: 'Person', + namespace: 'com.company', + type: 'record', + fields: [ + { + name: 'name', + type: 'string', + example: 'Donkey', + minLength: 0, + maxLenght: 20 + } + ] +}; + +export async function generate(): Promise { + const models = await generator.generate(AvroSchemaDoc); + for (const model of models) { + console.log(model.result); + } +} +if (require.main === module) { + generate(); +} diff --git a/examples/avro-schema-from-object/package-lock.json b/examples/avro-schema-from-object/package-lock.json new file mode 100644 index 0000000000..d7ba6c8425 --- /dev/null +++ b/examples/avro-schema-from-object/package-lock.json @@ -0,0 +1,10 @@ +{ + "name": "avro-to-models", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "hasInstallScript": true + } + } +} diff --git a/examples/avro-schema-from-object/package.json b/examples/avro-schema-from-object/package.json new file mode 100644 index 0000000000..56f1490f97 --- /dev/null +++ b/examples/avro-schema-from-object/package.json @@ -0,0 +1,12 @@ +{ + "config": { + "example_name": "avro-schema-from-object" + }, + "scripts": { + "install": "cd ../.. && npm i", + "start": "../../node_modules/.bin/ts-node --cwd ../../ ./examples/$npm_package_config_example_name/index.ts", + "start:windows": "..\\..\\node_modules\\.bin\\ts-node --cwd ..\\..\\ .\\examples\\%npm_package_config_example_name%\\index.ts", + "test": "../../node_modules/.bin/jest --config=../../jest.config.js ./examples/$npm_package_config_example_name/index.spec.ts", + "test:windows": "..\\..\\node_modules\\.bin\\jest --config=..\\..\\jest.config.js examples/%npm_package_config_example_name%/index.spec.ts" + } +} diff --git a/src/helpers/AvroToMetaModel.ts b/src/helpers/AvroToMetaModel.ts new file mode 100644 index 0000000000..d92598f1da --- /dev/null +++ b/src/helpers/AvroToMetaModel.ts @@ -0,0 +1,362 @@ +import { + AnyModel, + ArrayModel, + AvroSchema, + BooleanModel, + EnumModel, + EnumValueModel, + FloatModel, + IntegerModel, + MetaModel, + MetaModelOptions, + ObjectModel, + ObjectPropertyModel, + StringModel, + UnionModel +} from '../models'; +import { Logger } from '../utils'; + +function getMetaModelOptions(AvroModel: AvroSchema): MetaModelOptions { + const options: MetaModelOptions = {}; + if (Array.isArray(AvroModel.type) && AvroModel.type?.includes('null')) { + options.isNullable = true; + } else { + options.isNullable = false; + } + return options; +} + +function shouldBeAnyType(avroSchemaModel: AvroSchema): boolean { + // check the type array for the any type + const containsAllTypesButNotNull = + Array.isArray(avroSchemaModel.type) && + avroSchemaModel.type.length >= 8 && + !avroSchemaModel.type.includes('null'); + const containsAllTypes = + Array.isArray(avroSchemaModel.type) && avroSchemaModel.type.length === 10; + return containsAllTypesButNotNull || containsAllTypes; +} + +export function AvroToMetaModel( + avroSchemaModel: AvroSchema, + alreadySeenModels: Map = new Map() +): MetaModel { + const hasModel = alreadySeenModels.has(avroSchemaModel); + if (hasModel) { + return alreadySeenModels.get(avroSchemaModel) as MetaModel; + } + + const modelName = avroSchemaModel?.name || 'undefined'; + + if (shouldBeAnyType(avroSchemaModel)) { + return new AnyModel( + modelName, + avroSchemaModel.originalInput, + getMetaModelOptions(avroSchemaModel) + ); + } + if ( + avroSchemaModel.type && + !Array.isArray(avroSchemaModel.type) && + typeof avroSchemaModel.type !== 'string' + ) { + return AvroToMetaModel( + avroSchemaModel.type as AvroSchema, + alreadySeenModels + ); + } + const objectModel = toObjectModel( + avroSchemaModel, + modelName, + alreadySeenModels + ); + if (objectModel !== undefined) { + return objectModel; + } + const arrayModel = toArrayModel( + avroSchemaModel, + modelName, + alreadySeenModels + ); + if (arrayModel !== undefined) { + return arrayModel; + } + const booleanModel = toBooleanModel(avroSchemaModel, modelName); + if (booleanModel !== undefined) { + return booleanModel; + } + const stringModel = toStringModel(avroSchemaModel, modelName); + if (stringModel !== undefined) { + return stringModel; + } + const integerModel = toIntegerModel(avroSchemaModel, modelName); + if (integerModel !== undefined) { + return integerModel; + } + const floatModel = toFloatModel(avroSchemaModel, modelName); + if (floatModel !== undefined) { + return floatModel; + } + const enumModel = toEnumModel(avroSchemaModel, modelName); + if (enumModel !== undefined) { + return enumModel; + } + const unionModel = toUnionModel( + avroSchemaModel, + modelName, + alreadySeenModels + ); + if (unionModel !== undefined) { + return unionModel; + } + + Logger.warn('Failed to convert to MetaModel, defaulting to AnyModel.'); + return new AnyModel( + modelName, + avroSchemaModel.originalInput, + getMetaModelOptions(avroSchemaModel) + ); +} + +export function toBooleanModel( + avroSchemaModel: AvroSchema, + name: string +): BooleanModel | undefined { + if ( + (typeof avroSchemaModel.type === 'string' || + Array.isArray(avroSchemaModel.type)) && + avroSchemaModel.type.includes('boolean') + ) { + return new BooleanModel( + name, + avroSchemaModel.originalInput, + getMetaModelOptions(avroSchemaModel) + ); + } + return undefined; +} +export function toIntegerModel( + avroSchemaModel: AvroSchema, + name: string +): IntegerModel | undefined { + if ( + (typeof avroSchemaModel.type === 'string' || + Array.isArray(avroSchemaModel.type)) && + (avroSchemaModel.type.includes('int') || + avroSchemaModel.type.includes('long')) + ) { + return new IntegerModel( + name, + avroSchemaModel.originalInput, + getMetaModelOptions(avroSchemaModel) + ); + } + return undefined; +} +export function toFloatModel( + avroSchemaModel: AvroSchema, + name: string +): FloatModel | undefined { + if ( + (typeof avroSchemaModel.type === 'string' || + Array.isArray(avroSchemaModel.type)) && + (avroSchemaModel.type.includes('float') || + avroSchemaModel.type.includes('double')) + ) { + return new FloatModel( + name, + avroSchemaModel.originalInput, + getMetaModelOptions(avroSchemaModel) + ); + } + return undefined; +} +export function toStringModel( + avroSchemaModel: AvroSchema, + name: string +): StringModel | undefined { + if ( + (typeof avroSchemaModel.type === 'string' || + Array.isArray(avroSchemaModel.type)) && + avroSchemaModel.type.includes('string') + ) { + return new StringModel( + name, + avroSchemaModel.originalInput, + getMetaModelOptions(avroSchemaModel) + ); + } + return undefined; +} +export function toEnumModel( + avroSchemaModel: AvroSchema, + name: string +): EnumModel | undefined { + if ( + ((typeof avroSchemaModel.type === 'string' || + Array.isArray(avroSchemaModel.type)) && + avroSchemaModel.type.includes('enum')) || + Array.isArray(avroSchemaModel.symbols) + ) { + const enumValueToEnumValueModel = (enumValue: unknown): EnumValueModel => { + if (typeof enumValue !== 'string') { + return new EnumValueModel(JSON.stringify(enumValue), enumValue); + } + return new EnumValueModel(enumValue, enumValue); + }; + + const metaModel = new EnumModel( + name, + avroSchemaModel.originalInput, + getMetaModelOptions(avroSchemaModel), + [] + ); + + if (avroSchemaModel.symbols) { + for (const enumValue of avroSchemaModel.symbols) { + metaModel.values.push(enumValueToEnumValueModel(enumValue)); + } + } + return metaModel; + } + return undefined; +} +export function toUnionModel( + avroSchemaModel: AvroSchema, + name: string, + alreadySeenModels: Map +): UnionModel | undefined { + if (!Array.isArray(avroSchemaModel.type)) { + return undefined; + } + + // Should not create union from two types where one is null, i.e, true for ['string', 'null'] + const containsTypeWithNull = + Array.isArray(avroSchemaModel.type) && + avroSchemaModel.type.length === 2 && + avroSchemaModel.type.includes('null'); + + if (containsTypeWithNull) { + return undefined; + } + + // type: ['string', 'int'] + const unionModel = new UnionModel( + name, + avroSchemaModel.originalInput, + getMetaModelOptions(avroSchemaModel), + [] + ); + + //cache model before continuing + if (!alreadySeenModels.has(avroSchemaModel)) { + alreadySeenModels.set(avroSchemaModel, unionModel); + } + + // Has simple union types + // Each must have a different name then the root union model, as it otherwise clashes when code is generated + const enumModel = toEnumModel(avroSchemaModel, `${name}_enum`); + if (enumModel !== undefined) { + unionModel.union.push(enumModel); + } + const objectModel = toObjectModel( + avroSchemaModel, + `${name}_object`, + alreadySeenModels + ); + if (objectModel !== undefined) { + unionModel.union.push(objectModel); + } + const arrayModel = toArrayModel( + avroSchemaModel, + `${name}_array`, + alreadySeenModels + ); + if (arrayModel !== undefined) { + unionModel.union.push(arrayModel); + } + const stringModel = toStringModel(avroSchemaModel, `${name}_string`); + if (stringModel !== undefined) { + unionModel.union.push(stringModel); + } + const floatModel = toFloatModel(avroSchemaModel, `${name}_float`); + if (floatModel !== undefined) { + unionModel.union.push(floatModel); + } + const integerModel = toIntegerModel(avroSchemaModel, `${name}_integer`); + if (integerModel !== undefined) { + unionModel.union.push(integerModel); + } + const booleanModel = toBooleanModel(avroSchemaModel, `${name}_boolean`); + if (booleanModel !== undefined) { + unionModel.union.push(booleanModel); + } + return unionModel; +} +export function toObjectModel( + avroSchemaModel: AvroSchema, + name: string, + alreadySeenModels: Map +): ObjectModel | undefined { + if ( + (typeof avroSchemaModel.type === 'string' || + Array.isArray(avroSchemaModel.type)) && + avroSchemaModel.type.includes('record') + ) { + const metaModel = new ObjectModel( + name, + avroSchemaModel.originalInput, + getMetaModelOptions(avroSchemaModel), + {} + ); + // cache model before continuing + if (!alreadySeenModels.has(avroSchemaModel)) { + alreadySeenModels.set(avroSchemaModel, metaModel); + } + + // fields: a required attribute of record and a JSON Array of JSON Objects + for (const prop of avroSchemaModel?.fields || []) { + const propertyModel = new ObjectPropertyModel( + prop.name ?? '', + true, + AvroToMetaModel(prop, alreadySeenModels) + ); + metaModel.properties[String(prop.name)] = propertyModel; + } + return metaModel; + } + return undefined; +} +export function toArrayModel( + avroSchemaModel: AvroSchema, + name: string, + alreadySeenModels: Map +): ArrayModel | undefined { + if ( + (typeof avroSchemaModel.type === 'string' || + Array.isArray(avroSchemaModel.type)) && + avroSchemaModel.type?.includes('array') + ) { + const placeholderModel = new AnyModel( + '', + undefined, + getMetaModelOptions(avroSchemaModel) + ); + const metaModel = new ArrayModel( + name, + avroSchemaModel.originalInput, + getMetaModelOptions(avroSchemaModel), + placeholderModel + ); + alreadySeenModels.set(avroSchemaModel, metaModel); + if (avroSchemaModel.items !== undefined) { + const AvroModel = new AvroSchema(); + AvroModel.name = `${name}_${avroSchemaModel.items}`; + AvroModel.type = avroSchemaModel.items; + const valueModel = AvroToMetaModel(AvroModel, alreadySeenModels); + metaModel.valueModel = valueModel; + } + return metaModel; + } + return undefined; +} diff --git a/src/helpers/index.ts b/src/helpers/index.ts index c1c75769dc..b587bd6483 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -8,3 +8,4 @@ export * from './ConstrainHelpers'; export * from './PresetHelpers'; export * from './DependencyHelpers'; export * from './FilterHelpers'; +export * from './AvroToMetaModel'; diff --git a/src/models/AvroSchema.ts b/src/models/AvroSchema.ts new file mode 100644 index 0000000000..1d1dfbcb0a --- /dev/null +++ b/src/models/AvroSchema.ts @@ -0,0 +1,25 @@ +/** + * Avro Schema model + */ + +export class AvroSchema { + type?: string | string[] | AvroSchema; + name?: string; + namespace?: string; + originalInput?: any; + doc?: string; + aliases?: string[]; + symbols?: string[]; + items?: string; + fields?: AvroSchema[]; + example?: string | number; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + pattern?: string; + default?: unknown; + exclusiveMinimum?: unknown; + exclusiveMaximum?: unknown; + logicalType?: unknown; +} diff --git a/src/models/index.ts b/src/models/index.ts index 555d5d5352..42e94f7ca2 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -12,3 +12,4 @@ export * from './OpenapiV3Schema'; export * from './MetaModel'; export * from './ConstrainedMetaModel'; export * from './InputMetaModel'; +export * from './AvroSchema'; diff --git a/src/processors/AvroSchemaInputProcessor.ts b/src/processors/AvroSchemaInputProcessor.ts new file mode 100644 index 0000000000..2dcb3ba329 --- /dev/null +++ b/src/processors/AvroSchemaInputProcessor.ts @@ -0,0 +1,59 @@ +import { AbstractInputProcessor } from './AbstractInputProcessor'; +import { InputMetaModel } from '../models'; +import { Logger } from '../utils'; +import { AvroToMetaModel } from '../helpers/AvroToMetaModel'; + +/** + * Class for processing Avro Schema input + */ + +const avroType = [ + 'null', + 'boolean', + 'int', + 'long', + 'double', + 'float', + 'string', + 'record', + 'enum', + 'array', + 'map' +]; + +export class AvroSchemaInputProcessor extends AbstractInputProcessor { + /** + * Function processing an Avro Schema input + * + * @param input + */ + shouldProcess(input?: any): boolean { + if ( + input === '' || + JSON.stringify(input) === '{}' || + JSON.stringify(input) === '[]' + ) { + return false; + } + if (!avroType.includes(input.type) || !input.name) { + return false; + } + return true; + } + + process(input?: any): Promise { + if (!this.shouldProcess(input)) { + return Promise.reject( + new Error('Input is not an Avro Schema, so it cannot be processed.') + ); + } + Logger.debug('Processing input as Avro Schema document'); + const inputModel = new InputMetaModel(); + inputModel.originalInput = input; + const metaModel = AvroToMetaModel(input); + inputModel.models[metaModel.name] = metaModel; + Logger.debug('Completed processing input as Avro Schema document'); + + return Promise.resolve(inputModel); + } +} diff --git a/src/processors/InputProcessor.ts b/src/processors/InputProcessor.ts index 6dc3529007..6c15aa4aca 100644 --- a/src/processors/InputProcessor.ts +++ b/src/processors/InputProcessor.ts @@ -5,6 +5,7 @@ import { ProcessorOptions, InputMetaModel } from '../models'; import { SwaggerInputProcessor } from './SwaggerInputProcessor'; import { OpenAPIInputProcessor } from './OpenAPIInputProcessor'; import { TypeScriptInputProcessor } from './TypeScriptInputProcessor'; +import { AvroSchemaInputProcessor } from './AvroSchemaInputProcessor'; /** * Main input processor which figures out the type of input it receives and delegates the processing into separate individual processors. @@ -19,6 +20,7 @@ export class InputProcessor { this.setProcessor('openapi', new OpenAPIInputProcessor()); this.setProcessor('default', new JsonSchemaInputProcessor()); this.setProcessor('typescript', new TypeScriptInputProcessor()); + this.setProcessor('avro', new AvroSchemaInputProcessor()); } /** diff --git a/src/processors/index.ts b/src/processors/index.ts index 22a5d42ccc..28a7f17b5f 100644 --- a/src/processors/index.ts +++ b/src/processors/index.ts @@ -5,3 +5,4 @@ export * from './JsonSchemaInputProcessor'; export * from './SwaggerInputProcessor'; export * from './OpenAPIInputProcessor'; export * from './TypeScriptInputProcessor'; +export * from './AvroSchemaInputProcessor'; diff --git a/test/helpers/AvroToMetaModel.spec.ts b/test/helpers/AvroToMetaModel.spec.ts new file mode 100644 index 0000000000..299e7901ac --- /dev/null +++ b/test/helpers/AvroToMetaModel.spec.ts @@ -0,0 +1,191 @@ +import { + AnyModel, + ArrayModel, + AvroSchema, + BooleanModel, + EnumModel, + FloatModel, + IntegerModel, + ObjectModel, + StringModel +} from '../../src'; +import { AvroToMetaModel } from '../../src/helpers'; + +describe('AvroToMetaModel', () => { + describe('nullable', () => { + test('should apply null and string type', () => { + const av = new AvroSchema(); + av.name = 'test'; + av.type = ['string', 'null']; + + const model = AvroToMetaModel(av); + + expect(model).not.toBeUndefined(); + expect(model instanceof StringModel).toBeTruthy(); + expect(model.options.isNullable).toBeTruthy(); + }); + test('should not apply null type', () => { + const av = new AvroSchema(); + av.name = 'test'; + av.type = ['string']; + + const model = AvroToMetaModel(av); + + expect(model).not.toBeUndefined(); + expect(model instanceof StringModel).toBeTruthy(); + expect(model.options.isNullable).toBeFalsy(); + }); + }); + test('should default to any model', () => { + const av = new AvroSchema(); + av.name = 'test'; + + const model = AvroToMetaModel(av); + + expect(model).not.toBeUndefined(); + expect(model instanceof AnyModel).toBeTruthy(); + }); + test('should convert to any model', () => { + const av = new AvroSchema(); + av.type = [ + 'string', + 'float', + 'int', + 'double', + 'enum', + 'long', + 'boolean', + 'record', + 'array', + 'null' + ]; + av.name = 'test'; + + const model = AvroToMetaModel(av); + + expect(model).not.toBeUndefined(); + expect(model instanceof AnyModel).toBeTruthy(); + }); + test('should convert to any model with missing null', () => { + const av = new AvroSchema(); + av.type = [ + 'string', + 'float', + 'int', + 'double', + 'enum', + 'long', + 'boolean', + 'record', + 'array' + ]; + av.name = 'test'; + + const model = AvroToMetaModel(av); + + expect(model).not.toBeUndefined(); + expect(model instanceof AnyModel).toBeTruthy(); + }); + test('should convert to boolean model', () => { + const av = new AvroSchema(); + av.name = 'test'; + av.type = 'boolean'; + + const model = AvroToMetaModel(av); + + expect(model).not.toBeUndefined(); + expect(model instanceof BooleanModel).toEqual(true); + }); + test('should convert to string model', () => { + const av = new AvroSchema(); + av.name = 'test'; + av.type = 'string'; + + const model = AvroToMetaModel(av); + + expect(model).not.toBeUndefined(); + expect(model instanceof StringModel).toBeTruthy(); + }); + test('should convert type float to float model', () => { + const av = new AvroSchema(); + av.name = 'test'; + av.type = 'float'; + + const model = AvroToMetaModel(av); + + expect(model).not.toBeUndefined(); + expect(model instanceof FloatModel).toBeTruthy(); + }); + test('should convert type double to float model', () => { + const av = new AvroSchema(); + av.name = 'test'; + av.type = 'double'; + + const model = AvroToMetaModel(av); + + expect(model).not.toBeUndefined(); + expect(model instanceof FloatModel).toBeTruthy(); + }); + test('should convert type long to integer model', () => { + const av = new AvroSchema(); + av.name = 'test'; + av.type = 'long'; + + const model = AvroToMetaModel(av); + + expect(model).not.toBeUndefined(); + expect(model instanceof IntegerModel).toBeTruthy(); + }); + test('should convert type int to integer model', () => { + const av = new AvroSchema(); + av.name = 'test'; + av.type = 'int'; + + const model = AvroToMetaModel(av); + + expect(model).not.toBeUndefined(); + expect(model instanceof IntegerModel).toBeTruthy(); + }); + test('should convert to object model', () => { + const av = new AvroSchema(); + av.name = 'test'; + av.type = 'record'; + + const model = AvroToMetaModel(av); + + expect(model).not.toBeUndefined(); + expect(model instanceof ObjectModel).toBeTruthy(); + }); + test('should convert to enum model', () => { + const av = new AvroSchema(); + av.name = 'test'; + av.type = 'enum'; + av.symbols = ['hello', '2']; + + const model = AvroToMetaModel(av); + + expect(model).not.toBeUndefined(); + expect(model instanceof EnumModel).toBeTruthy(); + }); + test('should convert to array model', () => { + const av = new AvroSchema(); + av.name = 'test'; + av.type = 'array'; + av.items = 'string'; + + const model = AvroToMetaModel(av); + + expect(model).not.toBeUndefined(); + expect(model instanceof ArrayModel).toBeTruthy(); + }); + test('should handle AvroSchema value of type', () => { + const av = new AvroSchema(); + av.name = 'test'; + av.type = { name: 'test1', type: 'int' }; + + const model = AvroToMetaModel(av); + + expect(model).not.toBeUndefined(); + expect(model instanceof IntegerModel).toBeTruthy(); + }); +}); diff --git a/test/processors/AvroSchemaInputProcessor.spec.ts b/test/processors/AvroSchemaInputProcessor.spec.ts new file mode 100644 index 0000000000..e166dd5bd6 --- /dev/null +++ b/test/processors/AvroSchemaInputProcessor.spec.ts @@ -0,0 +1,67 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { AvroSchemaInputProcessor } from '../../src/processors'; + +const basicDoc = JSON.parse( + fs.readFileSync( + path.resolve(__dirname, './AvroSchemaInputProcessor/basic.json'), + 'utf8' + ) +); +describe('AvroSchemaInputProcessor', () => { + describe('shouldProcess()', () => { + const processor = new AvroSchemaInputProcessor(); + test('should fail correctly for empty string input', () => { + expect(processor.shouldProcess('')).toBeFalsy(); + }); + + test('should fail correctly for empty object input', () => { + expect(processor.shouldProcess({})).toBeFalsy(); + }); + + test('should fail correctly for empty array input', () => { + expect(processor.shouldProcess([])).toBeFalsy(); + }); + + test('should fail if input has no name property', () => { + expect( + processor.shouldProcess({ prop: 'hello', type: 'string' }) + ).toBeFalsy(); + }); + + test('should fail if input has no type property', () => { + expect( + processor.shouldProcess({ name: 'hello', someKey: 'someValue' }) + ).toBeFalsy(); + }); + + test('should return true for valid input', () => { + const processor = new AvroSchemaInputProcessor(); + const result = processor.shouldProcess({ name: 'hello', type: 'string' }); + expect(result).toBeTruthy(); + }); + test('should fail for invalid type', () => { + const processor = new AvroSchemaInputProcessor(); + const result = processor.shouldProcess({ + name: 'hello', + type: 'someValue' + }); + expect(result).toBeFalsy(); + }); + }); + + describe('process()', () => { + test('should throw error when trying to process wrong schema', async () => { + const processor = new AvroSchemaInputProcessor(); + await expect(processor.process({ someKey: 'someValue' })).rejects.toThrow( + 'Input is not an Avro Schema, so it cannot be processed.' + ); + }); + + test('should process Avro Schema', async () => { + const processor = new AvroSchemaInputProcessor(); + const result = await processor.process(basicDoc); + expect(result).toMatchSnapshot(); + }); + }); +}); diff --git a/test/processors/AvroSchemaInputProcessor/basic.json b/test/processors/AvroSchemaInputProcessor/basic.json new file mode 100644 index 0000000000..f96290e884 --- /dev/null +++ b/test/processors/AvroSchemaInputProcessor/basic.json @@ -0,0 +1,68 @@ +{ + "name": "Person", + "namespace": "com.company", + "type": "record", + "fields": [ + { "name": "name", "type": "string", "example": "Donkey", "minLength": 0 }, + { "name": "serialNo", "type": "string", "minLength": 0, "maxLength": 50 }, + { + "name": "email", + "type": ["null", "string"], + "example": "donkey@asyncapi.com", + "pattern": "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$" + }, + { + "name": "age", + "type": ["null", "int"], + "default": null, + "example": 123, + "exclusiveMinimum": 0, + "exclusiveMaximum": 200 + }, + { + "name": "favoriteProgrammingLanguage", + "type": { + "name": "ProgrammingLanguage", + "type": "enum", + "symbols": ["JS", "Java", "Go", "Rust", "C"], + "default": "JS" + } + }, + { + "name": "certifications", + "type": { + "type": "array", + "items": "string", + "minItems": 1, + "maxItems": 500, + "uniqueItems": true + } + }, + { + "name": "address", + "type": { + "name": "Address", + "type": "record", + "fields": [ + { "name": "zipcode", "type": "int", "example": 53003 }, + { "name": "country", "type": ["null", "string"] } + ] + } + }, + { + "name": "weight", + "type": "float", + "example": 65.1, + "minimum": 0, + "maximum": 500 + }, + { + "name": "height", + "type": "double", + "example": 1.85, + "minimum": 0, + "maximum": 3.0 + }, + { "name": "someid", "type": "string", "logicalType": "uuid" } + ] +} diff --git a/test/processors/InputProcessor.spec.ts b/test/processors/InputProcessor.spec.ts index f09b22eeb2..367786c517 100644 --- a/test/processors/InputProcessor.spec.ts +++ b/test/processors/InputProcessor.spec.ts @@ -4,6 +4,7 @@ import { InputMetaModel, ProcessorOptions } from '../../src/models'; import { AbstractInputProcessor, AsyncAPIInputProcessor, + AvroSchemaInputProcessor, JsonSchemaInputProcessor, InputProcessor, SwaggerInputProcessor @@ -58,6 +59,9 @@ describe('InputProcessor', () => { const asyncInputProcessor = new AsyncAPIInputProcessor(); jest.spyOn(asyncInputProcessor, 'shouldProcess'); jest.spyOn(asyncInputProcessor, 'process'); + const avroSchemaInputProcessor = new AvroSchemaInputProcessor(); + jest.spyOn(avroSchemaInputProcessor, 'shouldProcess'); + jest.spyOn(avroSchemaInputProcessor, 'process'); const defaultInputProcessor = new JsonSchemaInputProcessor(); jest.spyOn(defaultInputProcessor, 'shouldProcess'); jest.spyOn(defaultInputProcessor, 'process'); @@ -70,12 +74,14 @@ describe('InputProcessor', () => { const processor = new InputProcessor(); processor.setProcessor('asyncapi', asyncInputProcessor); processor.setProcessor('swagger', swaggerInputProcessor); + processor.setProcessor('avro', avroSchemaInputProcessor); processor.setProcessor('openapi', openAPIInputProcessor); processor.setProcessor('default', defaultInputProcessor); return { processor, asyncInputProcessor, swaggerInputProcessor, + avroSchemaInputProcessor, openAPIInputProcessor, defaultInputProcessor }; @@ -177,7 +183,27 @@ describe('InputProcessor', () => { expect(defaultInputProcessor.process).not.toHaveBeenCalled(); expect(defaultInputProcessor.shouldProcess).not.toHaveBeenCalled(); }); - + test('should be able to process Avro Schema input', async () => { + const { processor, avroSchemaInputProcessor, defaultInputProcessor } = + getProcessors(); + const inputSchemaString = fs.readFileSync( + path.resolve(__dirname, './AvroSchemaInputProcessor/basic.json'), + 'utf8' + ); + const inputSchema = JSON.parse(inputSchemaString); + await processor.process(inputSchema); + expect(avroSchemaInputProcessor.process).toHaveBeenNthCalledWith( + 1, + inputSchema, + undefined + ); + expect(avroSchemaInputProcessor.shouldProcess).toHaveBeenNthCalledWith( + 1, + inputSchema + ); + expect(defaultInputProcessor.process).not.toHaveBeenCalled(); + expect(defaultInputProcessor.shouldProcess).not.toHaveBeenCalled(); + }); test('should be able to process AsyncAPI schema input with options', async () => { const { processor, asyncInputProcessor, defaultInputProcessor } = getProcessors(); diff --git a/test/processors/__snapshots__/AvroSchemaInputProcessor.spec.ts.snap b/test/processors/__snapshots__/AvroSchemaInputProcessor.spec.ts.snap new file mode 100644 index 0000000000..31f4cadd6d --- /dev/null +++ b/test/processors/__snapshots__/AvroSchemaInputProcessor.spec.ts.snap @@ -0,0 +1,284 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AvroSchemaInputProcessor process() should process Avro Schema 1`] = ` +InputMetaModel { + "models": Object { + "Person": ObjectModel { + "name": "Person", + "options": Object { + "isNullable": false, + }, + "originalInput": undefined, + "properties": Object { + "address": ObjectPropertyModel { + "property": ObjectModel { + "name": "Address", + "options": Object { + "isNullable": false, + }, + "originalInput": undefined, + "properties": Object { + "country": ObjectPropertyModel { + "property": StringModel { + "name": "country", + "options": Object { + "isNullable": true, + }, + "originalInput": undefined, + }, + "propertyName": "country", + "required": true, + }, + "zipcode": ObjectPropertyModel { + "property": IntegerModel { + "name": "zipcode", + "options": Object { + "isNullable": false, + }, + "originalInput": undefined, + }, + "propertyName": "zipcode", + "required": true, + }, + }, + }, + "propertyName": "address", + "required": true, + }, + "age": ObjectPropertyModel { + "property": IntegerModel { + "name": "age", + "options": Object { + "isNullable": true, + }, + "originalInput": undefined, + }, + "propertyName": "age", + "required": true, + }, + "certifications": ObjectPropertyModel { + "property": ArrayModel { + "name": "undefined", + "options": Object { + "isNullable": false, + }, + "originalInput": undefined, + "valueModel": StringModel { + "name": "undefined_string", + "options": Object { + "isNullable": false, + }, + "originalInput": undefined, + }, + }, + "propertyName": "certifications", + "required": true, + }, + "email": ObjectPropertyModel { + "property": StringModel { + "name": "email", + "options": Object { + "isNullable": true, + }, + "originalInput": undefined, + }, + "propertyName": "email", + "required": true, + }, + "favoriteProgrammingLanguage": ObjectPropertyModel { + "property": EnumModel { + "name": "ProgrammingLanguage", + "options": Object { + "isNullable": false, + }, + "originalInput": undefined, + "values": Array [ + EnumValueModel { + "key": "JS", + "value": "JS", + }, + EnumValueModel { + "key": "Java", + "value": "Java", + }, + EnumValueModel { + "key": "Go", + "value": "Go", + }, + EnumValueModel { + "key": "Rust", + "value": "Rust", + }, + EnumValueModel { + "key": "C", + "value": "C", + }, + ], + }, + "propertyName": "favoriteProgrammingLanguage", + "required": true, + }, + "height": ObjectPropertyModel { + "property": FloatModel { + "name": "height", + "options": Object { + "isNullable": false, + }, + "originalInput": undefined, + }, + "propertyName": "height", + "required": true, + }, + "name": ObjectPropertyModel { + "property": StringModel { + "name": "name", + "options": Object { + "isNullable": false, + }, + "originalInput": undefined, + }, + "propertyName": "name", + "required": true, + }, + "serialNo": ObjectPropertyModel { + "property": StringModel { + "name": "serialNo", + "options": Object { + "isNullable": false, + }, + "originalInput": undefined, + }, + "propertyName": "serialNo", + "required": true, + }, + "someid": ObjectPropertyModel { + "property": StringModel { + "name": "someid", + "options": Object { + "isNullable": false, + }, + "originalInput": undefined, + }, + "propertyName": "someid", + "required": true, + }, + "weight": ObjectPropertyModel { + "property": FloatModel { + "name": "weight", + "options": Object { + "isNullable": false, + }, + "originalInput": undefined, + }, + "propertyName": "weight", + "required": true, + }, + }, + }, + }, + "originalInput": Object { + "fields": Array [ + Object { + "example": "Donkey", + "minLength": 0, + "name": "name", + "type": "string", + }, + Object { + "maxLength": 50, + "minLength": 0, + "name": "serialNo", + "type": "string", + }, + Object { + "example": "donkey@asyncapi.com", + "name": "email", + "pattern": "^[\\\\w-\\\\.]+@([\\\\w-]+\\\\.)+[\\\\w-]{2,4}$", + "type": Array [ + "null", + "string", + ], + }, + Object { + "default": null, + "example": 123, + "exclusiveMaximum": 200, + "exclusiveMinimum": 0, + "name": "age", + "type": Array [ + "null", + "int", + ], + }, + Object { + "name": "favoriteProgrammingLanguage", + "type": Object { + "default": "JS", + "name": "ProgrammingLanguage", + "symbols": Array [ + "JS", + "Java", + "Go", + "Rust", + "C", + ], + "type": "enum", + }, + }, + Object { + "name": "certifications", + "type": Object { + "items": "string", + "maxItems": 500, + "minItems": 1, + "type": "array", + "uniqueItems": true, + }, + }, + Object { + "name": "address", + "type": Object { + "fields": Array [ + Object { + "example": 53003, + "name": "zipcode", + "type": "int", + }, + Object { + "name": "country", + "type": Array [ + "null", + "string", + ], + }, + ], + "name": "Address", + "type": "record", + }, + }, + Object { + "example": 65.1, + "maximum": 500, + "minimum": 0, + "name": "weight", + "type": "float", + }, + Object { + "example": 1.85, + "maximum": 3, + "minimum": 0, + "name": "height", + "type": "double", + }, + Object { + "logicalType": "uuid", + "name": "someid", + "type": "string", + }, + ], + "name": "Person", + "namespace": "com.company", + "type": "record", + }, +} +`;