- Working types + schema validator

This commit is contained in:
Ferdi Koomen 2019-11-17 13:38:13 +01:00
parent 2a73a14a48
commit d6131dc780
27 changed files with 220 additions and 176 deletions

View File

@ -1,4 +1,5 @@
export interface Enum {
name: string;
value: string;
type: string;
}

View File

@ -7,8 +7,9 @@ export interface Model {
base: string;
template: string | null;
description: string | null;
validation: string | null;
extends: string[];
imports: string[];
enum: Enum[];
properties: Map<string, ModelProperty>;
properties: ModelProperty[];
}

View File

@ -7,4 +7,5 @@ export interface ModelProperty {
required: boolean;
nullable: boolean;
description: string | null;
validation: string | null;
}

View File

@ -1,4 +1,5 @@
import { Enum } from '../../../client/interfaces/Enum';
import { PrimaryType } from './constants';
export function getEnum(values?: (string | number)[]): Enum[] {
if (Array.isArray(values)) {
@ -11,11 +12,13 @@ export function getEnum(values?: (string | number)[]): Enum[] {
return {
name: `NUM_${value}`,
value: String(value),
type: PrimaryType.NUMBER,
};
}
return {
name: value.replace(/([a-z])([A-Z]+)/g, '$1_$2').toUpperCase(),
value: `'${value}'`,
type: PrimaryType.STRING,
};
});
}

View File

@ -1,4 +1,5 @@
import { Enum } from '../../../client/interfaces/Enum';
import { PrimaryType } from './constants';
export function getEnumFromDescription(description: string): Enum[] {
// Check if we can find this special format string:
@ -15,6 +16,7 @@ export function getEnumFromDescription(description: string): Enum[] {
symbols.push({
name: name.replace(/([a-z])([A-Z]+)/g, '$1_$2').toUpperCase(),
value: String(value),
type: PrimaryType.NUMBER,
});
}
});

View File

@ -5,12 +5,17 @@ import {getType} from './getType';
import {Model} from '../../../client/interfaces/Model';
import {PrimaryType} from './constants';
import {getEnumType} from './getEnumType';
import {ModelProperty} from '../../../client/interfaces/ModelProperty';
import {getEnum} from './getEnum';
import {getEnumFromDescription} from './getEnumFromDescription';
import {getTypeFromProperties} from './getTypeFromProperties';
import {getValidationForRef} from './getValidationForRef';
import {getValidationForEnum} from './getValidationForEnum';
import {getValidationForArrayRef} from './getValidationForArrayRef';
import {getValidationForType} from './getValidationForType';
import {getValidationForArray} from './getValidationForArray';
import {getValidationForProperties} from './getValidationForProperties';
export function getModel(openApi: OpenApi, definition: OpenApiSchema, name: string = 'unknown'): Model {
export function getModel(openApi: OpenApi, definition: OpenApiSchema, name: string = ''): Model {
// TODO: Properties now contain ALL properties, so we need to filter out enums
// before we render the file, plus we need to calculate the final TYPE of a model
// by checking all the properties!
@ -19,14 +24,15 @@ export function getModel(openApi: OpenApi, definition: OpenApiSchema, name: stri
const result: Model = {
name,
type: 'any',
base: 'any',
type: PrimaryType.OBJECT,
base: PrimaryType.OBJECT,
template: null,
description: getComment(definition.description),
validation: null,
extends: [],
imports: [],
enum: [],
properties: new Map<string, ModelProperty>(),
properties: [],
};
if (definition.$ref) {
@ -35,6 +41,7 @@ export function getModel(openApi: OpenApi, definition: OpenApiSchema, name: stri
result.base = definitionRef.base;
result.template = definitionRef.template;
result.imports.push(...definitionRef.imports);
result.validation = getValidationForRef(definitionRef);
return result;
}
@ -45,6 +52,7 @@ export function getModel(openApi: OpenApi, definition: OpenApiSchema, name: stri
result.type = getEnumType(enumerators);
result.base = PrimaryType.STRING;
result.enum.push(...enumerators);
result.validation = getValidationForEnum(name, enumerators);
return result;
}
return result;
@ -57,6 +65,7 @@ export function getModel(openApi: OpenApi, definition: OpenApiSchema, name: stri
result.type = getEnumType(enumerators);
result.base = PrimaryType.NUMBER;
result.enum.push(...enumerators);
result.validation = getValidationForEnum(name, enumerators);
return result;
}
return result;
@ -66,17 +75,19 @@ export function getModel(openApi: OpenApi, definition: OpenApiSchema, name: stri
// so we can create a typed array, otherwise this will be a "any[]".
if (definition.type === 'array' && definition.items) {
if (definition.items.$ref) {
const arrayItemsRef = getType(definition.items.$ref);
result.type = `${arrayItemsRef.type}[]`;
result.base = arrayItemsRef.base;
result.template = arrayItemsRef.template;
result.imports.push(...arrayItemsRef.imports);
const arrayItems = getType(definition.items.$ref);
result.type = `${arrayItems.type}[]`;
result.base = arrayItems.base;
result.template = arrayItems.template;
result.imports.push(...arrayItems.imports);
result.validation = getValidationForArrayRef(arrayItems);
} else {
const arrayItemsModel = getModel(openApi, definition.items);
result.type = `${arrayItemsModel.type}[]`;
result.base = arrayItemsModel.base;
result.template = arrayItemsModel.template;
result.imports.push(...arrayItemsModel.imports);
const arrayItems = getModel(openApi, definition.items);
result.type = `${arrayItems.type}[]`;
result.base = arrayItems.base;
result.template = arrayItems.template;
result.imports.push(...arrayItems.imports);
result.validation = getValidationForArray(name, arrayItems);
}
return result;
}
@ -96,38 +107,43 @@ export function getModel(openApi: OpenApi, definition: OpenApiSchema, name: stri
const propertyRequired = !!(parent.required && parent.required.includes(propertyName));
const propertyReadOnly = !!property.readOnly;
if (property.$ref) {
const propertyRef = getType(property.$ref);
const prop = getType(property.$ref);
result.base = PrimaryType.OBJECT;
result.imports.push(...propertyRef.imports);
result.properties.set(propertyName, {
result.imports.push(...prop.imports);
result.properties.push({
name: propertyName,
type: propertyRef.type,
base: propertyRef.base,
template: propertyRef.template,
type: prop.type,
base: prop.base,
template: prop.template,
readOnly: propertyReadOnly,
required: propertyRequired,
nullable: false,
description: property.description || null,
validation: getValidationForRef(prop),
});
} else {
const propertyModel = getModel(openApi, property);
const prop = getModel(openApi, property);
result.base = PrimaryType.OBJECT;
result.imports.push(...propertyModel.imports);
result.properties.set(propertyName, {
result.imports.push(...prop.imports);
result.properties.push({
name: propertyName,
type: propertyModel.type,
base: propertyModel.base,
template: propertyModel.template,
type: prop.type,
base: prop.base,
template: prop.template,
readOnly: propertyReadOnly,
required: propertyRequired,
nullable: false,
description: property.description || null,
validation: prop.validation,
});
}
}
}
}
});
result.type = getTypeFromProperties(result.properties);
result.base = PrimaryType.OBJECT;
result.validation = getValidationForProperties(name, result.properties, result.extends);
}
if (definition.type === 'object' && definition.properties) {
@ -137,36 +153,65 @@ export function getModel(openApi: OpenApi, definition: OpenApiSchema, name: stri
const propertyRequired = !!(definition.required && definition.required.includes(propertyName));
const propertyReadOnly = !!property.readOnly;
if (property.$ref) {
const propertyRef = getType(property.$ref);
const prop = getType(property.$ref);
result.base = PrimaryType.OBJECT;
result.imports.push(...propertyRef.imports);
result.properties.set(propertyName, {
result.imports.push(...prop.imports);
result.properties.push({
name: propertyName,
type: propertyRef.type,
base: propertyRef.base,
template: propertyRef.template,
type: prop.type,
base: prop.base,
template: prop.template,
readOnly: propertyReadOnly,
required: propertyRequired,
nullable: false,
description: property.description || null,
validation: getValidationForRef(prop),
});
} else {
const propertyModel = getModel(openApi, property);
const prop = getModel(openApi, property);
result.base = PrimaryType.OBJECT;
result.imports.push(...propertyModel.imports);
result.properties.set(propertyName, {
result.imports.push(...prop.imports);
result.properties.push({
name: propertyName,
type: propertyModel.type,
base: propertyModel.base,
template: propertyModel.template,
type: prop.type,
base: prop.base,
template: prop.template,
readOnly: propertyReadOnly,
required: propertyRequired,
nullable: false,
description: property.description || null,
validation: prop.validation,
});
}
}
}
result.type = getTypeFromProperties(result.properties);
result.base = PrimaryType.OBJECT;
result.validation = getValidationForProperties(name, result.properties, result.extends);
return result;
}
// If a property has additionalProperties, then it likely to be a dictionary type.
// In that case parse the related property and assume it lives inside a string
// based dictionary: { [key:string]: MyType }
if (definition.type === 'object' && definition.additionalProperties && typeof definition.additionalProperties === 'object') {
if (definition.additionalProperties.$ref) {
const additionalProperties = getType(definition.additionalProperties.$ref);
result.type = `Dictionary<${additionalProperties.type}>`;
result.base = 'Dictionary';
result.template = additionalProperties.type;
result.imports.push(...additionalProperties.imports);
result.imports.push('Dictionary');
console.log(name, 'Dictionary', result.type);
} else {
const additionalProperties = getModel(openApi, definition.additionalProperties);
result.type = `Dictionary<${additionalProperties.type}>`;
result.base = 'Dictionary';
result.template = additionalProperties.type;
result.imports.push(...additionalProperties.imports);
result.imports.push('Dictionary');
console.log(name, 'Dictionary', result.type);
}
return result;
}
@ -177,6 +222,7 @@ export function getModel(openApi: OpenApi, definition: OpenApiSchema, name: stri
result.base = definitionType.base;
result.template = definitionType.template;
result.imports.push(...definitionType.imports);
result.validation = getValidationForType(definitionType);
return result;
}

View File

@ -1,12 +0,0 @@
import { EOL } from 'os';
import { ModelProperty } from '../../../client/interfaces/ModelProperty';
export function getModelType(properties: ModelProperty[]): string {
return [
`{`,
...properties.map(property => {
return ` ${property.readOnly ? 'readonly ' : ''}${property.name}${property.required ? '' : '?'}: ${property.type},`;
}),
`}`,
].join(EOL);
}

View File

@ -4,9 +4,9 @@ import { ModelProperty } from '../../../client/interfaces/ModelProperty';
export function getModelValidation(name: string, properties: ModelProperty[]): string {
return [
`yup.object().shape({`,
...properties.map(property => {
return ` ${property.name}: ${property.validation},`;
}),
// ...properties.map(property => {
// return ` ${property.name}: ${property.validation},`;
// }),
`}).noUnknown()`,
].join(EOL);
}

View File

@ -9,10 +9,8 @@ export function getModels(openApi: OpenApi): Map<string, Model> {
if (openApi.definitions.hasOwnProperty(definitionName)) {
const definition = openApi.definitions[definitionName];
const definitionType = getType(definitionName);
if (!models.has(definitionType.base)) {
const model = getModel(openApi, definition, definitionType.base);
models.set(definitionType.base, model);
}
const model = getModel(openApi, definition, definitionType.base);
models.set(definitionType.base, model);
}
}
return models;

View File

@ -10,6 +10,7 @@ import { getOperationResponses } from './getOperationResponses';
import { getOperationResponse } from './getOperationResponse';
import { getOperationErrors } from './getOperationErrors';
import { Operation } from '../../../client/interfaces/Operation';
import { PrimaryType } from './constants';
export function getOperation(openApi: OpenApi, url: string, method: string, op: OpenApiOperation): Operation {
const serviceName = (op.tags && op.tags[0]) || 'Service';
@ -35,7 +36,7 @@ export function getOperation(openApi: OpenApi, url: string, method: string, op:
parametersBody: null,
imports: [],
errors: [],
result: 'void',
result: PrimaryType.VOID,
};
// Parse the operation parameters (path, query, body, etc).

View File

@ -6,7 +6,7 @@ describe('getType', () => {
expect(type.type).toEqual('number');
expect(type.base).toEqual('number');
expect(type.template).toEqual(null);
expect(Array.from(type.imports.values())).toEqual([]);
expect(type.imports).toEqual([]);
});
it('should convert string', () => {
@ -14,7 +14,7 @@ describe('getType', () => {
expect(type.type).toEqual('string');
expect(type.base).toEqual('string');
expect(type.template).toEqual(null);
expect(Array.from(type.imports.values())).toEqual([]);
expect(type.imports).toEqual([]);
});
it('should convert string array', () => {
@ -22,7 +22,7 @@ describe('getType', () => {
expect(type.type).toEqual('string[]');
expect(type.base).toEqual('string');
expect(type.template).toEqual(null);
expect(Array.from(type.imports.values())).toEqual([]);
expect(type.imports).toEqual([]);
});
it('should convert template with primary', () => {
@ -30,7 +30,7 @@ describe('getType', () => {
expect(type.type).toEqual('Link<string>');
expect(type.base).toEqual('Link');
expect(type.template).toEqual('string');
expect(Array.from(type.imports.values())).toEqual(['Link']);
expect(type.imports).toEqual(['Link']);
});
it('should convert template with model', () => {
@ -38,7 +38,7 @@ describe('getType', () => {
expect(type.type).toEqual('Link<Model>');
expect(type.base).toEqual('Link');
expect(type.template).toEqual('Model');
expect(Array.from(type.imports.values())).toEqual(['Link', 'Model']);
expect(type.imports).toEqual(['Link', 'Model']);
});
it('should have double imports', () => {
@ -46,7 +46,7 @@ describe('getType', () => {
expect(type.type).toEqual('Link<Link>');
expect(type.base).toEqual('Link');
expect(type.template).toEqual('Link');
expect(Array.from(type.imports.values())).toEqual(['Link', 'Link']);
expect(type.imports).toEqual(['Link', 'Link']);
});
it('should convert generic', () => {
@ -54,6 +54,6 @@ describe('getType', () => {
expect(type.type).toEqual('T');
expect(type.base).toEqual('T');
expect(type.template).toEqual(null);
expect(Array.from(type.imports.values())).toEqual([]);
expect(type.imports).toEqual([]);
});
});

View File

@ -0,0 +1,18 @@
import { EOL } from 'os';
import { ModelProperty } from '../../../client/interfaces/ModelProperty';
export function getTypeFromProperties(properties: ModelProperty[]): string {
return [
`{`,
...properties.map(property => {
let type = '';
type = `${type}${property.readOnly ? 'readonly ' : ''}`;
type = `${type}${property.name}`;
type = `${type}${property.required ? '' : '?'}`;
type = `${type}: ${property.type}`;
type = `${type}${property.nullable ? ' | null' : ''}`;
return `${type},`;
}),
`}`,
].join(EOL);
}

View File

@ -1,11 +0,0 @@
export function getValidation(validation: string, required: boolean = false, nullable: boolean = false): string {
if (required) {
validation = `${validation}.required()`;
}
if (nullable) {
validation = `${validation}.nullable()`;
}
return validation;
}

View File

@ -0,0 +1,5 @@
import { Model } from '../../../client/interfaces/Model';
export function getValidationForArray(name: string, model: Model): string {
return `yup.array<${name ? name : 'any'}>().of(${model.validation ? model.validation : 'yup.mixed()'})`;
}

View File

@ -1,15 +1,5 @@
import { Type } from '../../../client/interfaces/Type';
export function getValidationForArrayRef(ref: Type, required: boolean = false, nullable: boolean = false): string {
let validation = `yup.array<${ref.type}>().of(${ref.base}.schema)`;
if (required) {
validation = `${validation}.required()`;
}
if (nullable) {
validation = `${validation}.nullable()`;
}
return validation;
export function getValidationForArrayRef(ref: Type): string {
return `yup.array<${ref.type}>().of(${ref.base}.schema)`;
}

View File

@ -1,28 +0,0 @@
import { PrimaryType } from './constants';
import { Type } from '../../../client/interfaces/Type';
export function getValidationForArrayType(type: Type, required: boolean = false, nullable: boolean = false): string {
let validation = `yup.array<any>().of(yup.mixed())`;
switch (type.type) {
case PrimaryType.BOOLEAN:
validation = `yup.array<boolean>().of(yup.boolean())`;
break;
case PrimaryType.NUMBER:
validation = `yup.array<number>().of(yup.number())`;
break;
case PrimaryType.STRING:
validation = `yup.array<string>().of(yup.string())`;
break;
}
if (required) {
validation = `${validation}.required()`;
}
if (nullable) {
validation = `${validation}.nullable()`;
}
return validation;
}

View File

@ -0,0 +1,16 @@
import { Enum } from '../../../client/interfaces/Enum';
import { EOL } from 'os';
export function getValidationForEnum(name: string, enumerators: Enum[]): string {
return [
`yup.mixed${name ? `<${name}>` : ''}().oneOf([`,
...enumerators.map(enumerator => {
if (name) {
return `${name}.${enumerator.name},`;
} else {
return `${enumerator.value},`;
}
}),
`])`,
].join(EOL);
}

View File

@ -0,0 +1,18 @@
import { EOL } from 'os';
import { ModelProperty } from '../../../client/interfaces/ModelProperty';
export function getValidationForProperties(name: string, properties: ModelProperty[], extendClasses: string[]): string {
return [
...extendClasses.map(extendClass => `${extendClass}.schema.concat(`),
`yup.object${name ? `<${name}>` : ''}().shape({`,
...properties.map(property => {
let validation = '';
validation = `${validation}${property.name}: yup.lazy(() => ${property.validation}.default(undefined))`;
validation = `${validation}${property.required ? '.required()' : ''}`;
validation = `${validation}${property.nullable ? '.nullable()' : ''}`;
return `${validation},`;
}),
`}).noUnknown()`,
...extendClasses.map(() => `)`),
].join(EOL);
}

View File

@ -1,15 +1,5 @@
import { Type } from '../../../client/interfaces/Type';
export function getValidationForRef(ref: Type, required = false, nullable = false): string {
let validation = `${ref.base}.schema`;
if (required) {
validation = `${validation}.required()`;
}
if (nullable) {
validation = `${validation}.nullable()`;
}
return validation;
export function getValidationForRef(ref: Type): string {
return `${ref.base}.schema`;
}

View File

@ -1,9 +1,8 @@
import { PrimaryType } from './constants';
import { Type } from '../../../client/interfaces/Type';
export function getValidationForType(type: Type, required: boolean = false, nullable: boolean = false): string {
export function getValidationForType(type: Type): string {
let validation = `yup.mixed<${type.type}>()`;
switch (type.type) {
case PrimaryType.BOOLEAN:
validation = `yup.boolean()`;
@ -15,14 +14,5 @@ export function getValidationForType(type: Type, required: boolean = false, null
validation = `yup.string()`;
break;
}
if (required) {
validation = `${validation}.required()`;
}
if (nullable) {
validation = `${validation}.nullable()`;
}
return validation;
}

View File

@ -7,7 +7,7 @@ export function exportModel(model: Model): any {
imports: getSortedImports(model.imports).filter(name => {
return model.name !== name;
}),
properties: Array.from(model.properties.values())
properties: model.properties
.filter((property, index, arr) => {
return arr.findIndex(item => item.name === property.name) === index;
})

View File

@ -1,6 +1,5 @@
import { getSortedModels } from './getSortedModels';
import { Model } from '../client/interfaces/Model';
import { ModelProperty } from '../client/interfaces/ModelProperty';
describe('getSortedModels', () => {
it('should return sorted list', () => {
@ -11,10 +10,11 @@ describe('getSortedModels', () => {
base: 'John',
template: null,
description: null,
validation: null,
extends: [],
imports: [],
enum: [],
properties: new Map<string, ModelProperty>(),
properties: [],
});
models.set('Jane', {
name: 'Jane',
@ -22,10 +22,11 @@ describe('getSortedModels', () => {
base: 'Jane',
template: null,
description: null,
validation: null,
extends: [],
imports: [],
enum: [],
properties: new Map<string, ModelProperty>(),
properties: [],
});
models.set('Doe', {
name: 'Doe',
@ -33,10 +34,11 @@ describe('getSortedModels', () => {
base: 'Doe',
template: null,
description: null,
validation: null,
extends: [],
imports: [],
enum: [],
properties: new Map<string, ModelProperty>(),
properties: [],
});
expect(getSortedModels(new Map<string, Model>())).toEqual([]);

View File

@ -6,6 +6,8 @@ import { Client } from '../client/interfaces/Client';
import { Templates } from './readHandlebarsTemplates';
import { Language } from '../index';
import * as glob from 'glob';
import { Model } from '../client/interfaces/Model';
import { Service } from '../client/interfaces/Service';
jest.mock('rimraf');
jest.mock('mkdirp');
@ -22,8 +24,8 @@ describe('writeClient', () => {
const client: Client = {
server: 'http://localhost:8080',
version: 'v1',
models: [],
services: [],
models: new Map<string, Model>(),
services: new Map<string, Service>(),
};
const templates: Templates = {

View File

@ -2,6 +2,8 @@ import { writeClientIndex } from './writeClientIndex';
import * as fs from 'fs';
import { Client } from '../client/interfaces/Client';
import { Language } from '../index';
import { Model } from '../client/interfaces/Model';
import { Service } from '../client/interfaces/Service';
jest.mock('fs');
@ -12,8 +14,8 @@ describe('writeClientIndex', () => {
const client: Client = {
server: 'http://localhost:8080',
version: 'v1',
models: [],
services: [],
models: new Map<string, Model>(),
services: new Map<string, Service>(),
};
const template = () => 'dummy';
writeClientIndex(client, Language.TYPESCRIPT, template, '/');

View File

@ -9,24 +9,19 @@ const fsWriteFileSync = fs.writeFileSync as jest.MockedFunction<typeof fs.writeF
describe('writeClientModels', () => {
it('should write to filesystem', () => {
const models: Model[] = [
{
isInterface: false,
isType: false,
isEnum: false,
name: 'Item',
type: 'Item',
base: 'Item',
template: null,
validation: null,
description: null,
extends: [],
imports: [],
symbols: [],
properties: [],
enums: [],
},
];
const models = new Map<string, Model>();
models.set('Item', {
name: 'Item',
type: 'Item',
base: 'Item',
template: null,
description: null,
validation: null,
extends: [],
imports: [],
enum: [],
properties: [],
});
const template = () => 'dummy';
writeClientModels(models, Language.TYPESCRIPT, template, '/');
expect(fsWriteFileSync).toBeCalledWith('/Item.ts', 'dummy');

View File

@ -9,13 +9,12 @@ const fsWriteFileSync = fs.writeFileSync as jest.MockedFunction<typeof fs.writeF
describe('writeClientServices', () => {
it('should write to filesystem', () => {
const services: Service[] = [
{
name: 'Item',
operations: [],
imports: [],
},
];
const services = new Map<string, Service>();
services.set('Item', {
name: 'Item',
operations: [],
imports: [],
});
const template = () => 'dummy';
writeClientServices(services, Language.TYPESCRIPT, template, '/');
expect(fsWriteFileSync).toBeCalledWith('/Item.ts', 'dummy');

View File

@ -112,6 +112,21 @@
}
}
},
"DictionaryWithProperties": {
"description": "This is a complex dictionary",
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"foo": {
"type": "string"
},
"bar": {
"type": "string"
}
}
}
},
"ModelWithInteger": {
"description": "This is a model with one number property",
"type": "object",