From 0c23b01246df216e80d8aede4b927b938c1023b1 Mon Sep 17 00:00:00 2001 From: David Tymon Date: Wed, 7 Jul 2021 12:21:08 +1000 Subject: [PATCH] Attempt to handle unsupported Joi types rather than omitting them - currently unsupported Joi types are dropped from the interface generation which also includes an Joi extensions added - rather than doing that, attempt to workout a suitable TypeScript type to use for them in the interface generation - for Joi extensions, it is possible to add the base type in the metadata in the same way that joi-to-swagger recommends: const ExtendedJoi = Joi.extend(joi => { const ext: Joi.Extension = { type: 'objectId', base: joi.string().meta({ baseType: 'string' }) }; return ext; }); - use 'unknown' if no suitable type can be deduced --- src/__tests__/joiExtensions/joiExtensions.ts | 53 ++++++++++++++++++++ src/__tests__/joiTypes.ts | 44 +++++++++++----- src/joiUtils.ts | 20 ++++++-- src/parse.ts | 41 +++++++++++++-- 4 files changed, 139 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/joiExtensions/joiExtensions.ts diff --git a/src/__tests__/joiExtensions/joiExtensions.ts b/src/__tests__/joiExtensions/joiExtensions.ts new file mode 100644 index 00000000..10cec2cb --- /dev/null +++ b/src/__tests__/joiExtensions/joiExtensions.ts @@ -0,0 +1,53 @@ +import Joi from 'joi'; +import { convertSchema } from '../..'; + +// Add a couple of extensions to Joi, one without specifying the base type +const ExtendedJoi = Joi.extend(joi => { + const ext: Joi.Extension = { + type: 'objectId', + base: joi.string().meta({ baseType: 'string' }) + }; + return ext; +}).extend((joi: any) => { + const ext: Joi.Extension = { + type: 'dollars', + base: joi.number() + }; + return ext; +}); + +describe('Joi Extensions', () => { + test('An extended type with baseType set in metadata', () => { + const schema = Joi.object({ + doStuff: ExtendedJoi.objectId() + }).meta({ className: 'Test' }); + + const result = convertSchema({ debug: true }, schema); + expect(result).not.toBeUndefined; + // prettier-ignore + expect(result?.content).toBe( + [ + 'export interface Test {', + ' doStuff?: string;', + '}' + ].join('\n') + ); + }); + + test('An extended type with baseType not set in metadata', () => { + const schema = Joi.object({ + doStuff: ExtendedJoi.dollars() + }).meta({ className: 'Test' }); + + const result = convertSchema({ debug: true }, schema); + expect(result).not.toBeUndefined; + // prettier-ignore + expect(result?.content).toBe( + [ + 'export interface Test {', + ' doStuff?: unknown;', + '}' + ].join('\n') + ); + }); +}); diff --git a/src/__tests__/joiTypes.ts b/src/__tests__/joiTypes.ts index 9c7544a3..8c41a9c1 100644 --- a/src/__tests__/joiTypes.ts +++ b/src/__tests__/joiTypes.ts @@ -32,7 +32,6 @@ describe('`Joi.types()`', () => { }); test('Joi.function()', () => { - const consoleSpy = jest.spyOn(console, 'debug'); const schema = Joi.object({ doStuff: Joi.function(), moreThings: Joi.func() @@ -40,46 +39,67 @@ describe('`Joi.types()`', () => { const result = convertSchema({ debug: true }, schema); expect(result).not.toBeUndefined; - expect(result?.content).toBe(`export interface Test {}`); - expect(consoleSpy).toHaveBeenCalledWith('unsupported type: function'); + expect(result?.content).toBe( + [ + 'export interface Test {', + ' doStuff?: (...args: any[]) => any;', + ' moreThings?: (...args: any[]) => any;', + '}' + ].join('\n') + ); }); // TODO: It might be possible to support link // I guess this would find the referenced schema and get its type test('Joi.link()', () => { - const consoleSpy = jest.spyOn(console, 'debug'); const schema = Joi.object({ doStuff: Joi.link() }).meta({ className: 'Test' }); const result = convertSchema({ debug: true }, schema); expect(result).not.toBeUndefined; - expect(result?.content).toBe(`export interface Test {}`); - expect(consoleSpy).toHaveBeenCalledWith('unsupported type: link'); + // prettier-ignore + expect(result?.content).toBe( + [ + 'export interface Test {', + ' doStuff?: unknown;', + '}' + ].join('\n') + ); }); test('Joi.symbol()', () => { - const consoleSpy = jest.spyOn(console, 'debug'); const schema = Joi.object({ doStuff: Joi.symbol() }).meta({ className: 'Test' }); const result = convertSchema({ debug: true }, schema); expect(result).not.toBeUndefined; - expect(result?.content).toBe(`export interface Test {}`); - expect(consoleSpy).toHaveBeenCalledWith('unsupported type: symbol'); + // prettier-ignore + expect(result?.content).toBe( + [ + 'export interface Test {', + ' doStuff?: symbol;', + '}' + ].join('\n') + ); }); // TODO: Support Binary test('Joi.binary()', () => { - const consoleSpy = jest.spyOn(console, 'debug'); const schema = Joi.object({ doStuff: Joi.binary() }).meta({ className: 'Test' }); const result = convertSchema({ debug: true }, schema); expect(result).not.toBeUndefined; - expect(result?.content).toBe(`export interface Test {}`); - expect(consoleSpy).toHaveBeenCalledWith('unsupported type: binary'); + // prettier-ignore + expect(result?.content).toBe( + [ + 'export interface Test {', + ' doStuff?: Buffer;', + '}' + ].join('\n') + ); }); }); diff --git a/src/joiUtils.ts b/src/joiUtils.ts index e6adb50a..618ff560 100644 --- a/src/joiUtils.ts +++ b/src/joiUtils.ts @@ -1,6 +1,20 @@ import { Settings } from '.'; import { Describe } from './joiDescribeTypes'; +/** + * Fetch the metadata values for a given field. Note that it is possible to have + * more than one metadata record for a given field hence it is possible to get + * back a list of values. + * + * @param field - the name of the metadata field to fetch + * @param details - the schema details + * @returns the values for the given field + */ +export function getMetadataFromDetails(field: string, details: Describe): any[] { + const metas: any[] = details?.metas ?? []; + return metas.filter(entry => entry[field]).map(entry => entry[field]); +} + /** * Get the interface name from the Joi * @returns a string if it can find one @@ -10,11 +24,11 @@ export function getInterfaceOrTypeName(settings: Settings, details: Describe): s return details?.flags?.label?.replace(/\s/g, ''); } else { if (details?.metas && details.metas.length > 0) { - const classNames = details.metas.filter(meta => meta.className); - if (classNames.length !== 0){ + const classNames: string[] = getMetadataFromDetails('className', details); + if (classNames.length !== 0) { // If Joi.concat() has been used then there may be multiple // get the last one as that should be the correct one - const className = classNames[classNames.length - 1].className; + const className = classNames.pop(); return className?.replace(/\s/g, ''); } } diff --git a/src/parse.ts b/src/parse.ts index f266c3e5..817a87eb 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -9,7 +9,7 @@ import { ObjectDescribe, StringDescribe } from './joiDescribeTypes'; -import { getInterfaceOrTypeName } from './joiUtils'; +import { getInterfaceOrTypeName, getMetadataFromDetails } from './joiUtils'; // see __tests__/joiTypes.ts for more information export const supportedJoiTypes = ['array', 'object', 'alternatives', 'any', 'boolean', 'date', 'number', 'string']; @@ -223,9 +223,42 @@ export function parseSchema( } return child; } - if (settings.debug && !supportedJoiTypes.includes(details.type)) { - console.debug(`unsupported type: ${details.type}`); - return undefined; + if (!supportedJoiTypes.includes(details.type)) { + // See if we can find a base type for this type in the details. + let typeToUse; + const baseTypes: string[] = getMetadataFromDetails('baseType', details); + if (baseTypes.length > 0) { + // If there are multiple base types then the deepest one will be at the + // end of the list which is most likely the one to use. + typeToUse = baseTypes.pop() as string; + } + + // If we could not get the base type from the metadata then see if we can + // map it to something sensible. If not, then set it to 'unknown'. + if (typeToUse === undefined) { + switch (details.type as string) { + case 'function': + typeToUse = '(...args: any[]) => any'; + break; + + case 'symbol': + typeToUse = 'symbol'; + break; + + case 'binary': + typeToUse = 'Buffer'; + break; + + default: + typeToUse = 'unknown'; + break; + } + } + + if (settings.debug) { + console.debug(`Using '${typeToUse}' for unsupported type '${details.type}'`); + } + return makeTypeContentChild({ content: typeToUse, interfaceOrTypeName, jsDoc }); } const parsedSchema = parseHelper(); if (!parsedSchema) {