Преглед изворни кода

fix(server): Fix the ID encoding/decoding process, test it

Fixes #23
Michael Bromley пре 7 година
родитељ
комит
21326331e5

+ 4 - 4
server/e2e/__snapshots__/administrator.e2e-spec.ts.snap

@@ -4,10 +4,10 @@ exports[`Administrator resolver createAdministrator 1`] = `
 Object {
   "emailAddress": "test@test.com",
   "firstName": "First",
-  "id": "2",
+  "id": "T_2",
   "lastName": "Last",
   "user": Object {
-    "id": "3",
+    "id": "T_3",
     "identifier": "test@test.com",
     "lastLogin": null,
     "roles": Array [
@@ -44,10 +44,10 @@ exports[`Administrator resolver updateAdministrator 1`] = `
 Object {
   "emailAddress": "new-email",
   "firstName": "new first",
-  "id": "2",
+  "id": "T_2",
   "lastName": "new last",
   "user": Object {
-    "id": "3",
+    "id": "T_3",
     "identifier": "test@test.com",
     "lastLogin": null,
     "roles": Array [

+ 13 - 13
server/e2e/__snapshots__/product.e2e-spec.ts.snap

@@ -5,7 +5,7 @@ Object {
   "assets": Array [],
   "description": "A baked potato",
   "featuredAsset": null,
-  "id": "21",
+  "id": "T_21",
   "languageCode": "en",
   "name": "en Baked Potato",
   "optionGroups": Array [],
@@ -33,7 +33,7 @@ Object {
   "assets": Array [],
   "description": "A blob of mashed potato",
   "featuredAsset": null,
-  "id": "21",
+  "id": "T_21",
   "languageCode": "en",
   "name": "en Mashed Potato",
   "optionGroups": Array [],
@@ -60,17 +60,17 @@ exports[`Product resolver product mutation variants applyFacetValuesToProductVar
 Array [
   Object {
     "code": "Harris_LLC",
-    "id": "1",
+    "id": "T_1",
     "name": "Harris LLC",
   },
   Object {
     "code": "Heathcote_-_Goyette",
-    "id": "3",
+    "id": "T_3",
     "name": "Heathcote - Goyette",
   },
   Object {
     "code": "Fisher,_Sporer_and_Bailey",
-    "id": "5",
+    "id": "T_5",
     "name": "Fisher, Sporer and Bailey",
   },
 ]
@@ -80,17 +80,17 @@ exports[`Product resolver product mutation variants applyFacetValuesToProductVar
 Array [
   Object {
     "code": "Harris_LLC",
-    "id": "1",
+    "id": "T_1",
     "name": "Harris LLC",
   },
   Object {
     "code": "Heathcote_-_Goyette",
-    "id": "3",
+    "id": "T_3",
     "name": "Heathcote - Goyette",
   },
   Object {
     "code": "Fisher,_Sporer_and_Bailey",
-    "id": "5",
+    "id": "T_5",
     "name": "Fisher, Sporer and Bailey",
   },
 ]
@@ -101,7 +101,7 @@ Object {
   "assets": Array [
     Object {
       "fileSize": 4,
-      "id": "1",
+      "id": "T_1",
       "mimeType": "image/jpeg",
       "name": "charles-deluvio-695736-unsplash.jpg",
       "preview": "test-url/test-assets/charles-deluvio-695736-unsplash__preview.jpg",
@@ -110,7 +110,7 @@ Object {
     },
     Object {
       "fileSize": 4,
-      "id": "5",
+      "id": "T_5",
       "mimeType": "image/jpeg",
       "name": "mikkel-bech-748940-unsplash.jpg",
       "preview": "test-url/test-assets/mikkel-bech-748940-unsplash__preview.jpg",
@@ -121,20 +121,20 @@ Object {
   "description": "en Accusantium sed libero repudiandae.",
   "featuredAsset": Object {
     "fileSize": 4,
-    "id": "5",
+    "id": "T_5",
     "mimeType": "image/jpeg",
     "name": "mikkel-bech-748940-unsplash.jpg",
     "preview": "test-url/test-assets/mikkel-bech-748940-unsplash__preview.jpg",
     "source": "test-url/test-assets/mikkel-bech-748940-unsplash.jpg",
     "type": "IMAGE",
   },
-  "id": "2",
+  "id": "T_2",
   "languageCode": "en",
   "name": "en Intelligent Cotton Salad",
   "optionGroups": Array [
     Object {
       "code": "size",
-      "id": "1",
+      "id": "T_1",
       "languageCode": "en",
       "name": "Size",
     },

+ 2 - 2
server/e2e/__snapshots__/role.e2e-spec.ts.snap

@@ -4,7 +4,7 @@ exports[`Role resolver createRole 1`] = `
 Object {
   "code": "test",
   "description": "test role",
-  "id": "3",
+  "id": "T_3",
   "permissions": Array [
     "ReadCustomer",
     "UpdateCustomer",
@@ -16,7 +16,7 @@ exports[`Role resolver updateRole 1`] = `
 Object {
   "code": "test-modified",
   "description": "test role modified",
-  "id": "3",
+  "id": "T_3",
   "permissions": Array [
     "ReadCustomer",
     "UpdateCustomer",

+ 2 - 0
server/e2e/config/test-config.ts

@@ -4,6 +4,7 @@ import { VendureConfig } from '../../src/config/vendure-config';
 
 import { TestingAssetPreviewStrategy } from './testing-asset-preview-strategy';
 import { TestingAssetStorageStrategy } from './testing-asset-storage-strategy';
+import { TestingEntityIdStrategy } from './testing-entity-id-strategy';
 
 export const TEST_CONNECTION_NAME = undefined;
 
@@ -23,6 +24,7 @@ export const testConfig: VendureConfig = {
         logging: false,
     },
     customFields: {},
+    entityIdStrategy: new TestingEntityIdStrategy(),
     assetStorageStrategy: new TestingAssetStorageStrategy(),
     assetPreviewStrategy: new TestingAssetPreviewStrategy(),
 };

+ 17 - 0
server/e2e/config/testing-entity-id-strategy.ts

@@ -0,0 +1,17 @@
+import { IntegerIdStrategy } from '../../src/config/entity-id-strategy/entity-id-strategy';
+
+/**
+ * A testing entity id strategy which prefixes all IDs with a constant string. This is used in the
+ * e2e tests to ensure that all ID properties in arguments are being
+ * correctly decoded.
+ */
+export class TestingEntityIdStrategy implements IntegerIdStrategy {
+    readonly primaryKeyType = 'increment';
+    decodeId(id: string): number {
+        const asNumber = parseInt(id.replace('T_', ''), 10);
+        return Number.isNaN(asNumber) ? -1 : asNumber;
+    }
+    encodeId(primaryKey: number): string {
+        return 'T_' + primaryKey.toString();
+    }
+}

+ 4 - 4
server/e2e/product.e2e-spec.ts

@@ -119,7 +119,7 @@ describe('Product resolver', () => {
                 GET_PRODUCT_WITH_VARIANTS,
                 {
                     languageCode: LanguageCode.en,
-                    id: '2',
+                    id: 'T_2',
                 },
             );
 
@@ -302,12 +302,12 @@ describe('Product resolver', () => {
             const result = await client.query<AddOptionGroupToProduct, AddOptionGroupToProductVariables>(
                 ADD_OPTION_GROUP_TO_PRODUCT,
                 {
-                    optionGroupId: '1',
+                    optionGroupId: 'T_1',
                     productId: newProduct.id,
                 },
             );
             expect(result.addOptionGroupToProduct.optionGroups.length).toBe(1);
-            expect(result.addOptionGroupToProduct.optionGroups[0].id).toBe('1');
+            expect(result.addOptionGroupToProduct.optionGroups[0].id).toBe('T_1');
         });
 
         it('addOptionGroupToProduct errors with an invalid productId', async () => {
@@ -315,7 +315,7 @@ describe('Product resolver', () => {
                 await client.query<AddOptionGroupToProduct, AddOptionGroupToProductVariables>(
                     ADD_OPTION_GROUP_TO_PRODUCT,
                     {
-                        optionGroupId: '1',
+                        optionGroupId: 'T_1',
                         productId: '999',
                     },
                 );

+ 1 - 1
server/package.json

@@ -93,7 +93,7 @@
     "coverageDirectory": "../coverage",
     "globals": {
       "ts-jest": {
-        "skipBabel": true
+        "skipBabel": false
       }
     }
   }

+ 5 - 0
server/src/api/api.module.ts

@@ -9,6 +9,7 @@ import { ServiceModule } from '../service/service.module';
 import { AssetInterceptor } from './common/asset-interceptor';
 import { AuthGuard } from './common/auth-guard';
 import { GraphqlConfigService } from './common/graphql-config.service';
+import { IdInterceptor } from './common/id-interceptor';
 import { RequestContextService } from './common/request-context.service';
 import { RolesGuard } from './common/roles-guard';
 import { AdministratorResolver } from './resolvers/administrator.resolver';
@@ -63,6 +64,10 @@ const exportedProviders = [
             provide: APP_INTERCEPTOR,
             useClass: AssetInterceptor,
         },
+        {
+            provide: APP_INTERCEPTOR,
+            useClass: IdInterceptor,
+        },
     ],
     exports: exportedProviders,
 })

+ 0 - 91
server/src/api/common/apply-id-codec-decorator.spec.ts

@@ -1,91 +0,0 @@
-import { ApplyIdCodec, IdCodecType } from './apply-id-codec-decorator';
-
-describe('@ApplyIdCodec()', () => {
-    let mockResolver: any;
-    let mockIdCodec: MockIdCodec;
-    let argsReceived: any;
-    const RETURN_VAL = 'returnVal';
-    const ENCODED_VAL = 'encoded';
-    const DECODED_VAL = 'decoded';
-
-    class MockIdCodec implements IdCodecType {
-        decode = jest.fn().mockReturnValue(DECODED_VAL);
-        encode = jest.fn().mockReturnValue(ENCODED_VAL);
-    }
-
-    beforeEach(() => {
-        mockIdCodec = new MockIdCodec();
-        argsReceived = undefined;
-
-        class MockResolver {
-            @ApplyIdCodec(undefined, mockIdCodec)
-            getItem(_, args) {
-                argsReceived = args;
-                return RETURN_VAL;
-            }
-
-            @ApplyIdCodec(['foo'], mockIdCodec)
-            getItemWithKeys(_, args) {
-                argsReceived = args;
-                return RETURN_VAL;
-            }
-
-            @ApplyIdCodec(undefined, mockIdCodec)
-            getItemAsync(_, args) {
-                argsReceived = args;
-                return Promise.resolve(RETURN_VAL);
-            }
-        }
-
-        mockResolver = new MockResolver();
-    });
-
-    it('calls IdCodec.decode with args', () => {
-        const args = { foo: 1 };
-        mockResolver.getItem({}, args);
-
-        expect(mockIdCodec.decode).toHaveBeenCalledWith(args, undefined);
-    });
-
-    it('passes transformed args to resolver method', () => {
-        const args = { foo: 1 };
-        mockResolver.getItem({}, args);
-
-        expect(argsReceived).toBe(DECODED_VAL);
-    });
-
-    it('calls IdCodec.decode with args and transformKeys', () => {
-        const args = { foo: 1 };
-        mockResolver.getItemWithKeys({}, args);
-
-        expect(mockIdCodec.decode).toHaveBeenCalledWith(args, ['foo']);
-    });
-
-    it('calls IdCodec.encode on return value', () => {
-        const args = { foo: 1 };
-        mockResolver.getItem({}, args);
-
-        expect(mockIdCodec.encode).toHaveBeenCalledWith(RETURN_VAL);
-    });
-
-    it('returns the encoded value', () => {
-        const args = { foo: 1 };
-        const result = mockResolver.getItem({}, args);
-
-        expect(result).toBe(ENCODED_VAL);
-    });
-
-    it('calls IdCodec.encode on returned promise value', () => {
-        const args = { foo: 1 };
-        return mockResolver.getItemAsync({}, args).then(() => {
-            expect(mockIdCodec.encode).toHaveBeenCalledWith(RETURN_VAL);
-        });
-    });
-
-    it('returns a promise with the encoded value', () => {
-        const args = { foo: 1 };
-        return mockResolver.getItemAsync({}, args).then(result => {
-            expect(result).toBe(ENCODED_VAL);
-        });
-    });
-});

+ 0 - 39
server/src/api/common/apply-id-codec-decorator.ts

@@ -1,39 +0,0 @@
-import { getConfig } from '../../config/vendure-config';
-
-import { IdCodec } from './id-codec';
-
-export type IdCodecType = { [K in keyof IdCodec]: IdCodec[K] };
-
-/**
- * A decorator for use on resolver methods which automatically applies the configured
- * EntityIdStrategy to the arguments & return values of the resolver.
- *
- * @param transformKeys - An optional array of keys of the arguments object to be decoded. If not set,
- * then the arguments will be walked recursively and any `id` property will be decoded.
- * @param customIdCodec - A custom IdCodec instance, primarily intended for testing.
- */
-export function ApplyIdCodec(transformKeys?: string[], customIdCodec?: IdCodecType) {
-    let idCodec: IdCodecType;
-
-    return (target: any, name: string | symbol, descriptor: PropertyDescriptor) => {
-        if (!customIdCodec) {
-            const strategy = getConfig().entityIdStrategy;
-            idCodec = new IdCodec(strategy);
-        } else {
-            idCodec = customIdCodec;
-        }
-
-        const originalFn = descriptor.value;
-        if (typeof originalFn === 'function') {
-            descriptor.value = function(rootValue?: any, args?: any, context?: any, info?: any) {
-                const encodedArgs = idCodec.decode(args, transformKeys);
-                const result = originalFn.apply(this, [rootValue, encodedArgs, context, info]);
-                if (result.then) {
-                    return result.then(data => idCodec.encode(data));
-                } else {
-                    return idCodec.encode(result);
-                }
-            };
-        }
-    };
-}

+ 109 - 2
server/src/api/common/id-codec.spec.ts

@@ -29,6 +29,47 @@ describe('IdCodecService', () => {
             expect(idCodec.encode(undefined as any)).toBeUndefined();
         });
 
+        it('returns a clone of the input object', () => {
+            const input = { id: 'id', name: 'foo' };
+
+            const result = idCodec.encode(input);
+            expect(result).not.toBe(input);
+        });
+
+        it('returns a deep clone', () => {
+            const obj1 = { 1: true };
+            const obj2 = { 2: true };
+            const arr = [obj1, obj2];
+            const parent = { myArray: arr };
+            const input = { foo: parent };
+
+            const result = idCodec.encode(input);
+            expect(result).not.toBe(input);
+            expect(result.foo).not.toBe(parent);
+            expect(result.foo.myArray).not.toBe(arr);
+            expect(result.foo.myArray[0]).not.toBe(obj1);
+            expect(result.foo.myArray[1]).not.toBe(obj2);
+        });
+
+        it('does not clone complex object instances', () => {
+            // tslint:disable:no-floating-promises
+            const promise = new Promise(() => {
+                /**/
+            });
+            const date = new Date();
+            const regex = new RegExp('');
+            const input = {
+                promise,
+                date,
+                regex,
+            };
+            const result = idCodec.encode(input);
+            expect(result.promise).toBe(promise);
+            expect(result.date).toBe(date);
+            expect(result.regex).toBe(regex);
+            // tslint:enable:no-floating-promises
+        });
+
         it('works with simple entity', () => {
             const input = { id: 'id', name: 'foo' };
 
@@ -142,7 +183,7 @@ describe('IdCodecService', () => {
             const input = { id: 'id', name: 'foo' };
 
             const result = idCodec.encode(input, ['name']);
-            expect(result).toEqual({ id: 'id', name: ENCODED });
+            expect(result).toEqual({ id: ENCODED, name: ENCODED });
         });
     });
 
@@ -264,10 +305,76 @@ describe('IdCodecService', () => {
         });
 
         it('transformKeys can be customized', () => {
+            const input = { name: 'foo' };
+
+            const result = idCodec.decode(input, ['name']);
+            expect(result).toEqual({ name: DECODED });
+        });
+
+        it('id keys is still implicitly decoded when transformKeys are defined', () => {
             const input = { id: 'id', name: 'foo' };
 
             const result = idCodec.decode(input, ['name']);
-            expect(result).toEqual({ id: 'id', name: DECODED });
+            expect(result).toEqual({ id: DECODED, name: DECODED });
+        });
+
+        it('transformKeys works for nested matching keys', () => {
+            const input = {
+                input: {
+                    id: 'id',
+                    featuredAssetId: 'id',
+                    foo: 'bar',
+                },
+            };
+
+            const result = idCodec.decode(input, ['featuredAssetId']);
+            expect(result).toEqual({
+                input: {
+                    id: DECODED,
+                    featuredAssetId: DECODED,
+                    foo: 'bar',
+                },
+            });
+        });
+
+        it('transformKeys works for nested matching key array', () => {
+            const input = {
+                input: {
+                    id: 'id',
+                    assetIds: ['id1', 'id2', 'id3'],
+                    foo: 'bar',
+                },
+            };
+
+            const result = idCodec.decode(input, ['assetIds']);
+            expect(result).toEqual({
+                input: {
+                    id: DECODED,
+                    assetIds: [DECODED, DECODED, DECODED],
+                    foo: 'bar',
+                },
+            });
+        });
+
+        it('transformKeys works for multiple nested keys', () => {
+            const input = {
+                input: {
+                    id: 'id',
+                    featuredAssetId: 'id',
+                    assetIds: ['id1', 'id2', 'id3'],
+                    foo: 'bar',
+                },
+            };
+
+            const result = idCodec.decode(input, ['featuredAssetId', 'assetIds']);
+            expect(result).toEqual({
+                input: {
+                    id: DECODED,
+                    featuredAssetId: DECODED,
+                    assetIds: [DECODED, DECODED, DECODED],
+                    foo: 'bar',
+                },
+            });
         });
     });
 });

+ 41 - 32
server/src/api/common/id-codec.ts

@@ -18,9 +18,15 @@ export class IdCodec {
      * @param target - The object to be decoded
      * @param transformKeys - An optional array of keys of the target to be decoded. If not defined,
      * then the default recursive behaviour will be used.
+     * @return A decoded clone of the target
      */
-    decode<T extends string | number | object | undefined>(target: T, transformKeys?: Array<keyof T>): T {
-        return this.transformRecursive(target, input => this.entityIdStrategy.decodeId(input), transformKeys);
+    decode<T extends string | number | object | undefined>(target: T, transformKeys?: string[]): T {
+        const transformKeysWithId = [...(transformKeys || []), 'id'];
+        return this.transformRecursive(
+            target,
+            input => this.entityIdStrategy.decodeId(input),
+            transformKeysWithId,
+        );
     }
 
     /**
@@ -30,67 +36,74 @@ export class IdCodec {
      * @param target - The object to be encoded
      * @param transformKeys - An optional array of keys of the target to be encoded. If not defined,
      * then the default recursive behaviour will be used.
+     * @return An encoded clone of the target
      */
-    encode<T extends string | number | object | undefined>(target: T, transformKeys?: Array<keyof T>): T {
-        return this.transformRecursive(target, input => this.entityIdStrategy.encodeId(input), transformKeys);
+    encode<T extends string | number | object | undefined>(target: T, transformKeys?: string[]): T {
+        const transformKeysWithId = [...(transformKeys || []), 'id'];
+        return this.transformRecursive(
+            target,
+            input => this.entityIdStrategy.encodeId(input),
+            transformKeysWithId,
+        );
     }
 
-    private transformRecursive<T>(
-        target: T,
-        transformFn: (input: any) => ID,
-        transformKeys?: Array<keyof T>,
-    ): T {
-        if (target == null) {
+    private transformRecursive<T>(target: T, transformFn: (input: any) => ID, transformKeys?: string[]): T {
+        // noinspection SuspiciousInstanceOfGuard
+        if (
+            target == null ||
+            target instanceof Promise ||
+            target instanceof Date ||
+            target instanceof RegExp
+        ) {
             return target;
         }
         if (typeof target === 'string' || typeof target === 'number') {
             return transformFn(target as any) as any;
         }
-        this.transform(target, transformFn, transformKeys);
-
-        if (transformKeys) {
-            return target;
-        }
 
         if (Array.isArray(target)) {
-            if (target.length === 0) {
+            (target as any) = target.slice(0);
+            if (target.length === 0 || typeof target[0] === 'string' || typeof target[0] === 'number') {
                 return target;
             }
             const isSimpleObject = this.isSimpleObject(target[0]);
-            const isEntity = this.isEntity(target[0]);
-            if (isSimpleObject && !isEntity) {
-                return target;
-            }
             if (isSimpleObject) {
                 const length = target.length;
                 for (let i = 0; i < length; i++) {
-                    this.transform(target[i], transformFn);
+                    target[i] = this.transform(target[i], transformFn, transformKeys);
                 }
             } else {
                 const length = target.length;
                 for (let i = 0; i < length; i++) {
-                    target[i] = this.transformRecursive(target[i], transformFn);
+                    target[i] = this.transformRecursive(target[i], transformFn, transformKeys);
                 }
             }
         } else {
+            target = this.transform(target, transformFn, transformKeys);
             for (const key of Object.keys(target)) {
                 if (this.isObject(target[key])) {
-                    this.transformRecursive(target[key], transformFn);
+                    target[key] = this.transformRecursive(target[key], transformFn, transformKeys);
                 }
             }
         }
         return target;
     }
 
-    private transform<T>(target: T, transformFn: (input: any) => ID, transformKeys?: Array<keyof T>): T {
+    private transform<T>(target: T, transformFn: (input: any) => ID, transformKeys?: string[]): T {
+        const clone = Object.assign({}, target);
         if (transformKeys) {
             for (const key of transformKeys) {
-                target[key] = transformFn(target[key]) as any;
+                if (target.hasOwnProperty(key)) {
+                    const val = target[key];
+                    if (Array.isArray(val)) {
+                        clone[key] = val.map(v => transformFn(v));
+                    } else {
+                        clone[key] = transformFn(val);
+                    }
+                }
             }
-        } else if (this.isEntity(target)) {
-            target.id = transformFn(target.id);
         }
-        return target;
+        return clone;
     }
 
     private isSimpleObject(target: any): boolean {
@@ -103,10 +116,6 @@ export class IdCodec {
         return true;
     }
 
-    private isEntity(target: any): target is VendureEntity {
-        return target && target.hasOwnProperty('id');
-    }
-
     private isObject(target: any): target is object {
         return typeof target === 'object' && target != null;
     }

+ 48 - 0
server/src/api/common/id-interceptor.ts

@@ -0,0 +1,48 @@
+import { ExecutionContext, Injectable, NestInterceptor, ReflectMetadata } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { GqlExecutionContext } from '@nestjs/graphql';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { ConfigService } from '../../config/config.service';
+
+import { IdCodec } from './id-codec';
+
+const DECODE_METADATA_KEY = '__decode__';
+
+/**
+ * Attatches metadata to the resolver defining which keys are ids which need to be decoded.
+ * By default, all keys named "id" will be implicitly decoded, but some operations have ID arguments
+ * which are not named "id", e.g. assignRoleToAdministrator, where there are 2 ID arguments passed.
+ *
+ * @example
+ * ```
+ *  @Query()
+ *  @Decode('administratorId', 'roleId')
+ *  assignRoleToAdministrator(@Args() args) {
+ *      // ...
+ *  }
+ * ```
+ */
+export const Decode = (...transformKeys: string[]) => ReflectMetadata(DECODE_METADATA_KEY, transformKeys);
+
+/**
+ * This interceptor automatically decodes incoming requests and encodes outgoing requests so that any
+ * ID values are transformed correctly as per the configured EntityIdStrategy.
+ */
+@Injectable()
+export class IdInterceptor implements NestInterceptor {
+    private codec: IdCodec;
+
+    constructor(private configService: ConfigService, private readonly reflector: Reflector) {
+        this.codec = new IdCodec(this.configService.entityIdStrategy);
+    }
+
+    intercept(context: ExecutionContext, call$: Observable<any>): Observable<any> {
+        const args = GqlExecutionContext.create(context).getArgs();
+        const transformKeys = this.reflector.get<string[]>(DECODE_METADATA_KEY, context.getHandler());
+
+        Object.assign(args, this.codec.decode(args, transformKeys));
+        return call$.pipe(map(data => this.codec.encode(data)));
+    }
+}

+ 3 - 6
server/src/api/resolvers/administrator.resolver.ts

@@ -11,7 +11,7 @@ import { PaginatedList } from 'shared/shared-types';
 
 import { Administrator } from '../../entity/administrator/administrator.entity';
 import { AdministratorService } from '../../service/providers/administrator.service';
-import { ApplyIdCodec } from '../common/apply-id-codec-decorator';
+import { Decode } from '../common/id-interceptor';
 import { Allow } from '../common/roles-guard';
 
 @Resolver('Administrator')
@@ -20,21 +20,19 @@ export class AdministratorResolver {
 
     @Query()
     @Allow(Permission.ReadAdministrator)
-    @ApplyIdCodec()
     administrators(@Args() args: GetAdministratorsVariables): Promise<PaginatedList<Administrator>> {
         return this.administratorService.findAll(args.options || undefined);
     }
 
     @Query()
     @Allow(Permission.ReadAdministrator)
-    @ApplyIdCodec()
     administrator(@Args() args: GetAdministratorVariables): Promise<Administrator | undefined> {
         return this.administratorService.findOne(args.id);
     }
 
     @Mutation()
     @Allow(Permission.CreateAdministrator)
-    @ApplyIdCodec()
+    @Decode('roleIds')
     createAdministrator(@Args() args: CreateAdministratorVariables): Promise<Administrator> {
         const { input } = args;
         return this.administratorService.create(input);
@@ -42,7 +40,6 @@ export class AdministratorResolver {
 
     @Mutation()
     @Allow(Permission.CreateAdministrator)
-    @ApplyIdCodec()
     updateAdministrator(@Args() args: UpdateAdministratorVariables): Promise<Administrator> {
         const { input } = args;
         return this.administratorService.update(input);
@@ -50,7 +47,7 @@ export class AdministratorResolver {
 
     @Mutation()
     @Allow(Permission.UpdateAdministrator)
-    @ApplyIdCodec()
+    @Decode('administratorId', 'roleId')
     assignRoleToAdministrator(@Args() args: AssignRoleToAdministratorVariables): Promise<Administrator> {
         return this.administratorService.assignRole(args.administratorId, args.roleId);
     }

+ 7 - 11
server/src/api/resolvers/customer.resolver.ts

@@ -1,11 +1,11 @@
-import { Mutation, Query, ResolveProperty, Resolver } from '@nestjs/graphql';
+import { Args, Mutation, Query, ResolveProperty, Resolver } from '@nestjs/graphql';
 import { Permission } from 'shared/generated-types';
 import { PaginatedList } from 'shared/shared-types';
 
 import { Address } from '../../entity/address/address.entity';
 import { Customer } from '../../entity/customer/customer.entity';
 import { CustomerService } from '../../service/providers/customer.service';
-import { ApplyIdCodec } from '../common/apply-id-codec-decorator';
+import { Decode } from '../common/id-interceptor';
 import { Allow } from '../common/roles-guard';
 
 @Resolver('Customer')
@@ -14,37 +14,33 @@ export class CustomerResolver {
 
     @Query()
     @Allow(Permission.ReadCustomer)
-    @ApplyIdCodec()
-    async customers(obj, args): Promise<PaginatedList<Customer>> {
+    async customers(@Args() args): Promise<PaginatedList<Customer>> {
         return this.customerService.findAll(args.options);
     }
 
     @Query()
     @Allow(Permission.ReadCustomer)
-    @ApplyIdCodec()
-    async customer(obj, args): Promise<Customer | undefined> {
+    async customer(@Args() args): Promise<Customer | undefined> {
         return this.customerService.findOne(args.id);
     }
 
     @ResolveProperty()
     @Allow(Permission.ReadCustomer)
-    @ApplyIdCodec()
     async addresses(customer: Customer): Promise<Address[]> {
         return this.customerService.findAddressesByCustomerId(customer.id);
     }
 
     @Mutation()
     @Allow(Permission.CreateCustomer)
-    @ApplyIdCodec()
-    async createCustomer(_, args): Promise<Customer> {
+    async createCustomer(@Args() args): Promise<Customer> {
         const { input, password } = args;
         return this.customerService.create(input, password);
     }
 
     @Mutation()
     @Allow(Permission.CreateCustomer)
-    @ApplyIdCodec()
-    async createCustomerAddress(_, args): Promise<Address> {
+    @Decode('customerId')
+    async createCustomerAddress(@Args() args): Promise<Address> {
         const { customerId, input } = args;
         return this.customerService.createAddress(customerId, input);
     }

+ 11 - 14
server/src/api/resolvers/facet.resolver.ts

@@ -1,4 +1,4 @@
-import { Mutation, Query, Resolver } from '@nestjs/graphql';
+import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
     CreateFacetValuesVariables,
     CreateFacetVariables,
@@ -15,7 +15,6 @@ import { Facet } from '../../entity/facet/facet.entity';
 import { I18nError } from '../../i18n/i18n-error';
 import { FacetValueService } from '../../service/providers/facet-value.service';
 import { FacetService } from '../../service/providers/facet.service';
-import { ApplyIdCodec } from '../common/apply-id-codec-decorator';
 import { Allow } from '../common/roles-guard';
 
 @Resolver('Facet')
@@ -24,22 +23,19 @@ export class FacetResolver {
 
     @Query()
     @Allow(Permission.ReadCatalog)
-    @ApplyIdCodec()
-    facets(obj, args): Promise<PaginatedList<Translated<Facet>>> {
+    facets(@Args() args): Promise<PaginatedList<Translated<Facet>>> {
         return this.facetService.findAll(args.languageCode, args.options);
     }
 
     @Query()
     @Allow(Permission.ReadCatalog)
-    @ApplyIdCodec()
-    async facet(obj, args): Promise<Translated<Facet> | undefined> {
+    async facet(@Args() args): Promise<Translated<Facet> | undefined> {
         return this.facetService.findOne(args.id, args.languageCode);
     }
 
     @Mutation()
     @Allow(Permission.CreateCatalog)
-    @ApplyIdCodec()
-    async createFacet(_, args: CreateFacetVariables): Promise<Translated<Facet>> {
+    async createFacet(@Args() args: CreateFacetVariables): Promise<Translated<Facet>> {
         const { input } = args;
         const facet = await this.facetService.create(args.input);
 
@@ -54,16 +50,16 @@ export class FacetResolver {
 
     @Mutation()
     @Allow(Permission.UpdateCatalog)
-    @ApplyIdCodec()
-    async updateFacet(_, args: UpdateFacetVariables): Promise<Translated<Facet>> {
+    async updateFacet(@Args() args: UpdateFacetVariables): Promise<Translated<Facet>> {
         const { input } = args;
         return this.facetService.update(args.input);
     }
 
     @Mutation()
     @Allow(Permission.CreateCatalog)
-    @ApplyIdCodec()
-    async createFacetValues(_, args: CreateFacetValuesVariables): Promise<Array<Translated<FacetValue>>> {
+    async createFacetValues(
+        @Args() args: CreateFacetValuesVariables,
+    ): Promise<Array<Translated<FacetValue>>> {
         const { input } = args;
         const facetId = input[0].facetId;
         const facet = await this.facetService.findOne(facetId, DEFAULT_LANGUAGE_CODE);
@@ -75,8 +71,9 @@ export class FacetResolver {
 
     @Mutation()
     @Allow(Permission.UpdateCatalog)
-    @ApplyIdCodec()
-    async updateFacetValues(_, args: UpdateFacetValuesVariables): Promise<Array<Translated<FacetValue>>> {
+    async updateFacetValues(
+        @Args() args: UpdateFacetValuesVariables,
+    ): Promise<Array<Translated<FacetValue>>> {
         const { input } = args;
         return Promise.all(input.map(facetValue => this.facetValueService.update(facetValue)));
     }

+ 5 - 12
server/src/api/resolvers/product-option.resolver.ts

@@ -1,4 +1,4 @@
-import { Mutation, Query, ResolveProperty, Resolver } from '@nestjs/graphql';
+import { Args, Mutation, Query, ResolveProperty, Resolver } from '@nestjs/graphql';
 import { CreateProductOptionGroupVariables, Permission } from 'shared/generated-types';
 
 import { Translated } from '../../common/types/locale-types';
@@ -6,7 +6,6 @@ import { ProductOptionGroup } from '../../entity/product-option-group/product-op
 import { ProductOption } from '../../entity/product-option/product-option.entity';
 import { ProductOptionGroupService } from '../../service/providers/product-option-group.service';
 import { ProductOptionService } from '../../service/providers/product-option.service';
-import { ApplyIdCodec } from '../common/apply-id-codec-decorator';
 import { Allow } from '../common/roles-guard';
 
 @Resolver('ProductOptionGroup')
@@ -18,21 +17,18 @@ export class ProductOptionResolver {
 
     @Query()
     @Allow(Permission.ReadCatalog)
-    @ApplyIdCodec()
-    productOptionGroups(obj, args): Promise<Array<Translated<ProductOptionGroup>>> {
+    productOptionGroups(@Args() args): Promise<Array<Translated<ProductOptionGroup>>> {
         return this.productOptionGroupService.findAll(args.languageCode, args.filterTerm);
     }
 
     @Query()
     @Allow(Permission.ReadCatalog)
-    @ApplyIdCodec()
-    productOptionGroup(obj, args): Promise<Translated<ProductOptionGroup> | undefined> {
+    productOptionGroup(@Args() args): Promise<Translated<ProductOptionGroup> | undefined> {
         return this.productOptionGroupService.findOne(args.id, args.languageCode);
     }
 
     @ResolveProperty()
     @Allow(Permission.ReadCatalog)
-    @ApplyIdCodec()
     async options(optionGroup: Translated<ProductOptionGroup>): Promise<Array<Translated<ProductOption>>> {
         if (optionGroup.options) {
             return Promise.resolve(optionGroup.options);
@@ -43,10 +39,8 @@ export class ProductOptionResolver {
 
     @Mutation()
     @Allow(Permission.CreateCatalog)
-    @ApplyIdCodec()
     async createProductOptionGroup(
-        _,
-        args: CreateProductOptionGroupVariables,
+        @Args() args: CreateProductOptionGroupVariables,
     ): Promise<Translated<ProductOptionGroup>> {
         const { input } = args;
         const group = await this.productOptionGroupService.create(args.input);
@@ -62,8 +56,7 @@ export class ProductOptionResolver {
 
     @Mutation()
     @Allow(Permission.UpdateCatalog)
-    @ApplyIdCodec()
-    async updateProductOptionGroup(_, args): Promise<Translated<ProductOptionGroup>> {
+    async updateProductOptionGroup(@Args() args): Promise<Translated<ProductOptionGroup>> {
         const { input } = args;
         return this.productOptionGroupService.update(args.input);
     }

+ 7 - 10
server/src/api/resolvers/product.resolver.ts

@@ -22,7 +22,7 @@ import { I18nError } from '../../i18n/i18n-error';
 import { FacetValueService } from '../../service/providers/facet-value.service';
 import { ProductVariantService } from '../../service/providers/product-variant.service';
 import { ProductService } from '../../service/providers/product.service';
-import { ApplyIdCodec } from '../common/apply-id-codec-decorator';
+import { Decode } from '../common/id-interceptor';
 import { RequestContext } from '../common/request-context';
 import { RequestContextPipe } from '../common/request-context.pipe';
 import { Allow } from '../common/roles-guard';
@@ -37,7 +37,6 @@ export class ProductResolver {
 
     @Query()
     @Allow(Permission.ReadCatalog)
-    @ApplyIdCodec()
     async products(
         @Context(RequestContextPipe) ctx: RequestContext,
         @Args() args: GetProductListVariables,
@@ -48,7 +47,6 @@ export class ProductResolver {
 
     @Query()
     @Allow(Permission.ReadCatalog)
-    @ApplyIdCodec()
     async product(
         @Context(RequestContextPipe) ctx: RequestContext,
         @Args() args: GetProductWithVariantsVariables,
@@ -59,7 +57,7 @@ export class ProductResolver {
 
     @Mutation()
     @Allow(Permission.CreateCatalog)
-    @ApplyIdCodec()
+    @Decode('assetIds', 'featuredAssetId')
     async createProduct(
         @Context(RequestContextPipe) ctx: RequestContext,
         @Args() args: CreateProductVariables,
@@ -70,7 +68,7 @@ export class ProductResolver {
 
     @Mutation()
     @Allow(Permission.UpdateCatalog)
-    @ApplyIdCodec()
+    @Decode('assetIds', 'featuredAssetId')
     async updateProduct(
         @Context(RequestContextPipe) ctx: RequestContext,
         @Args() args: UpdateProductVariables,
@@ -81,7 +79,7 @@ export class ProductResolver {
 
     @Mutation()
     @Allow(Permission.UpdateCatalog)
-    @ApplyIdCodec(['productId', 'optionGroupId'])
+    @Decode('productId', 'optionGroupId')
     async addOptionGroupToProduct(
         @Context(RequestContextPipe) ctx: RequestContext,
         @Args() args: AddOptionGroupToProductVariables,
@@ -92,7 +90,7 @@ export class ProductResolver {
 
     @Mutation()
     @Allow(Permission.UpdateCatalog)
-    @ApplyIdCodec(['productId', 'optionGroupId'])
+    @Decode('productId', 'optionGroupId')
     async removeOptionGroupFromProduct(
         @Context(RequestContextPipe) ctx: RequestContext,
         @Args() args: RemoveOptionGroupFromProductVariables,
@@ -103,7 +101,7 @@ export class ProductResolver {
 
     @Mutation()
     @Allow(Permission.CreateCatalog)
-    @ApplyIdCodec()
+    @Decode('productId')
     async generateVariantsForProduct(
         @Context(RequestContextPipe) ctx: RequestContext,
         @Args() args: GenerateProductVariantsVariables,
@@ -115,7 +113,6 @@ export class ProductResolver {
 
     @Mutation()
     @Allow(Permission.UpdateCatalog)
-    @ApplyIdCodec()
     async updateProductVariants(
         @Context(RequestContextPipe) ctx: RequestContext,
         @Args() args: UpdateProductVariantsVariables,
@@ -126,7 +123,7 @@ export class ProductResolver {
 
     @Mutation()
     @Allow(Permission.UpdateCatalog)
-    @ApplyIdCodec()
+    @Decode('facetValueIds', 'productVariantIds')
     async applyFacetValuesToProductVariants(
         @Context(RequestContextPipe) ctx: RequestContext,
         @Args() args: ApplyFacetValuesToProductVariantsVariables,

+ 0 - 5
server/src/api/resolvers/role.resolver.ts

@@ -10,7 +10,6 @@ import { PaginatedList } from 'shared/shared-types';
 
 import { Role } from '../../entity/role/role.entity';
 import { RoleService } from '../../service/providers/role.service';
-import { ApplyIdCodec } from '../common/apply-id-codec-decorator';
 import { Allow } from '../common/roles-guard';
 
 @Resolver('Roles')
@@ -19,21 +18,18 @@ export class RoleResolver {
 
     @Query()
     @Allow(Permission.ReadAdministrator)
-    @ApplyIdCodec()
     roles(@Args() args: GetRolesVariables): Promise<PaginatedList<Role>> {
         return this.roleService.findAll(args.options || undefined);
     }
 
     @Query()
     @Allow(Permission.ReadAdministrator)
-    @ApplyIdCodec()
     role(@Args() args: GetRoleVariables): Promise<Role | undefined> {
         return this.roleService.findOne(args.id);
     }
 
     @Mutation()
     @Allow(Permission.CreateAdministrator)
-    @ApplyIdCodec()
     createRole(@Args() args: CreateRoleVariables): Promise<Role> {
         const { input } = args;
         return this.roleService.create(input);
@@ -41,7 +37,6 @@ export class RoleResolver {
 
     @Mutation()
     @Allow(Permission.UpdateAdministrator)
-    @ApplyIdCodec()
     updateRole(@Args() args: UpdateRoleVariables): Promise<Role> {
         const { input } = args;
         return this.roleService.update(input);

+ 29 - 0
shared/simple-deep-clone.ts

@@ -0,0 +1,29 @@
+/**
+ * An extremely fast function for deep-cloning an object which only contains simple
+ * values, i.e. primitives, arrays and nested simple objects.
+ */
+export function simpleDeepClone<T>(input: T): T {
+    // if not array or object or is null return self
+    if (typeof input !== 'object' || input === null) {
+        return input;
+    }
+    let output: any;
+    let i: number | string;
+    // handle case: array
+    if (input instanceof Array) {
+        let l;
+        output = [] as any[];
+        for (i = 0, l = input.length; i < l; i++) {
+            output[i] = simpleDeepClone(input[i]);
+        }
+        return output;
+    }
+    // handle case: object
+    output = {};
+    for (i in input) {
+        if (input.hasOwnProperty(i)) {
+            output[i] = simpleDeepClone((input as any)[i]);
+        }
+    }
+    return output;
+}