diff --git a/examples/generate-scala-enums/README.md b/examples/generate-scala-enums/README.md new file mode 100644 index 0000000000..167791ec4a --- /dev/null +++ b/examples/generate-scala-enums/README.md @@ -0,0 +1,17 @@ +# Scala Enums + +A basic example of how to use Modelina and output a Scala enumeration. + +## 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/generate-scala-enums/index.spec.ts b/examples/generate-scala-enums/index.spec.ts new file mode 100644 index 0000000000..7afa11cb9c --- /dev/null +++ b/examples/generate-scala-enums/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 Kotlin Enums', () => { + 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/generate-scala-enums/index.ts b/examples/generate-scala-enums/index.ts new file mode 100644 index 0000000000..458cc94c8d --- /dev/null +++ b/examples/generate-scala-enums/index.ts @@ -0,0 +1,20 @@ +import { ScalaGenerator } from '../../src/generators/scala'; + +const generator = new ScalaGenerator(); +const jsonSchemaDraft7 = { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + $id: 'protocol', + type: ['string', 'int', 'boolean'], + enum: ['HTTP', 1, 'HTTPS', true] +}; + +export async function generate(): Promise { + const models = await generator.generate(jsonSchemaDraft7); + for (const model of models) { + console.log(model.result); + } +} +if (require.main === module) { + generate(); +} diff --git a/examples/generate-scala-enums/package-lock.json b/examples/generate-scala-enums/package-lock.json new file mode 100644 index 0000000000..71205d5b99 --- /dev/null +++ b/examples/generate-scala-enums/package-lock.json @@ -0,0 +1,10 @@ +{ + "name": "generate-scala-enums", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "hasInstallScript": true + } + } +} diff --git a/examples/generate-scala-enums/package.json b/examples/generate-scala-enums/package.json new file mode 100644 index 0000000000..f3f7301a7c --- /dev/null +++ b/examples/generate-scala-enums/package.json @@ -0,0 +1,12 @@ +{ + "config": { + "example_name": "generate-scala-enums" + }, + "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/examples/generate-scala-models/README.md b/examples/generate-scala-models/README.md new file mode 100644 index 0000000000..c8770e41e7 --- /dev/null +++ b/examples/generate-scala-models/README.md @@ -0,0 +1,17 @@ +# Scala Data Models + +A basic example of how to use Modelina and output a Scala data model. + +## 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/generate-scala-models/index.spec.ts b/examples/generate-scala-models/index.spec.ts new file mode 100644 index 0000000000..010afc6245 --- /dev/null +++ b/examples/generate-scala-models/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 Kotlin Models', () => { + afterAll(() => { + jest.restoreAllMocks(); + }); + test('and should log expected output to console', async () => { + await generate(); + expect(spy.mock.calls.length).toEqual(3); + expect(spy.mock.calls[0]).toMatchSnapshot(); + }); +}); diff --git a/examples/generate-scala-models/index.ts b/examples/generate-scala-models/index.ts new file mode 100644 index 0000000000..74cde5421c --- /dev/null +++ b/examples/generate-scala-models/index.ts @@ -0,0 +1,41 @@ +import { ScalaGenerator } from '../../src/generators/scala'; + +const generator = new ScalaGenerator(); +const jsonSchemaDraft7 = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + additionalProperties: false, + properties: { + email: { + type: 'string', + format: 'email' + }, + cache: { + type: 'integer' + }, + website: { + type: 'object', + additionalProperties: false, + properties: { + domain: { + type: 'string', + format: 'url' + }, + protocol: { + type: 'string', + enum: ['HTTP', 'HTTPS'] + } + } + } + } +}; + +export async function generate(): Promise { + const models = await generator.generate(jsonSchemaDraft7); + for (const model of models) { + console.log(model.result); + } +} +if (require.main === module) { + generate(); +} diff --git a/examples/generate-scala-models/package-lock.json b/examples/generate-scala-models/package-lock.json new file mode 100644 index 0000000000..61b5cb38ef --- /dev/null +++ b/examples/generate-scala-models/package-lock.json @@ -0,0 +1,10 @@ +{ + "name": "generate-scala-models", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "hasInstallScript": true + } + } +} diff --git a/examples/generate-scala-models/package.json b/examples/generate-scala-models/package.json new file mode 100644 index 0000000000..5ca775bc64 --- /dev/null +++ b/examples/generate-scala-models/package.json @@ -0,0 +1,12 @@ +{ + "config": { + "example_name": "generate-scala-models" + }, + "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/generators/scala/Constants.ts b/src/generators/scala/Constants.ts new file mode 100644 index 0000000000..69415eeb33 --- /dev/null +++ b/src/generators/scala/Constants.ts @@ -0,0 +1,50 @@ +import { checkForReservedKeyword } from '../../helpers'; + +export const RESERVED_SCALA_KEYWORDS = [ + 'abstract', + 'case', + 'catch', + 'class', + 'def', + 'do', + 'else', + 'extends', + 'false', + 'final', + 'finally', + 'for', + 'forSome', + 'if', + 'implicit', + 'import', + 'lazy', + 'match', + 'new', + 'null', + 'object', + 'override', + 'package', + 'private', + 'protected', + 'return', + 'sealed', + 'super', + 'this', + 'throw', + 'trait', + 'true', + 'try', + 'type', + 'val', + 'var', + 'while', + 'with', + 'yield' +]; + +export function isReservedScalaKeyword( + word: string, + forceLowerCase = true +): boolean { + return checkForReservedKeyword(word, RESERVED_SCALA_KEYWORDS, forceLowerCase); +} diff --git a/src/generators/scala/ScalaConstrainer.ts b/src/generators/scala/ScalaConstrainer.ts new file mode 100644 index 0000000000..f004c279ec --- /dev/null +++ b/src/generators/scala/ScalaConstrainer.ts @@ -0,0 +1,156 @@ +import { Constraints } from '../../helpers'; +import { ConstrainedEnumValueModel } from '../../models'; +import { + defaultEnumKeyConstraints, + defaultEnumValueConstraints +} from './constrainer/EnumConstrainer'; +import { defaultModelNameConstraints } from './constrainer/ModelNameConstrainer'; +import { defaultPropertyKeyConstraints } from './constrainer/PropertyKeyConstrainer'; +import { defaultConstantConstraints } from './constrainer/ConstantConstrainer'; +import { ScalaTypeMapping } from './ScalaGenerator'; + +function enumFormatToNumberType( + enumValueModel: ConstrainedEnumValueModel, + format: string | undefined +): string { + switch (format) { + case 'integer': + case 'int32': + return 'Int'; + case 'long': + case 'int64': + return 'Long'; + case 'float': + return 'Float'; + case 'double': + return 'Double'; + default: + return Number.isInteger(enumValueModel.value) ? 'Int' : 'Double'; + } +} + +function fromEnumValueToKotlinType( + enumValueModel: ConstrainedEnumValueModel, + format: string | undefined +): string { + switch (typeof enumValueModel.value) { + case 'boolean': + return 'Boolean'; + case 'number': + case 'bigint': + return enumFormatToNumberType(enumValueModel, format); + case 'object': + return 'Any'; + case 'string': + return 'String'; + default: + return 'Any'; + } +} + +/** + * Converts union of different number types to the most strict type it can be. + * + * int + double = double (long + double, float + double can never happen, otherwise this would be converted to double) + * int + float = float (long + float can never happen, otherwise this would be the case as well) + * int + long = long + * + * Basically a copy from JavaConstrainer.ts + */ +function interpretUnionValueType(types: string[]): string { + if (types.includes('Double')) { + return 'Double'; + } + + if (types.includes('Float')) { + return 'Float'; + } + + if (types.includes('Long')) { + return 'Long'; + } + + return 'Any'; +} + +export const ScalaDefaultTypeMapping: ScalaTypeMapping = { + Object({ constrainedModel }): string { + return constrainedModel.name; + }, + Reference({ constrainedModel }): string { + return constrainedModel.name; + }, + Any(): string { + return 'Any'; + }, + Float({ constrainedModel }): string { + return constrainedModel.options.format === 'float' ? 'Float' : 'Double'; + }, + Integer({ constrainedModel }): string { + return constrainedModel.options.format === 'long' || + constrainedModel.options.format === 'int64' + ? 'Long' + : 'Int'; + }, + String({ constrainedModel }): string { + switch (constrainedModel.options.format) { + case 'date': { + return 'java.time.LocalDate'; + } + case 'time': { + return 'java.time.OffsetTime'; + } + case 'dateTime': + case 'date-time': { + return 'java.time.OffsetDateTime'; + } + case 'binary': { + return 'Array[Byte]'; + } + default: { + return 'String'; + } + } + }, + Boolean(): string { + return 'Boolean'; + }, + // Since there are not tuples in Kotlin, we have to return a collection of `Any` + Tuple({ options }): string { + const isList = options.collectionType && options.collectionType === 'List'; + + return isList ? 'List[Any]' : 'Array[Any]'; + }, + Array({ constrainedModel, options }): string { + const isList = options.collectionType && options.collectionType === 'List'; + const type = constrainedModel.valueModel.type; + + return isList ? `List[${type}]` : `Array[${type}]`; + }, + Enum({ constrainedModel }): string { + const valueTypes = constrainedModel.values.map((enumValue) => + fromEnumValueToKotlinType(enumValue, constrainedModel.options.format) + ); + const uniqueTypes = [...new Set(valueTypes)]; + + // Enums cannot handle union types, default to a loose type + return uniqueTypes.length > 1 + ? interpretUnionValueType(uniqueTypes) + : uniqueTypes[0]; + }, + Union(): string { + // No Unions in Kotlin, use Any for now. + return 'Any'; + }, + Dictionary({ constrainedModel }): string { + return `Map[${constrainedModel.key.type}, ${constrainedModel.value.type}]`; + } +}; + +export const ScalaDefaultConstraints: Constraints = { + enumKey: defaultEnumKeyConstraints(), + enumValue: defaultEnumValueConstraints(), + modelName: defaultModelNameConstraints(), + propertyKey: defaultPropertyKeyConstraints(), + constant: defaultConstantConstraints() +}; diff --git a/src/generators/scala/ScalaDependencyManager.ts b/src/generators/scala/ScalaDependencyManager.ts new file mode 100644 index 0000000000..d49d728a54 --- /dev/null +++ b/src/generators/scala/ScalaDependencyManager.ts @@ -0,0 +1,20 @@ +import { AbstractDependencyManager } from '../AbstractDependencyManager'; +import { ScalaOptions } from './ScalaGenerator'; + +export class ScalaDependencyManager extends AbstractDependencyManager { + constructor( + public options: ScalaOptions, + dependencies: string[] = [] + ) { + super(dependencies); + } + + /** + * Adds a dependency package ensuring correct syntax. + * + * @param dependencyPackage package to import, for example `javax.validation.constraints.*` + */ + addDependency(dependencyPackage: string): void { + super.addDependency(`import ${dependencyPackage}`); + } +} diff --git a/src/generators/scala/ScalaFileGenerator.ts b/src/generators/scala/ScalaFileGenerator.ts new file mode 100644 index 0000000000..dab37a9636 --- /dev/null +++ b/src/generators/scala/ScalaFileGenerator.ts @@ -0,0 +1,43 @@ +import { ScalaGenerator, ScalaRenderCompleteModelOptions } from '.'; +import { InputMetaModel, OutputModel } from '../../models'; +import * as path from 'path'; +import { AbstractFileGenerator } from '../AbstractFileGenerator'; +import { FileHelpers } from '../../helpers'; + +export class ScalaFileGenerator + extends ScalaGenerator + implements AbstractFileGenerator +{ + /** + * Generates all the models to an output directory each model with their own separate files. + * + * @param input + * @param outputDirectory where you want the models generated to + * @param options + * @param ensureFilesWritten verify that the files is completely written before returning, this can happen if the file system is swamped with write requests. + */ + public async generateToFiles( + input: Record | InputMetaModel, + outputDirectory: string, + options: ScalaRenderCompleteModelOptions, + ensureFilesWritten = false + ): Promise { + let generatedModels = await this.generateCompleteModels(input, options); + //Filter anything out that have not been successfully generated + generatedModels = generatedModels.filter((outputModel) => { + return outputModel.modelName !== ''; + }); + for (const outputModel of generatedModels) { + const filePath = path.resolve( + outputDirectory, + `${outputModel.modelName}.scala` + ); + await FileHelpers.writerToFileSystem( + outputModel.result, + filePath, + ensureFilesWritten + ); + } + return generatedModels; + } +} diff --git a/src/generators/scala/ScalaGenerator.ts b/src/generators/scala/ScalaGenerator.ts new file mode 100644 index 0000000000..c765b8af8b --- /dev/null +++ b/src/generators/scala/ScalaGenerator.ts @@ -0,0 +1,260 @@ +import { + AbstractGenerator, + AbstractGeneratorRenderArgs, + AbstractGeneratorRenderCompleteModelArgs, + CommonGeneratorOptions, + defaultGeneratorOptions +} from '../AbstractGenerator'; +import { + ConstrainedEnumModel, + ConstrainedMetaModel, + ConstrainedObjectModel, + InputMetaModel, + MetaModel, + RenderOutput +} from '../../models'; +import { split, TypeMapping } from '../../helpers'; +import { ScalaPreset, SCALA_DEFAULT_PRESET } from './ScalaPreset'; +import { ClassRenderer } from './renderers/ClassRenderer'; +import { EnumRenderer } from './renderers/EnumRenderer'; +import { isReservedScalaKeyword } from './Constants'; +import { Logger } from '../..'; +import { + constrainMetaModel, + Constraints +} from '../../helpers/ConstrainHelpers'; +import { + ScalaDefaultConstraints, + ScalaDefaultTypeMapping +} from './ScalaConstrainer'; +import { DeepPartial, mergePartialAndDefault } from '../../utils/Partials'; +import { ScalaDependencyManager } from './ScalaDependencyManager'; + +export interface ScalaOptions extends CommonGeneratorOptions { + typeMapping: TypeMapping; + constraints: Constraints; + collectionType: 'List' | 'Array'; +} + +export type ScalaTypeMapping = TypeMapping< + ScalaOptions, + ScalaDependencyManager +>; + +export interface ScalaRenderCompleteModelOptions { + packageName: string; +} + +export class ScalaGenerator extends AbstractGenerator< + ScalaOptions, + ScalaRenderCompleteModelOptions +> { + static defaultOptions: ScalaOptions = { + ...defaultGeneratorOptions, + defaultPreset: SCALA_DEFAULT_PRESET, + collectionType: 'List', + typeMapping: ScalaDefaultTypeMapping, + constraints: ScalaDefaultConstraints + }; + + constructor(options?: DeepPartial) { + const realizedOptions = ScalaGenerator.getScalaOptions(options); + super('Scala', realizedOptions); + } + + /** + * Returns the Scala options by merging custom options with default ones. + */ + static getScalaOptions(options?: DeepPartial): ScalaOptions { + const optionsToUse = mergePartialAndDefault( + ScalaGenerator.defaultOptions, + options + ) as ScalaOptions; + //Always overwrite the dependency manager unless user explicitly state they want it (ignore default temporary dependency manager) + if (options?.dependencyManager === undefined) { + optionsToUse.dependencyManager = () => { + return new ScalaDependencyManager(optionsToUse); + }; + } + return optionsToUse; + } + + /** + * Wrapper to get an instance of the dependency manager + */ + getDependencyManager(options: ScalaOptions): ScalaDependencyManager { + return this.getDependencyManagerInstance(options) as ScalaDependencyManager; + } + + /** + * This function makes sure we split up the MetaModels accordingly to what we want to render as models. + */ + splitMetaModel(model: MetaModel): MetaModel[] { + const metaModelsToSplit = { + splitEnum: true, + splitObject: true + }; + return split(model, metaModelsToSplit); + } + + constrainToMetaModel( + model: MetaModel, + options: DeepPartial + ): ConstrainedMetaModel { + const optionsToUse = ScalaGenerator.getScalaOptions({ + ...this.options, + ...options + }); + const dependencyManagerToUse = this.getDependencyManager(optionsToUse); + return constrainMetaModel( + this.options.typeMapping, + this.options.constraints, + { + metaModel: model, + dependencyManager: dependencyManagerToUse, + options: this.options, + constrainedName: '' //This is just a placeholder, it will be constrained within the function + } + ); + } + + /** + * Render a scattered model, where the source code and library and model dependencies are separated. + * + * @param model + * @param inputModel + */ + render( + args: AbstractGeneratorRenderArgs + ): Promise { + const optionsToUse = ScalaGenerator.getScalaOptions({ + ...this.options, + ...args.options + }); + if (args.constrainedModel instanceof ConstrainedObjectModel) { + return this.renderClass( + args.constrainedModel, + args.inputModel, + optionsToUse + ); + } else if (args.constrainedModel instanceof ConstrainedEnumModel) { + return this.renderEnum( + args.constrainedModel, + args.inputModel, + optionsToUse + ); + } + Logger.warn( + `Scala generator, cannot generate this type of model, ${args.constrainedModel.name}` + ); + return Promise.resolve( + RenderOutput.toRenderOutput({ + result: '', + renderedName: '', + dependencies: [] + }) + ); + } + + /** + * Render a complete model result where the model code, library and model dependencies are all bundled appropriately. + * + * For Scala you need to specify which package the model is placed under. + * + * @param model + * @param inputModel + * @param options used to render the full output + */ + async renderCompleteModel( + args: AbstractGeneratorRenderCompleteModelArgs< + ScalaOptions, + ScalaRenderCompleteModelOptions + > + ): Promise { + const optionsToUse = ScalaGenerator.getScalaOptions({ + ...this.options, + ...args.options + }); + const outputModel = await this.render({ + ...args, + options: optionsToUse + }); + const packageName = this.sanitizePackageName( + args.completeOptions.packageName ?? 'Asyncapi.Models' + ); + const outputContent = `package ${packageName} +${outputModel.dependencies.join('\n')} + +${outputModel.result}`; + return RenderOutput.toRenderOutput({ + result: outputContent, + renderedName: outputModel.renderedName, + dependencies: outputModel.dependencies + }); + } + + private sanitizePackageName(packageName: string): string { + return packageName + .split('.') + .map((subpackage) => + isReservedScalaKeyword(subpackage, true) + ? `\`${subpackage}\`` + : subpackage + ) + .join('.'); + } + + async renderClass( + model: ConstrainedObjectModel, + inputModel: InputMetaModel, + options?: Partial + ): Promise { + const optionsToUse = ScalaGenerator.getScalaOptions({ + ...this.options, + ...options + }); + const dependencyManagerToUse = this.getDependencyManager(optionsToUse); + const presets = this.getPresets('class'); + const renderer = new ClassRenderer( + this.options, + this, + presets, + model, + inputModel, + dependencyManagerToUse + ); + const result = await renderer.runSelfPreset(); + return RenderOutput.toRenderOutput({ + result, + renderedName: model.name, + dependencies: dependencyManagerToUse.dependencies + }); + } + + async renderEnum( + model: ConstrainedEnumModel, + inputModel: InputMetaModel, + options?: Partial + ): Promise { + const optionsToUse = ScalaGenerator.getScalaOptions({ + ...this.options, + ...options + }); + const dependencyManagerToUse = this.getDependencyManager(optionsToUse); + const presets = this.getPresets('enum'); + const renderer = new EnumRenderer( + this.options, + this, + presets, + model, + inputModel, + dependencyManagerToUse + ); + const result = await renderer.runSelfPreset(); + return RenderOutput.toRenderOutput({ + result, + renderedName: model.name, + dependencies: dependencyManagerToUse.dependencies + }); + } +} diff --git a/src/generators/scala/ScalaPreset.ts b/src/generators/scala/ScalaPreset.ts new file mode 100644 index 0000000000..19388a4fdd --- /dev/null +++ b/src/generators/scala/ScalaPreset.ts @@ -0,0 +1,24 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import { Preset, ClassPreset, EnumPreset } from '../../models'; +import { ScalaOptions } from './ScalaGenerator'; +import { + ClassRenderer, + SCALA_DEFAULT_CLASS_PRESET +} from './renderers/ClassRenderer'; +import { + EnumRenderer, + SCALA_DEFAULT_ENUM_PRESET +} from './renderers/EnumRenderer'; + +export type ClassPresetType = ClassPreset; +export type EnumPresetType = EnumPreset; + +export type ScalaPreset = Preset<{ + class: ClassPresetType; + enum: EnumPresetType; +}>; + +export const SCALA_DEFAULT_PRESET: ScalaPreset = { + class: SCALA_DEFAULT_CLASS_PRESET, + enum: SCALA_DEFAULT_ENUM_PRESET +}; diff --git a/src/generators/scala/ScalaRenderer.ts b/src/generators/scala/ScalaRenderer.ts new file mode 100644 index 0000000000..c5120264bd --- /dev/null +++ b/src/generators/scala/ScalaRenderer.ts @@ -0,0 +1,69 @@ +import { AbstractRenderer } from '../AbstractRenderer'; +import { ScalaGenerator, ScalaOptions } from './ScalaGenerator'; +import { ConstrainedMetaModel, InputMetaModel, Preset } from '../../models'; +import { FormatHelpers } from '../../helpers'; +import { ScalaDependencyManager } from './ScalaDependencyManager'; + +/** + * Common renderer for Scala + * + * @extends AbstractRenderer + */ +export abstract class ScalaRenderer< + RendererModelType extends ConstrainedMetaModel +> extends AbstractRenderer { + constructor( + options: ScalaOptions, + generator: ScalaGenerator, + presets: Array<[Preset, unknown]>, + model: RendererModelType, + inputModel: InputMetaModel, + public dependencyManager: ScalaDependencyManager + ) { + super(options, generator, presets, model, inputModel); + } + + renderComments(lines: string | string[]): string { + lines = FormatHelpers.breakLines(lines); + const newLiteral = lines.map((line) => ` * ${line}`).join('\n'); + return `/** +${newLiteral} + */`; + } + + renderAnnotation( + annotationName: string, + value?: any | Record + ): string { + const name = `@${annotationName}`; + + if (value === undefined || value === null) { + return name; + } + + if (typeof value !== 'object') { + return `${name}(${value})`; + } + + const entries = Object.entries(value || {}); + + if (entries.length === 0) { + return name; + } + + const values = concatenateEntries(entries); + return `${name}(${values})`; + } +} + +function concatenateEntries(entries: [string, unknown][] = []): string { + return entries + .map(([paramName, newValue]) => { + if (paramName && newValue !== undefined) { + return `${paramName}=${newValue}`; + } + return newValue; + }) + .filter((v) => v !== undefined) + .join(', '); +} diff --git a/src/generators/scala/constrainer/ConstantConstrainer.ts b/src/generators/scala/constrainer/ConstantConstrainer.ts new file mode 100644 index 0000000000..2c076ad46d --- /dev/null +++ b/src/generators/scala/constrainer/ConstantConstrainer.ts @@ -0,0 +1,7 @@ +import { ConstantConstraint } from '../../../helpers'; + +export function defaultConstantConstraints(): ConstantConstraint { + return () => { + return undefined; + }; +} diff --git a/src/generators/scala/constrainer/EnumConstrainer.ts b/src/generators/scala/constrainer/EnumConstrainer.ts new file mode 100644 index 0000000000..9ba2e2cb83 --- /dev/null +++ b/src/generators/scala/constrainer/EnumConstrainer.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { ConstrainedEnumModel, EnumModel } from '../../../models'; +import { + NO_NUMBER_START_CHAR, + NO_DUPLICATE_ENUM_KEYS, + NO_EMPTY_VALUE, + NO_RESERVED_KEYWORDS +} from '../../../helpers/Constraints'; +import { + FormatHelpers, + EnumKeyConstraint, + EnumValueConstraint +} from '../../../helpers'; +import { isReservedScalaKeyword } from '../Constants'; + +export type ModelEnumKeyConstraints = { + NO_SPECIAL_CHAR: (value: string) => string; + NO_NUMBER_START_CHAR: (value: string) => string; + NO_DUPLICATE_KEYS: ( + constrainedEnumModel: ConstrainedEnumModel, + enumModel: EnumModel, + value: string, + namingFormatter: (value: string) => string + ) => string; + NO_EMPTY_VALUE: (value: string) => string; + NAMING_FORMATTER: (value: string) => string; + NO_RESERVED_KEYWORDS: (value: string) => string; +}; + +export const DefaultEnumKeyConstraints: ModelEnumKeyConstraints = { + NO_SPECIAL_CHAR: (value: string) => { + //Exclude ` ` because it gets formatted by NAMING_FORMATTER + //Exclude '_' because they are allowed as enum keys + return FormatHelpers.replaceSpecialCharacters(value, { + exclude: [' ', '_'], + separator: '_' + }); + }, + NO_NUMBER_START_CHAR, + NO_DUPLICATE_KEYS: NO_DUPLICATE_ENUM_KEYS, + NO_EMPTY_VALUE, + NAMING_FORMATTER: FormatHelpers.toConstantCase, + NO_RESERVED_KEYWORDS: (value: string) => { + return NO_RESERVED_KEYWORDS(value, isReservedScalaKeyword); + } +}; + +/** + * Default constraint logic for Scala, which converts the enum key into a key that is compatible with Scala + */ +export function defaultEnumKeyConstraints( + customConstraints?: Partial +): EnumKeyConstraint { + const constraints = { ...DefaultEnumKeyConstraints, ...customConstraints }; + + return ({ enumKey, enumModel, constrainedEnumModel }) => { + let constrainedEnumKey = enumKey; + constrainedEnumKey = constraints.NO_SPECIAL_CHAR(constrainedEnumKey); + constrainedEnumKey = constraints.NO_NUMBER_START_CHAR(constrainedEnumKey); + constrainedEnumKey = constraints.NO_EMPTY_VALUE(constrainedEnumKey); + constrainedEnumKey = constraints.NO_RESERVED_KEYWORDS(constrainedEnumKey); + //If the enum key has been manipulated, lets make sure it don't clash with existing keys + if (constrainedEnumKey !== enumKey) { + constrainedEnumKey = constraints.NO_DUPLICATE_KEYS( + constrainedEnumModel, + enumModel, + constrainedEnumKey, + constraints.NAMING_FORMATTER! + ); + } + constrainedEnumKey = constraints.NAMING_FORMATTER(constrainedEnumKey); + return constrainedEnumKey; + }; +} + +/** + * Convert the enum value to a value that is compatible with Scala + */ +export function defaultEnumValueConstraints(): EnumValueConstraint { + return ({ enumValue }) => { + let constrainedEnumValue = enumValue; + switch (typeof enumValue) { + case 'string': + case 'boolean': + constrainedEnumValue = `"${enumValue}"`; + break; + case 'bigint': + case 'number': { + constrainedEnumValue = enumValue; + break; + } + case 'object': { + constrainedEnumValue = `"${JSON.stringify(enumValue).replace( + /"/g, + '\\"' + )}"`; + break; + } + default: { + constrainedEnumValue = `"${JSON.stringify(enumValue)}"`; + } + } + return constrainedEnumValue; + }; +} diff --git a/src/generators/scala/constrainer/ModelNameConstrainer.ts b/src/generators/scala/constrainer/ModelNameConstrainer.ts new file mode 100644 index 0000000000..c9c523dff2 --- /dev/null +++ b/src/generators/scala/constrainer/ModelNameConstrainer.ts @@ -0,0 +1,53 @@ +import { + NO_NUMBER_START_CHAR, + NO_EMPTY_VALUE, + NO_RESERVED_KEYWORDS +} from '../../../helpers/Constraints'; +import { FormatHelpers, ModelNameConstraint } from '../../../helpers'; +import { isReservedScalaKeyword } from '../Constants'; + +export type ModelNameConstraints = { + NO_SPECIAL_CHAR: (value: string) => string; + NO_NUMBER_START_CHAR: (value: string) => string; + NO_EMPTY_VALUE: (value: string) => string; + NAMING_FORMATTER: (value: string) => string; + NO_RESERVED_KEYWORDS: (value: string) => string; +}; + +export const DefaultModelNameConstraints: ModelNameConstraints = { + NO_SPECIAL_CHAR: (value: string) => { + //Exclude ` ` because it gets formatted by NAMING_FORMATTER + //Exclude '_' because they are allowed + return FormatHelpers.replaceSpecialCharacters(value, { + exclude: [' ', '_'], + separator: '_' + }); + }, + NO_NUMBER_START_CHAR, + NO_EMPTY_VALUE, + NAMING_FORMATTER: (value: string) => { + return FormatHelpers.toPascalCase(value); + }, + NO_RESERVED_KEYWORDS: (value: string) => { + return NO_RESERVED_KEYWORDS(value, isReservedScalaKeyword); + } +}; + +/** + * Default constraint logic for Scala, which converts the model name into something that is compatible with Scala + */ +export function defaultModelNameConstraints( + customConstraints?: Partial +): ModelNameConstraint { + const constraints = { ...DefaultModelNameConstraints, ...customConstraints }; + + return ({ modelName }) => { + let constrainedValue = modelName; + constrainedValue = constraints.NO_SPECIAL_CHAR(constrainedValue); + constrainedValue = constraints.NO_NUMBER_START_CHAR(constrainedValue); + constrainedValue = constraints.NO_EMPTY_VALUE(constrainedValue); + constrainedValue = constraints.NO_RESERVED_KEYWORDS(constrainedValue); + constrainedValue = constraints.NAMING_FORMATTER(constrainedValue); + return constrainedValue; + }; +} diff --git a/src/generators/scala/constrainer/PropertyKeyConstrainer.ts b/src/generators/scala/constrainer/PropertyKeyConstrainer.ts new file mode 100644 index 0000000000..640761546c --- /dev/null +++ b/src/generators/scala/constrainer/PropertyKeyConstrainer.ts @@ -0,0 +1,80 @@ +import { ConstrainedObjectModel, ObjectModel } from '../../../models'; +import { + NO_NUMBER_START_CHAR, + NO_DUPLICATE_PROPERTIES, + NO_EMPTY_VALUE, + NO_RESERVED_KEYWORDS +} from '../../../helpers/Constraints'; +import { FormatHelpers, PropertyKeyConstraint } from '../../../helpers'; +import { isReservedScalaKeyword } from '../Constants'; + +export type PropertyKeyConstraintOptions = { + NO_SPECIAL_CHAR: (value: string) => string; + NO_NUMBER_START_CHAR: (value: string) => string; + NO_DUPLICATE_PROPERTIES: ( + constrainedObjectModel: ConstrainedObjectModel, + objectModel: ObjectModel, + propertyName: string, + namingFormatter: (value: string) => string + ) => string; + NO_EMPTY_VALUE: (value: string) => string; + NAMING_FORMATTER: (value: string) => string; + NO_RESERVED_KEYWORDS: (value: string) => string; +}; + +export const DefaultPropertyKeyConstraints: PropertyKeyConstraintOptions = { + NO_SPECIAL_CHAR: (value: string) => { + //Exclude ` ` because it gets formatted by NAMING_FORMATTER + //Exclude '_' because they are allowed + return FormatHelpers.replaceSpecialCharacters(value, { + exclude: [' ', '_'], + separator: '_' + }); + }, + NO_NUMBER_START_CHAR, + NO_DUPLICATE_PROPERTIES, + NO_EMPTY_VALUE, + NAMING_FORMATTER: FormatHelpers.toCamelCase, + NO_RESERVED_KEYWORDS: (value: string) => { + return NO_RESERVED_KEYWORDS(value, isReservedScalaKeyword); + } +}; +/** + * Default constraint logic for Scala, which converts the object property key into something that is compatible with Scala + */ +export function defaultPropertyKeyConstraints( + customConstraints?: Partial +): PropertyKeyConstraint { + const constraints = { + ...DefaultPropertyKeyConstraints, + ...customConstraints + }; + + return ({ objectPropertyModel, constrainedObjectModel, objectModel }) => { + let constrainedPropertyKey = objectPropertyModel.propertyName; + + constrainedPropertyKey = constraints.NO_SPECIAL_CHAR( + constrainedPropertyKey + ); + constrainedPropertyKey = constraints.NO_NUMBER_START_CHAR( + constrainedPropertyKey + ); + constrainedPropertyKey = constraints.NO_EMPTY_VALUE(constrainedPropertyKey); + constrainedPropertyKey = constraints.NO_RESERVED_KEYWORDS( + constrainedPropertyKey + ); + //If the property name has been manipulated, lets make sure it don't clash with existing properties + if (constrainedPropertyKey !== objectPropertyModel.propertyName) { + constrainedPropertyKey = constraints.NO_DUPLICATE_PROPERTIES( + constrainedObjectModel, + objectModel, + constrainedPropertyKey, + constraints.NAMING_FORMATTER + ); + } + constrainedPropertyKey = constraints.NAMING_FORMATTER( + constrainedPropertyKey + ); + return constrainedPropertyKey; + }; +} diff --git a/src/generators/scala/index.ts b/src/generators/scala/index.ts new file mode 100644 index 0000000000..d538f6c328 --- /dev/null +++ b/src/generators/scala/index.ts @@ -0,0 +1,21 @@ +export * from './ScalaGenerator'; +export * from './ScalaFileGenerator'; +export { SCALA_DEFAULT_PRESET } from './ScalaPreset'; +export type { ScalaPreset } from './ScalaPreset'; +export * from './presets'; + +export { + defaultEnumKeyConstraints as scalaDefaultEnumKeyConstraints, + DefaultEnumKeyConstraints as ScalaDefaultEnumKeyConstraints, + defaultEnumValueConstraints as scalaDefaultEnumValueConstraints +} from './constrainer/EnumConstrainer'; + +export { + DefaultModelNameConstraints as ScalaDefaultModelNameConstraints, + defaultModelNameConstraints as scalaDefaultModelNameConstraints +} from './constrainer/ModelNameConstrainer'; + +export { + DefaultPropertyKeyConstraints as ScalaDefaultPropertyKeyConstraints, + defaultPropertyKeyConstraints as scalaDefaultPropertyKeyConstraints +} from './constrainer/PropertyKeyConstrainer'; diff --git a/src/generators/scala/presets/DescriptionPreset.ts b/src/generators/scala/presets/DescriptionPreset.ts new file mode 100644 index 0000000000..a7fb1c7ac2 --- /dev/null +++ b/src/generators/scala/presets/DescriptionPreset.ts @@ -0,0 +1,63 @@ +import { ScalaRenderer } from '../ScalaRenderer'; +import { ScalaPreset } from '../ScalaPreset'; +import { FormatHelpers } from '../../../helpers'; +import { ConstrainedEnumModel, ConstrainedObjectModel } from '../../../models'; +function renderDescription({ + renderer, + content, + item +}: { + renderer: ScalaRenderer; + content: string; + item: ConstrainedObjectModel | ConstrainedEnumModel; +}): string { + if (!item.originalInput['description']) { + return content; + } + + let comment = `${item.originalInput['description']}`; + + if (item instanceof ConstrainedObjectModel) { + const properties = Object.keys(item.properties) + .map((key) => item.properties[`${key}`]) + .map((model) => { + const property = `@property ${model.propertyName}`; + const desc = model.property.originalInput['description']; + + return desc !== undefined ? `${property} ${desc}` : property; + }) + .join('\n'); + + comment += `\n\n${properties}`; + } + + const examples = Array.isArray(item.originalInput['examples']) + ? `Examples: \n${FormatHelpers.renderJSONExamples( + item.originalInput['examples'] + )}` + : null; + + if (examples !== null) { + comment += `\n\n${examples}`; + } + + return `${renderer.renderComments(comment)}\n${content}`; +} + +/** + * Preset which adds description to rendered model. + * + * @implements {ScalaPreset} + */ +export const SCALA_DESCRIPTION_PRESET: ScalaPreset = { + class: { + self({ renderer, model, content }) { + return renderDescription({ renderer, content, item: model }); + } + }, + enum: { + self({ renderer, model, content }) { + return renderDescription({ renderer, content, item: model }); + } + } +}; diff --git a/src/generators/scala/presets/index.ts b/src/generators/scala/presets/index.ts new file mode 100644 index 0000000000..e74c57b288 --- /dev/null +++ b/src/generators/scala/presets/index.ts @@ -0,0 +1 @@ +export * from './DescriptionPreset'; diff --git a/src/generators/scala/renderers/ClassRenderer.ts b/src/generators/scala/renderers/ClassRenderer.ts new file mode 100644 index 0000000000..d175300b07 --- /dev/null +++ b/src/generators/scala/renderers/ClassRenderer.ts @@ -0,0 +1,66 @@ +import { ScalaRenderer } from '../ScalaRenderer'; +import { + ConstrainedObjectModel, + ConstrainedObjectPropertyModel +} from '../../../models'; +import { ScalaOptions } from '../ScalaGenerator'; +import { ClassPresetType } from '../ScalaPreset'; + +function getPropertyType(property: ConstrainedObjectPropertyModel): string { + return property.required + ? property.property.type + : `Option[${property.property.type}]`; +} + +/** + * Renderer for Scala's `class` type + * + * @extends ScalaRenderer + */ +export class ClassRenderer extends ScalaRenderer { + async defaultSelf(hasProperties: boolean): Promise { + return hasProperties + ? await this.defaultWithProperties() + : `class ${this.model.name} {}`; + } + + private async defaultWithProperties(): Promise { + const content = [ + await this.renderProperties(), + await this.runAdditionalContentPreset() + ]; + + return `case class ${this.model.name}( +${this.indent(this.renderBlock(content, 2))} +)`; + } + + async renderProperties(): Promise { + const properties = this.model.properties || {}; + const content: string[] = []; + + for (const property of Object.values(properties)) { + const rendererProperty = await this.runPropertyPreset(property); + content.push(rendererProperty); + } + + return this.renderBlock(content); + } + + runPropertyPreset(property: ConstrainedObjectPropertyModel): Promise { + return this.runPreset('property', { property }); + } +} + +export const SCALA_DEFAULT_CLASS_PRESET: ClassPresetType = { + self({ renderer, model }) { + const hasProperties = Object.keys(model.properties).length > 0; + + return renderer.defaultSelf(hasProperties); + }, + property({ property }) { + const propertyType = getPropertyType(property); + + return `${property.propertyName}: ${propertyType},`; + } +}; diff --git a/src/generators/scala/renderers/EnumRenderer.ts b/src/generators/scala/renderers/EnumRenderer.ts new file mode 100644 index 0000000000..45986fb6a8 --- /dev/null +++ b/src/generators/scala/renderers/EnumRenderer.ts @@ -0,0 +1,60 @@ +import { ScalaRenderer } from '../ScalaRenderer'; +import { + ConstrainedEnumModel, + ConstrainedEnumValueModel +} from '../../../models'; +import { EnumPresetType } from '../ScalaPreset'; +import { ScalaOptions } from '../ScalaGenerator'; +import { FormatHelpers } from '../../../helpers'; + +/** + * Renderer for Scala's `enum` type + * + * @extends ScalaRenderer + */ +export class EnumRenderer extends ScalaRenderer { + async defaultSelf(): Promise { + const content = [ + await this.renderItems(), + await this.runFromValuePreset(), + await this.runAdditionalContentPreset() + ]; + return `object ${this.model.name} extends Enumeration { + type ${this.model.name} = Value + +${this.indent(this.renderBlock(content, 2))} +}`; + } + + async renderItems(): Promise { + const enums = this.model.values || []; + const items: string[] = []; + + for (const value of enums) { + const renderedItem = await this.runItemPreset(value); + items.push(renderedItem); + } + + const content = items.join('\n'); + return `${content}`; + } + + runItemPreset(item: ConstrainedEnumValueModel): Promise { + return this.runPreset('item', { item }); + } + + runFromValuePreset(): Promise { + return this.runPreset('fromValue'); + } +} + +export const SCALA_DEFAULT_ENUM_PRESET: EnumPresetType = { + self({ renderer }) { + return renderer.defaultSelf(); + }, + item({ item, model }) { + const key = FormatHelpers.toPascalCase(item.key); + + return `val ${key}: ${model.name}.Value = Value(${item.value})`; + } +}; diff --git a/test/TestUtils/TestRenderers.ts b/test/TestUtils/TestRenderers.ts index 56ca121ff8..063d71bf99 100644 --- a/test/TestUtils/TestRenderers.ts +++ b/test/TestUtils/TestRenderers.ts @@ -15,6 +15,7 @@ import { RustRenderer } from '../../src/generators/rust/RustRenderer'; import { PythonRenderer } from '../../src/generators/python/PythonRenderer'; import { KotlinRenderer } from '../../src/generators/kotlin/KotlinRenderer'; import { PhpRenderer } from '../../src/generators/php/PhpRenderer'; +import { ScalaRenderer } from '../../src/generators/scala/ScalaRenderer'; export class TestRenderer extends AbstractRenderer { constructor(presets = []) { @@ -43,3 +44,4 @@ export class MockRustRenderer extends RustRenderer {} export class MockPythonRenderer extends PythonRenderer {} export class MockKotlinRenderer extends KotlinRenderer {} export class MockPhpRenderer extends PhpRenderer {} +export class MockScalaRenderer extends ScalaRenderer {} diff --git a/test/generators/scala/Constants.spec.ts b/test/generators/scala/Constants.spec.ts new file mode 100644 index 0000000000..59da18ea3e --- /dev/null +++ b/test/generators/scala/Constants.spec.ts @@ -0,0 +1,12 @@ +import { isReservedScalaKeyword } from '../../../src/generators/scala/Constants'; + +describe('Reserved keywords', () => { + it('should return true if the word is a reserved keyword', () => { + expect(isReservedScalaKeyword('abstract')).toBe(true); + expect(isReservedScalaKeyword('type')).toBe(true); + }); + + it('should return false if the word is not a reserved keyword', () => { + expect(isReservedScalaKeyword('dinosaur')).toBe(false); + }); +}); diff --git a/test/generators/scala/ScalaConstrainer.spec.ts b/test/generators/scala/ScalaConstrainer.spec.ts new file mode 100644 index 0000000000..f1fd616ccf --- /dev/null +++ b/test/generators/scala/ScalaConstrainer.spec.ts @@ -0,0 +1,512 @@ +import { ScalaDefaultTypeMapping } from '../../../src/generators/scala/ScalaConstrainer'; +import { ScalaGenerator, ScalaOptions } from '../../../src/generators/scala'; +import { + ConstrainedAnyModel, + ConstrainedArrayModel, + ConstrainedBooleanModel, + ConstrainedDictionaryModel, + ConstrainedEnumModel, + ConstrainedEnumValueModel, + ConstrainedFloatModel, + ConstrainedIntegerModel, + ConstrainedObjectModel, + ConstrainedReferenceModel, + ConstrainedStringModel, + ConstrainedTupleModel, + ConstrainedTupleValueModel, + ConstrainedUnionModel +} from '../../../src'; +describe('ScalaConstrainer', () => { + describe('ObjectModel', () => { + test('should render the constrained name as type', () => { + const model = new ConstrainedObjectModel('test', undefined, {}, '', {}); + const type = ScalaDefaultTypeMapping.Object({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual(model.name); + }); + }); + describe('Reference', () => { + test('should render the constrained name as type', () => { + const refModel = new ConstrainedAnyModel('test', undefined, {}, ''); + const model = new ConstrainedReferenceModel( + 'test', + undefined, + {}, + '', + refModel + ); + const type = ScalaDefaultTypeMapping.Reference({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual(model.name); + }); + }); + describe('Any', () => { + test('should render type', () => { + const model = new ConstrainedAnyModel('test', undefined, {}, ''); + const type = ScalaDefaultTypeMapping.Any({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Any'); + }); + }); + describe('Float', () => { + test('should render type', () => { + const model = new ConstrainedFloatModel('test', undefined, {}, ''); + const type = ScalaDefaultTypeMapping.Float({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Double'); + }); + test('should render Float when format has number format', () => { + const model = new ConstrainedFloatModel( + 'test', + {}, + { format: 'float' }, + '' + ); + const type = ScalaDefaultTypeMapping.Float({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Float'); + }); + }); + describe('Integer', () => { + test('should render type', () => { + const model = new ConstrainedIntegerModel('test', undefined, {}, ''); + const type = ScalaDefaultTypeMapping.Integer({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Int'); + }); + test('should render Int when format has integer format', () => { + const model = new ConstrainedIntegerModel( + 'test', + {}, + { format: 'int32' }, + '' + ); + const type = ScalaDefaultTypeMapping.Integer({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Int'); + }); + test('should render Long when format has long format', () => { + const model = new ConstrainedIntegerModel( + 'test', + {}, + { format: 'long' }, + '' + ); + const type = ScalaDefaultTypeMapping.Integer({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Long'); + }); + test('should render Long when format has int64 format', () => { + const model = new ConstrainedIntegerModel( + 'test', + {}, + { format: 'int64' }, + '' + ); + const type = ScalaDefaultTypeMapping.Integer({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Long'); + }); + }); + describe('String', () => { + test('should render type', () => { + const model = new ConstrainedStringModel('test', undefined, {}, ''); + const type = ScalaDefaultTypeMapping.String({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('String'); + }); + test('should render LocalDate when format has date format', () => { + const model = new ConstrainedStringModel( + 'test', + {}, + { format: 'date' }, + '' + ); + const type = ScalaDefaultTypeMapping.String({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('java.time.LocalDate'); + }); + test('should render OffsetTime when format has time format', () => { + const model = new ConstrainedStringModel( + 'test', + {}, + { format: 'time' }, + '' + ); + const type = ScalaDefaultTypeMapping.String({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('java.time.OffsetTime'); + }); + test('should render OffsetDateTime when format has dateTime format', () => { + const model = new ConstrainedStringModel( + 'test', + {}, + { format: 'dateTime' }, + '' + ); + const type = ScalaDefaultTypeMapping.String({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('java.time.OffsetDateTime'); + }); + test('should render OffsetDateTime when format has date-time format', () => { + const model = new ConstrainedStringModel( + 'test', + {}, + { format: 'date-time' }, + '' + ); + const type = ScalaDefaultTypeMapping.String({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('java.time.OffsetDateTime'); + }); + test('should render byte when format has binary format', () => { + const model = new ConstrainedStringModel( + 'test', + {}, + { format: 'binary' }, + '' + ); + const type = ScalaDefaultTypeMapping.String({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Array[Byte]'); + }); + }); + + describe('Boolean', () => { + test('should render type', () => { + const model = new ConstrainedBooleanModel('test', undefined, {}, ''); + const type = ScalaDefaultTypeMapping.Boolean({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Boolean'); + }); + }); + + describe('Tuple', () => { + test('should render type', () => { + const stringModel = new ConstrainedStringModel( + 'test', + undefined, + {}, + 'String' + ); + const tupleValueModel = new ConstrainedTupleValueModel(0, stringModel); + const model = new ConstrainedTupleModel('test', undefined, {}, '', [ + tupleValueModel + ]); + const type = ScalaDefaultTypeMapping.Tuple({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('List[Any]'); + }); + test('should render multiple tuple types', () => { + const stringModel = new ConstrainedStringModel( + 'test', + undefined, + {}, + 'String' + ); + const tupleValueModel0 = new ConstrainedTupleValueModel(0, stringModel); + const tupleValueModel1 = new ConstrainedTupleValueModel(1, stringModel); + const model = new ConstrainedTupleModel('test', undefined, {}, '', [ + tupleValueModel0, + tupleValueModel1 + ]); + const type = ScalaDefaultTypeMapping.Tuple({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('List[Any]'); + }); + }); + + describe('Array', () => { + test('should render type', () => { + const arrayModel = new ConstrainedStringModel( + 'test', + undefined, + {}, + 'String' + ); + const model = new ConstrainedArrayModel( + 'test', + undefined, + {}, + '', + arrayModel + ); + const options: ScalaOptions = { + ...ScalaGenerator.defaultOptions, + collectionType: 'Array' + }; + const type = ScalaDefaultTypeMapping.Array({ + constrainedModel: model, + options, + dependencyManager: undefined as never + }); + expect(type).toEqual('Array[String]'); + }); + test('should render array as a list', () => { + const arrayModel = new ConstrainedStringModel( + 'test', + undefined, + {}, + 'String' + ); + const model = new ConstrainedArrayModel( + 'test', + undefined, + {}, + '', + arrayModel + ); + const options: ScalaOptions = { + ...ScalaGenerator.defaultOptions, + collectionType: 'List' + }; + const type = ScalaDefaultTypeMapping.Array({ + constrainedModel: model, + options, + dependencyManager: undefined as never + }); + expect(type).toEqual('List[String]'); + }); + }); + + describe('Enum', () => { + test('should render string enum values as String type', () => { + const enumValue = new ConstrainedEnumValueModel( + 'test', + 'string type', + {} + ); + const model = new ConstrainedEnumModel('test', undefined, {}, '', [ + enumValue + ]); + const type = ScalaDefaultTypeMapping.Enum({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('String'); + }); + test('should render boolean enum values as boolean type', () => { + const enumValue = new ConstrainedEnumValueModel('test', true, {}); + const model = new ConstrainedEnumModel('test', undefined, {}, '', [ + enumValue + ]); + const type = ScalaDefaultTypeMapping.Enum({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Boolean'); + }); + test('should render generic number enum value with format', () => { + const enumValue = new ConstrainedEnumValueModel('test', 123, {}); + const model = new ConstrainedEnumModel('test', undefined, {}, '', [ + enumValue + ]); + const type = ScalaDefaultTypeMapping.Enum({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Int'); + }); + test('should render generic number enum value with float format as float type', () => { + const enumValue = new ConstrainedEnumValueModel('test', 12.0, {}); + const model = new ConstrainedEnumModel( + 'test', + {}, + { format: 'float' }, + '', + [enumValue] + ); + const type = ScalaDefaultTypeMapping.Enum({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Float'); + }); + test('should render generic number enum value with double format as double type', () => { + const enumValue = new ConstrainedEnumValueModel('test', 12.0, {}); + const model = new ConstrainedEnumModel( + 'test', + {}, + { format: 'double' }, + '', + [enumValue] + ); + const type = ScalaDefaultTypeMapping.Enum({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Double'); + }); + test('should render object enum value as generic Object', () => { + const enumValue = new ConstrainedEnumValueModel('test', {}, {}); + const model = new ConstrainedEnumModel('test', undefined, {}, '', [ + enumValue + ]); + const type = ScalaDefaultTypeMapping.Enum({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Any'); + }); + test('should render multiple value types as generic Object', () => { + const enumValue2 = new ConstrainedEnumValueModel('test', true, {}); + const enumValue1 = new ConstrainedEnumValueModel( + 'test', + 'string type', + {} + ); + const model = new ConstrainedEnumModel('test', undefined, {}, '', [ + enumValue1, + enumValue2 + ]); + const type = ScalaDefaultTypeMapping.Enum({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Any'); + }); + test('should render double and integer as double type', () => { + const enumValue2 = new ConstrainedEnumValueModel('test', 123, {}); + const enumValue1 = new ConstrainedEnumValueModel('test', 123.12, {}); + const model = new ConstrainedEnumModel('test', undefined, {}, '', [ + enumValue1, + enumValue2 + ]); + const type = ScalaDefaultTypeMapping.Enum({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Double'); + }); + test('should render int and long as long type', () => { + const enumValue2 = new ConstrainedEnumValueModel('test', 123, {}); + const enumValue1 = new ConstrainedEnumValueModel('test', 123, {}); + const model = new ConstrainedEnumModel( + 'test', + {}, + { format: 'long' }, + '', + [enumValue1, enumValue2] + ); + const type = ScalaDefaultTypeMapping.Enum({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Long'); + }); + }); + + describe('Union', () => { + test('should render type', () => { + const unionModel = new ConstrainedStringModel( + 'test', + undefined, + {}, + 'str' + ); + const model = new ConstrainedUnionModel('test', undefined, {}, '', [ + unionModel + ]); + const type = ScalaDefaultTypeMapping.Union({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Any'); + }); + }); + + describe('Dictionary', () => { + test('should render type', () => { + const keyModel = new ConstrainedStringModel( + 'test', + undefined, + {}, + 'String' + ); + const valueModel = new ConstrainedStringModel( + 'test', + undefined, + {}, + 'String' + ); + const model = new ConstrainedDictionaryModel( + 'test', + undefined, + {}, + '', + keyModel, + valueModel + ); + const type = ScalaDefaultTypeMapping.Dictionary({ + constrainedModel: model, + options: ScalaGenerator.defaultOptions, + dependencyManager: undefined as never + }); + expect(type).toEqual('Map[String, String]'); + }); + }); +}); diff --git a/test/generators/scala/ScalaGenerator.spec.ts b/test/generators/scala/ScalaGenerator.spec.ts new file mode 100644 index 0000000000..7fab2302cd --- /dev/null +++ b/test/generators/scala/ScalaGenerator.spec.ts @@ -0,0 +1,211 @@ +import { ScalaGenerator } from '../../../src/generators/scala'; + +describe('ScalaGenerator', () => { + let generator: ScalaGenerator; + beforeEach(() => { + generator = new ScalaGenerator(); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should not render reserved keyword', async () => { + const doc = { + $id: 'Address', + type: 'object', + properties: { + class: { type: 'string' } + }, + additionalProperties: false + }; + + const models = await generator.generate(doc); + expect(models).toHaveLength(1); + expect(models[0].result).toMatchSnapshot(); + }); + + test('should render `case class` type', async () => { + const doc = { + $id: 'Address', + type: 'object', + properties: { + street_name: { type: 'string' }, + city: { type: 'string', description: 'City description' }, + state: { type: 'string' }, + house_number: { type: 'number' }, + marriage: { + type: 'boolean', + description: 'Status if marriage live in given house' + }, + members: { + oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }] + }, + array_type: { + type: 'array', + items: [{ type: 'string' }, { type: 'number' }] + }, + date: { type: 'string', format: 'date' }, + time: { type: 'string', format: 'time' }, + dateTime: { type: 'string', format: 'date-time' }, + binary: { type: 'string', format: 'binary' } + }, + patternProperties: { + '^S(.?*)test&': { + type: 'string' + } + }, + required: ['street_name', 'city', 'state', 'house_number', 'array_type'] + }; + + const expectedDependencies = []; + + const models = await generator.generate(doc); + expect(models).toHaveLength(1); + expect(models[0].result).toMatchSnapshot(); + expect(models[0].dependencies).toEqual(expectedDependencies); + }); + + test('should render `enum class` type (string type)', async () => { + const doc = { + $id: 'States', + type: 'string', + enum: ['Texas', 'Alabama', 'California', 'New York'] + }; + const expectedDependencies = []; + + const models = await generator.generate(doc); + expect(models).toHaveLength(1); + expect(models[0].result).toMatchSnapshot(); + expect(models[0].dependencies).toEqual(expectedDependencies); + }); + + test('should render `enum` type (integer type)', async () => { + const doc = { + $id: 'Numbers', + type: 'integer', + enum: [0, 1, 2, 3] + }; + + const models = await generator.generate(doc); + expect(models).toHaveLength(1); + expect(models[0].result).toMatchSnapshot(); + }); + + test('should render `enum` type (union type)', async () => { + const doc = { + $id: 'Union', + type: ['string', 'integer', 'boolean'], + enum: ['Texas', 'Alabama', 0, 1, '1', true, { test: 'test' }] + }; + + const models = await generator.generate(doc); + expect(models).toHaveLength(1); + expect(models[0].result).toMatchSnapshot(); + }); + + test('should render enums with translated special characters', async () => { + const doc = { + $id: 'States', + enum: ['test+', 'test', 'test-', 'test?!', '*test'] + }; + + const models = await generator.generate(doc); + expect(models).toHaveLength(1); + expect(models[0].result).toMatchSnapshot(); + }); + + test('should render List type for collections', async () => { + const doc = { + $id: 'CustomClass', + type: 'object', + additionalProperties: false, + properties: { + arrayType: { + type: 'array', + items: { type: 'integer' }, + additionalItems: false + } + } + }; + + generator = new ScalaGenerator({ collectionType: 'List' }); + + const models = await generator.generate(doc); + expect(models).toHaveLength(1); + expect(models[0].result).toMatchSnapshot(); + }); + + test('should render models and their dependencies', async () => { + const doc = { + $id: 'Address', + type: 'object', + properties: { + street_name: { type: 'string' }, + city: { type: 'string', description: 'City description' }, + state: { type: 'string' }, + house_number: { type: 'number' }, + marriage: { + type: 'boolean', + description: 'Status if marriage live in given house' + }, + members: { + oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }] + }, + array_type: { + type: 'array', + items: [{ type: 'string' }, { type: 'number' }] + }, + other_model: { + type: 'object', + $id: 'OtherModel', + properties: { street_name: { type: 'string' } } + } + }, + patternProperties: { + '^S(.?*)test&': { + type: 'string' + } + }, + required: ['street_name', 'city', 'state', 'house_number', 'array_type'] + }; + const config = { packageName: 'test.package' }; + const models = await generator.generateCompleteModels(doc, config); + expect(models).toHaveLength(2); + expect(models[0].result).toMatchSnapshot(); + expect(models[1].result).toMatchSnapshot(); + }); + test('should escape reserved keywords in package name', async () => { + const doc = { + $id: 'Address', + type: 'object', + properties: { + street_name: { type: 'string' }, + city: { type: 'string', description: 'City description' }, + state: { type: 'string' }, + house_number: { type: 'number' }, + marriage: { + type: 'boolean', + description: 'Status if marriage live in given house' + }, + members: { + oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }] + }, + array_type: { + type: 'array', + items: [{ type: 'string' }, { type: 'number' }] + } + }, + patternProperties: { + '^S(.?*)test&': { + type: 'string' + } + }, + required: ['street_name', 'city', 'state', 'house_number', 'array_type'] + }; + const config = { packageName: 'test.class.package' }; + const models = await generator.generateCompleteModels(doc, config); + + const expectedPackageDeclaration = 'package test.`class`.`package'; + expect(models[0].result).toContain(expectedPackageDeclaration); + }); +}); diff --git a/test/generators/scala/ScalaRenderer.spec.ts b/test/generators/scala/ScalaRenderer.spec.ts new file mode 100644 index 0000000000..1b19e1b7e6 --- /dev/null +++ b/test/generators/scala/ScalaRenderer.spec.ts @@ -0,0 +1,41 @@ +import { ScalaGenerator } from '../../../src/generators/scala'; +import { ScalaRenderer } from '../../../src/generators/scala/ScalaRenderer'; +import { ConstrainedObjectModel, InputMetaModel } from '../../../src/models'; +import { MockScalaRenderer } from '../../TestUtils/TestRenderers'; + +describe('ScalaRenderer', () => { + let renderer: ScalaRenderer; + beforeEach(() => { + renderer = new MockScalaRenderer( + ScalaGenerator.defaultOptions, + new ScalaGenerator(), + [], + new ConstrainedObjectModel('', undefined, '', {}), + new InputMetaModel() + ); + }); + + describe('renderComments()', () => { + test('Should be able to render comments', () => { + expect(renderer.renderComments('someComment')).toEqual(`/** + * someComment + */`); + }); + }); + + describe('renderAnnotation()', () => { + test('Should render', () => { + expect(renderer.renderAnnotation('someComment')).toEqual('@someComment'); + }); + test('Should be able to render multiple values', () => { + expect( + renderer.renderAnnotation('someComment', { test: 1, cool: '"story"' }) + ).toEqual('@someComment(test=1, cool="story")'); + }); + test('Should be able to render one value', () => { + expect( + renderer.renderAnnotation('someComment', { test: '"test2"' }) + ).toEqual('@someComment(test="test2")'); + }); + }); +}); diff --git a/test/generators/scala/__snapshots__/ScalaGenerator.spec.ts.snap b/test/generators/scala/__snapshots__/ScalaGenerator.spec.ts.snap new file mode 100644 index 0000000000..717eef2ed4 --- /dev/null +++ b/test/generators/scala/__snapshots__/ScalaGenerator.spec.ts.snap @@ -0,0 +1,105 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ScalaGenerator should not render reserved keyword 1`] = ` +"case class Address( + reservedClass: Option[String], +)" +`; + +exports[`ScalaGenerator should render \`case class\` type 1`] = ` +"case class Address( + streetName: String, + city: String, + state: String, + houseNumber: Double, + marriage: Option[Boolean], + members: Option[Any], + arrayType: List[Any], + date: Option[java.time.LocalDate], + time: Option[java.time.OffsetTime], + dateTime: Option[java.time.OffsetDateTime], + binary: Option[Array[Byte]], + additionalProperties: Option[Map[String, Any]], +)" +`; + +exports[`ScalaGenerator should render \`enum class\` type (string type) 1`] = ` +"object States extends Enumeration { + type States = Value + + val Texas: States.Value = Value(\\"Texas\\") + val Alabama: States.Value = Value(\\"Alabama\\") + val California: States.Value = Value(\\"California\\") + val NewYork: States.Value = Value(\\"New York\\") +}" +`; + +exports[`ScalaGenerator should render \`enum\` type (integer type) 1`] = ` +"object Numbers extends Enumeration { + type Numbers = Value + + val Number_0: Numbers.Value = Value(0) + val Number_1: Numbers.Value = Value(1) + val Number_2: Numbers.Value = Value(2) + val Number_3: Numbers.Value = Value(3) +}" +`; + +exports[`ScalaGenerator should render \`enum\` type (union type) 1`] = ` +"object Union extends Enumeration { + type Union = Value + + val Texas: Union.Value = Value(\\"Texas\\") + val Alabama: Union.Value = Value(\\"Alabama\\") + val Number_0: Union.Value = Value(0) + val Number_1: Union.Value = Value(1) + val ReservedNumber_1: Union.Value = Value(\\"1\\") + val ReservedTrue: Union.Value = Value(\\"true\\") + val CurlyleftQuotationTestQuotationColonQuotationTestQuotationCurlyright: Union.Value = Value(\\"{\\\\\\"test\\\\\\":\\\\\\"test\\\\\\"}\\") +}" +`; + +exports[`ScalaGenerator should render List type for collections 1`] = ` +"case class CustomClass( + arrayType: Option[List[Int]], +)" +`; + +exports[`ScalaGenerator should render enums with translated special characters 1`] = ` +"object States extends Enumeration { + type States = Value + + val TestPlus: States.Value = Value(\\"test+\\") + val Test: States.Value = Value(\\"test\\") + val TestMinus: States.Value = Value(\\"test-\\") + val TestQuestionExclamation: States.Value = Value(\\"test?!\\") + val AsteriskTest: States.Value = Value(\\"*test\\") +}" +`; + +exports[`ScalaGenerator should render models and their dependencies 1`] = ` +"package test.\`package\` + + +case class Address( + streetName: String, + city: String, + state: String, + houseNumber: Double, + marriage: Option[Boolean], + members: Option[Any], + arrayType: List[Any], + otherModel: Option[OtherModel], + additionalProperties: Option[Map[String, Any]], +)" +`; + +exports[`ScalaGenerator should render models and their dependencies 2`] = ` +"package test.\`package\` + + +case class OtherModel( + streetName: Option[String], + additionalProperties: Option[Map[String, Any]], +)" +`; diff --git a/test/generators/scala/presets/DescriptionPreset.spec.ts b/test/generators/scala/presets/DescriptionPreset.spec.ts new file mode 100644 index 0000000000..6d78deabf0 --- /dev/null +++ b/test/generators/scala/presets/DescriptionPreset.spec.ts @@ -0,0 +1,44 @@ +import { + ScalaGenerator, + SCALA_DESCRIPTION_PRESET +} from '../../../../src/generators/scala'; + +describe('SCALA_DESCRIPTION_PRESET', () => { + let generator: ScalaGenerator; + beforeEach(() => { + generator = new ScalaGenerator({ presets: [SCALA_DESCRIPTION_PRESET] }); + }); + + test('should render description and examples for class', async () => { + const doc = { + $id: 'Clazz', + type: 'object', + description: 'Description for class', + examples: [{ prop: 'value' }], + properties: { + prop: { + type: 'string', + description: 'Description for prop', + examples: ['exampleValue'] + } + } + }; + const models = await generator.generate(doc); + expect(models).toHaveLength(1); + expect(models[0].result).toMatchSnapshot(); + }); + + test('should render description and examples for enum', async () => { + const doc = { + $id: 'Enum', + type: 'string', + description: 'Description for enum', + examples: ['value'], + enum: ['on', 'off'] + }; + + const models = await generator.generate(doc); + expect(models).toHaveLength(1); + expect(models[0].result).toMatchSnapshot(); + }); +}); diff --git a/test/generators/scala/presets/__snapshots__/DescriptionPreset.spec.ts.snap b/test/generators/scala/presets/__snapshots__/DescriptionPreset.spec.ts.snap new file mode 100644 index 0000000000..9ed63ab635 --- /dev/null +++ b/test/generators/scala/presets/__snapshots__/DescriptionPreset.spec.ts.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SCALA_DESCRIPTION_PRESET should render description and examples for class 1`] = ` +"/** + * Description for class + * + * @property prop Description for prop + * @property additionalProperties + * + * Examples: + * {\\"prop\\":\\"value\\"} + */ +case class Clazz( + prop: Option[String], + additionalProperties: Option[Map[String, Any]], +)" +`; + +exports[`SCALA_DESCRIPTION_PRESET should render description and examples for enum 1`] = ` +"/** + * Description for enum + * + * Examples: + * value + */ +object Enum extends Enumeration { + type Enum = Value + + val On: Enum.Value = Value(\\"on\\") + val Off: Enum.Value = Value(\\"off\\") +}" +`;