Browse Source

feat(server): Validate custom field config to ensure no naming conflicts

Michael Bromley 7 years ago
parent
commit
c8618e847d

+ 2 - 0
server/src/app.module.ts

@@ -17,6 +17,7 @@ import { AuthService } from './auth/auth.service';
 import { JwtStrategy } from './auth/jwt.strategy';
 import { PasswordService } from './auth/password.service';
 import { getConfig } from './config/vendure-config';
+import { validateCustomFieldsConfig } from './entity/custom-entity-fields';
 import { addGraphQLCustomFields } from './entity/graphql-custom-fields';
 import { I18nService } from './i18n/i18n.service';
 import { TranslationUpdaterService } from './locale/translation-updater.service';
@@ -60,6 +61,7 @@ export class AppModule implements NestModule {
     ) {}
 
     configure(consumer: MiddlewareConsumer) {
+        validateCustomFieldsConfig(this.configService.customFields);
         const schema = this.createSchema(this.configService.customFields);
 
         consumer

+ 10 - 4
server/src/bootstrap.ts

@@ -1,6 +1,10 @@
 import { NestFactory } from '@nestjs/core';
 
+import { Type } from '../../shared/shared-types';
+
 import { getConfig, setConfig, VendureConfig } from './config/vendure-config';
+import { VendureEntity } from './entity/base/base.entity';
+import { registerCustomEntityFields } from './entity/custom-entity-fields';
 
 /**
  * Bootstrap the Vendure server.
@@ -14,19 +18,21 @@ export async function bootstrap(userConfig: Partial<VendureConfig>) {
     // base VendureEntity to be correctly configured with the primary key type
     // specified in the EntityIdStrategy.
     // tslint:disable-next-line:whitespace
-    const { coreEntities } = await import('./entity/entities');
+    const { coreEntitiesMap } = await import('./entity/entities');
     setConfig({
         dbConnectionOptions: {
-            entities: coreEntities as any,
+            entities: Object.values(coreEntitiesMap) as Array<Type<VendureEntity>>,
         },
     });
 
+    const config = getConfig();
+
+    registerCustomEntityFields(config);
+
     // The AppModule *must* be loaded only after the entities have been set in the
     // config, so that they are available when the AppModule decorator is evaluated.
     // tslint:disable-next-line:whitespace
     const appModule = await import('./app.module');
-    const config = getConfig();
-
     const app = await NestFactory.create(appModule.AppModule, { cors: config.cors });
     await app.listen(config.port);
 }

+ 99 - 19
server/src/entity/custom-entity-fields.ts

@@ -1,10 +1,11 @@
-import { Column, ColumnType, Entity } from 'typeorm';
+import { Column, ColumnType, Connection, ConnectionOptions, Entity, getConnection } from 'typeorm';
 
-import { CustomFields, CustomFieldType } from '../../../shared/shared-types';
+import { CustomFieldConfig, CustomFields, CustomFieldType, Type } from '../../../shared/shared-types';
 import { assertNever } from '../../../shared/shared-utils';
-import { getConfig } from '../config/vendure-config';
+import { VendureConfig } from '../config/vendure-config';
 
-const config = getConfig();
+import { VendureEntity } from './base/base.entity';
+import { coreEntitiesMap } from './entities';
 
 @Entity()
 export class CustomAddressFields {}
@@ -32,16 +33,19 @@ export class CustomUserFields {}
 /**
  * Dynamically add columns to the custom field entity based on the CustomFields config.
  */
-function registerEntityCustomFields(
+function registerCustomFieldsForEntity(
+    config: VendureConfig,
     entityName: keyof CustomFields,
     ctor: { new (): any },
     translation = false,
 ) {
     const customFields = config.customFields && config.customFields[entityName];
+    const dbEngine = config.dbConnectionOptions.type;
     if (customFields) {
         for (const customField of customFields) {
             const { name, type } = customField;
-            const registerColumn = () => Column({ type: getColumnType(type), name })(new ctor(), name);
+            const registerColumn = () =>
+                Column({ type: getColumnType(dbEngine, type), name })(new ctor(), name);
 
             if (translation) {
                 if (type === 'localeString') {
@@ -56,8 +60,7 @@ function registerEntityCustomFields(
     }
 }
 
-function getColumnType(type: CustomFieldType): ColumnType {
-    const dbEngine = config.dbConnectionOptions.type;
+function getColumnType(dbEngine: ConnectionOptions['type'], type: CustomFieldType): ColumnType {
     switch (type) {
         case 'string':
         case 'localeString':
@@ -76,14 +79,91 @@ function getColumnType(type: CustomFieldType): ColumnType {
     return 'varchar';
 }
 
-registerEntityCustomFields('Address', CustomAddressFields);
-registerEntityCustomFields('Customer', CustomCustomerFields);
-registerEntityCustomFields('Product', CustomProductFields);
-registerEntityCustomFields('Product', CustomProductFieldsTranslation, true);
-registerEntityCustomFields('ProductOption', CustomProductOptionFields);
-registerEntityCustomFields('ProductOption', CustomProductOptionFieldsTranslation, true);
-registerEntityCustomFields('ProductOptionGroup', CustomProductOptionGroupFields);
-registerEntityCustomFields('ProductOptionGroup', CustomProductOptionGroupFieldsTranslation, true);
-registerEntityCustomFields('ProductVariant', CustomProductVariantFields);
-registerEntityCustomFields('ProductVariant', CustomProductVariantFieldsTranslation, true);
-registerEntityCustomFields('User', CustomUserFields);
+function validateCustomFieldsForEntity(
+    connection: Connection,
+    entity: Type<VendureEntity>,
+    customFields: CustomFieldConfig[],
+): void {
+    const metadata = connection.getMetadata(entity);
+    const { relations } = metadata;
+
+    const translationRelation = relations.find(r => r.propertyName === 'translations');
+    if (translationRelation) {
+        const translationEntity = translationRelation.type;
+        const translationPropMap = connection.getMetadata(translationEntity).createPropertiesMap();
+        const localeStringFields = customFields.filter(field => field.type === 'localeString');
+        assertNoNameConflicts(entity.name, translationPropMap, localeStringFields);
+    } else {
+        assertNoLocaleStringFields(entity, customFields);
+    }
+
+    const nonLocaleStringFields = customFields.filter(field => field.type !== 'localeString');
+    const propMap = metadata.createPropertiesMap();
+    assertNoNameConflicts(entity.name, propMap, nonLocaleStringFields);
+}
+
+/**
+ * Assert that none of the custom field names conflict with existing properties of the entity, as provided
+ * by the TypeORM PropertiesMap object.
+ */
+function assertNoNameConflicts(entityName: string, propMap: object, customFields: CustomFieldConfig[]): void {
+    for (const customField of customFields) {
+        if (propMap.hasOwnProperty(customField.name)) {
+            const message = `Custom field name conflict: the "${entityName}" entity already has a built-in property "${
+                customField.name
+            }".`;
+            throw new Error(message);
+        }
+    }
+}
+
+/**
+ * For entities which are not localized (Address, Customer), we assert that none of the custom fields
+ * have a type "localeString".
+ */
+function assertNoLocaleStringFields(entity: Type<any>, customFields: CustomFieldConfig[]): void {
+    if (!!customFields.find(f => f.type === 'localeString')) {
+        const message = `Custom field type error: the "${
+            entity.name
+        }" entity does not support the "localeString" type.`;
+        throw new Error(message);
+    }
+}
+
+/**
+ * Dynamically registers any custom fields with TypeORM. This function should be run at the bootstrap
+ * stage of the app lifecycle, before the AppModule is initialized.
+ */
+export function registerCustomEntityFields(config: VendureConfig) {
+    registerCustomFieldsForEntity(config, 'Address', CustomAddressFields);
+    registerCustomFieldsForEntity(config, 'Customer', CustomCustomerFields);
+    registerCustomFieldsForEntity(config, 'Product', CustomProductFields);
+    registerCustomFieldsForEntity(config, 'Product', CustomProductFieldsTranslation, true);
+    registerCustomFieldsForEntity(config, 'ProductOption', CustomProductOptionFields);
+    registerCustomFieldsForEntity(config, 'ProductOption', CustomProductOptionFieldsTranslation, true);
+    registerCustomFieldsForEntity(config, 'ProductOptionGroup', CustomProductOptionGroupFields);
+    registerCustomFieldsForEntity(
+        config,
+        'ProductOptionGroup',
+        CustomProductOptionGroupFieldsTranslation,
+        true,
+    );
+    registerCustomFieldsForEntity(config, 'ProductVariant', CustomProductVariantFields);
+    registerCustomFieldsForEntity(config, 'ProductVariant', CustomProductVariantFieldsTranslation, true);
+    registerCustomFieldsForEntity(config, 'User', CustomUserFields);
+}
+
+/**
+ * Validates the custom fields config, e.g. by ensuring that there are no naming conflicts with the built-in fields
+ * of each entity.
+ */
+export function validateCustomFieldsConfig(customFieldConfig: CustomFields) {
+    const connection = getConnection();
+
+    for (const key of Object.keys(customFieldConfig)) {
+        const entityName = key as keyof CustomFields;
+        const customEntityFields = customFieldConfig[entityName] || [];
+        const entity = coreEntitiesMap[entityName];
+        validateCustomFieldsForEntity(connection, entity, customEntityFields);
+    }
+}

+ 5 - 2
server/src/entity/entities.ts

@@ -11,7 +11,10 @@ import { ProductTranslation } from './product/product-translation.entity';
 import { Product } from './product/product.entity';
 import { User } from './user/user.entity';
 
-export const coreEntities = [
+/**
+ * A map of all the core database entities.
+ */
+export const coreEntitiesMap = {
     Address,
     Administrator,
     Customer,
@@ -24,4 +27,4 @@ export const coreEntities = [
     ProductVariant,
     ProductVariantTranslation,
     User,
-];
+};