Browse Source

feat(server): Implement user-configurable id encoding strategies

Michael Bromley 7 years ago
parent
commit
fc39e9fef5

+ 17 - 11
server/src/api/customer/customer.resolver.ts

@@ -4,35 +4,41 @@ import { Address } from '../../entity/address/address.entity';
 import { CreateCustomerDto } from '../../entity/customer/customer.dto';
 import { Customer } from '../../entity/customer/customer.entity';
 import { CustomerService } from '../../service/customer.service';
+import { IdCodecService } from '../../service/id-codec.service';
 
 @Resolver('Customer')
 export class CustomerResolver {
-    constructor(private customerService: CustomerService) {}
+    constructor(private customerService: CustomerService, private idCodecService: IdCodecService) {}
 
     @Query('customers')
-    customers(obj, args): Promise<PaginatedList<Customer>> {
-        return this.customerService.findAll(args.take, args.skip);
+    async customers(obj, args): Promise<PaginatedList<Customer>> {
+        return this.customerService.findAll(args.take, args.skip).then(list => this.idCodecService.encode(list));
     }
 
     @Query('customer')
-    customer(obj, args): Promise<Customer | undefined> {
-        return this.customerService.findOne(args.id);
+    async customer(obj, args): Promise<Customer | undefined> {
+        return this.customerService
+            .findOne(this.idCodecService.decode(args).id)
+            .then(c => this.idCodecService.encode(c));
     }
 
     @ResolveProperty('addresses')
-    addresses(customer: Customer): Promise<Address[]> {
-        return this.customerService.findAddressesByCustomerId(customer.id);
+    async addresses(customer: Customer): Promise<Address[]> {
+        const address = await this.customerService.findAddressesByCustomerId(this.idCodecService.decode(customer).id);
+        return this.idCodecService.encode(address);
     }
 
     @Mutation()
-    createCustomer(_, args): Promise<Customer> {
+    async createCustomer(_, args): Promise<Customer> {
         const { input, password } = args;
-        return this.customerService.create(input, password);
+        const customer = await this.customerService.create(input, password);
+        return this.idCodecService.encode(customer);
     }
 
     @Mutation()
-    createCustomerAddress(_, args): Promise<Address> {
+    async createCustomerAddress(_, args): Promise<Address> {
         const { customerId, input } = args;
-        return this.customerService.createAddress(customerId, input);
+        const address = await this.customerService.createAddress(this.idCodecService.decode(customerId), input);
+        return this.idCodecService.encode(address);
     }
 }

+ 18 - 8
server/src/api/product/product.resolver.ts

@@ -1,21 +1,30 @@
 import { Mutation, Query, Resolver } from '@nestjs/graphql';
 import { PaginatedList } from '../../common/common-types';
 import { Product } from '../../entity/product/product.entity';
+import { IdCodecService } from '../../service/id-codec.service';
 import { ProductVariantService } from '../../service/product-variant.service';
 import { ProductService } from '../../service/product.service';
 
 @Resolver('Product')
 export class ProductResolver {
-    constructor(private productService: ProductService, private productVariantService: ProductVariantService) {}
+    constructor(
+        private productService: ProductService,
+        private idCodecService: IdCodecService,
+        private productVariantService: ProductVariantService,
+    ) {}
 
     @Query('products')
-    products(obj, args): Promise<PaginatedList<Product>> {
-        return this.productService.findAll(args.languageCode, args.take, args.skip);
+    async products(obj, args): Promise<PaginatedList<Product>> {
+        return this.productService
+            .findAll(args.languageCode, args.take, args.skip)
+            .then(list => this.idCodecService.encode(list));
     }
 
     @Query('product')
-    product(obj, args): Promise<Product | undefined> {
-        return this.productService.findOne(args.id, args.languageCode);
+    async product(obj, args): Promise<Product | undefined> {
+        return this.productService
+            .findOne(this.idCodecService.decode(args).id, args.languageCode)
+            .then(p => this.idCodecService.encode(p));
     }
 
     @Mutation()
@@ -29,12 +38,13 @@ export class ProductResolver {
             }
         }
 
-        return product;
+        return this.idCodecService.encode(product);
     }
 
     @Mutation()
-    updateProduct(_, args): Promise<Product | undefined> {
+    async updateProduct(_, args): Promise<Product | undefined> {
         const { input } = args;
-        return this.productService.update(input);
+        const product = await this.productService.update(this.idCodecService.decode(input));
+        return this.idCodecService.decode(product);
     }
 }

+ 2 - 2
server/src/config/auto-increment-id-strategy.ts

@@ -1,11 +1,11 @@
 import { IntegerIdStrategy } from './entity-id-strategy';
 
 /**
- * An id strategy which simply uses auto-increment integers as primary keys
+ * An id strategy which uses auto-increment integers as primary keys
  * for all entities.
  */
 export class AutoIncrementIdStrategy implements IntegerIdStrategy {
-    primaryKeyType: 'increment';
+    readonly primaryKeyType = 'increment';
     decodeId(id: string): number {
         return +id;
     }

+ 3 - 3
server/src/config/entity-id-strategy.ts

@@ -3,19 +3,19 @@ import { ID } from '../common/common-types';
 export type PrimaryKeyType = 'increment' | 'uuid';
 
 export interface EntityIdStrategy<T extends ID = ID> {
-    primaryKeyType: PrimaryKeyType;
+    readonly primaryKeyType: PrimaryKeyType;
     encodeId: (primaryKey: T) => string;
     decodeId: (id: string) => T;
 }
 
 export interface IntegerIdStrategy extends EntityIdStrategy<number> {
-    primaryKeyType: 'increment';
+    readonly primaryKeyType: 'increment';
     encodeId: (primaryKey: number) => string;
     decodeId: (id: string) => number;
 }
 
 export interface StringIdStrategy extends EntityIdStrategy<string> {
-    primaryKeyType: 'uuid';
+    readonly primaryKeyType: 'uuid';
     encodeId: (primaryKey: string) => string;
     decodeId: (id: string) => string;
 }

+ 2 - 2
server/src/config/uuid-id-strategy.ts

@@ -1,11 +1,11 @@
 import { StringIdStrategy } from './entity-id-strategy';
 
 /**
- * An id strategy which simply uses auto-increment integers as primary keys
+ * An id strategy which uses string uuids as primary keys
  * for all entities.
  */
 export class UuidIdStrategy implements StringIdStrategy {
-    primaryKeyType: 'uuid';
+    readonly primaryKeyType = 'uuid';
     decodeId(id: string): string {
         return id;
     }

+ 3 - 1
server/src/entity/base/base.entity.ts

@@ -1,5 +1,6 @@
 import { CreateDateColumn, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
 import { DeepPartial, ID } from '../../common/common-types';
+import { getEntityIdStrategy } from '../../config/vendure-config';
 
 /**
  * This is the base class from which all entities inherit.
@@ -13,7 +14,8 @@ export abstract class VendureEntity {
         }
     }
 
-    @PrimaryGeneratedColumn('increment') id: ID;
+    @PrimaryGeneratedColumn(getEntityIdStrategy().primaryKeyType as any)
+    id: ID;
 
     @CreateDateColumn() createdAt: string;
 

+ 261 - 0
server/src/service/id-codec.service.spec.ts

@@ -0,0 +1,261 @@
+import { Test } from '@nestjs/testing';
+import { ConfigService } from './config.service';
+import { DECODED, ENCODED, MockConfigService } from './config.service.mock';
+import { IdCodecService } from './id-codec.service';
+
+describe('IdCodecService', () => {
+    let idCodecService: IdCodecService;
+    let configService: MockConfigService;
+
+    beforeEach(async () => {
+        const module = await Test.createTestingModule({
+            providers: [IdCodecService, { provide: ConfigService, useClass: MockConfigService }],
+        }).compile();
+
+        idCodecService = module.get(IdCodecService);
+        configService = module.get(ConfigService) as any;
+    });
+
+    describe('encode()', () => {
+        it('works with a string', () => {
+            const input = 'id';
+
+            const result = idCodecService.encode(input);
+            expect(result).toEqual(ENCODED);
+        });
+
+        it('works with a number', () => {
+            const input = 123;
+
+            const result = idCodecService.encode(input);
+            expect(result).toEqual(ENCODED);
+        });
+
+        it('works with simple entity', () => {
+            const input = { id: 'id', name: 'foo' };
+
+            const result = idCodecService.encode(input);
+            expect(result).toEqual({ id: ENCODED, name: 'foo' });
+        });
+
+        it('works with 2-level nested entities', () => {
+            const input = {
+                id: 'id',
+                friend: { id: 'id' },
+            };
+
+            const result = idCodecService.encode(input);
+            expect(result).toEqual({
+                id: ENCODED,
+                friend: { id: ENCODED },
+            });
+        });
+
+        it('works with 3-level nested entities', () => {
+            const input = {
+                id: 'id',
+                friend: {
+                    dog: { id: 'id' },
+                },
+            };
+
+            const result = idCodecService.encode(input);
+            expect(result).toEqual({
+                id: ENCODED,
+                friend: {
+                    dog: { id: ENCODED },
+                },
+            });
+        });
+
+        it('works with list of simple entities', () => {
+            const input = [{ id: 'id', name: 'foo' }, { id: 'id', name: 'bar' }];
+
+            const result = idCodecService.encode(input);
+            expect(result).toEqual([{ id: ENCODED, name: 'foo' }, { id: ENCODED, name: 'bar' }]);
+        });
+
+        it('does not throw with an empty list', () => {
+            const input = [];
+
+            const result = idCodecService.encode(input);
+            expect(() => idCodecService.encode(input)).not.toThrow();
+        });
+
+        it('works with nested list of simple entities', () => {
+            const input = {
+                items: [{ id: 'id', name: 'foo' }, { id: 'id', name: 'bar' }],
+            };
+
+            const result = idCodecService.encode(input);
+            expect(result).toEqual({
+                items: [{ id: ENCODED, name: 'foo' }, { id: ENCODED, name: 'bar' }],
+            });
+        });
+
+        it('works with large and nested list', () => {
+            const length = 100;
+            const input = {
+                items: Array.from({ length }).map(() => ({
+                    id: 'id',
+                    name: { bar: 'baz' },
+                    foo: 'yo',
+                    friends: [{ id: 'id', name: { first: 'boris', id: 'id' } }],
+                })),
+            };
+
+            const result = idCodecService.encode(input);
+            expect(result.items.length).toBe(length);
+            expect(result.items[0].id).toBe(ENCODED);
+            expect(result.items[0].friends[0].id).toBe(ENCODED);
+            expect(result.items[0].friends[0].name.id).toBe(ENCODED);
+        });
+
+        it('works with nested list of nested lists', () => {
+            const input = {
+                items: [
+                    {
+                        id: 'id',
+                        friends: [{ id: 'id' }, { id: 'id' }],
+                    },
+                    {
+                        id: 'id',
+                        friends: [{ id: 'id' }, { id: 'id' }],
+                    },
+                ],
+            };
+
+            const result = idCodecService.encode(input);
+            expect(result).toEqual({
+                items: [
+                    {
+                        id: ENCODED,
+                        friends: [{ id: ENCODED }, { id: ENCODED }],
+                    },
+                    {
+                        id: ENCODED,
+                        friends: [{ id: ENCODED }, { id: ENCODED }],
+                    },
+                ],
+            });
+        });
+    });
+
+    describe('decode()', () => {
+        it('works with a string', () => {
+            const input = 'id';
+
+            const result = idCodecService.decode(input);
+            expect(result).toEqual(DECODED);
+        });
+
+        it('works with a number', () => {
+            const input = 123;
+
+            const result = idCodecService.decode(input);
+            expect(result).toEqual(DECODED);
+        });
+
+        it('works with simple entity', () => {
+            const input = { id: 'id', name: 'foo' };
+
+            const result = idCodecService.decode(input);
+            expect(result).toEqual({ id: DECODED, name: 'foo' });
+        });
+
+        it('works with 2-level nested entities', () => {
+            const input = {
+                id: 'id',
+                friend: { id: 'id' },
+            };
+
+            const result = idCodecService.decode(input);
+            expect(result).toEqual({
+                id: DECODED,
+                friend: { id: DECODED },
+            });
+        });
+
+        it('works with 3-level nested entities', () => {
+            const input = {
+                id: 'id',
+                friend: {
+                    dog: { id: 'id' },
+                },
+            };
+
+            const result = idCodecService.decode(input);
+            expect(result).toEqual({
+                id: DECODED,
+                friend: {
+                    dog: { id: DECODED },
+                },
+            });
+        });
+
+        it('works with list of simple entities', () => {
+            const input = [{ id: 'id', name: 'foo' }, { id: 'id', name: 'bar' }];
+
+            const result = idCodecService.decode(input);
+            expect(result).toEqual([{ id: DECODED, name: 'foo' }, { id: DECODED, name: 'bar' }]);
+        });
+
+        it('works with nested list of simple entities', () => {
+            const input = {
+                items: [{ id: 'id', name: 'foo' }, { id: 'id', name: 'bar' }],
+            };
+
+            const result = idCodecService.decode(input);
+            expect(result).toEqual({
+                items: [{ id: DECODED, name: 'foo' }, { id: DECODED, name: 'bar' }],
+            });
+        });
+
+        it('works with large and nested list', () => {
+            const length = 100;
+            const input = {
+                items: Array.from({ length }).map(() => ({
+                    id: 'id',
+                    name: { bar: 'baz' },
+                    foo: 'yo',
+                    friends: [{ id: 'id', name: { first: 'boris', id: 'id' } }],
+                })),
+            };
+
+            const result = idCodecService.decode(input);
+            expect(result.items.length).toBe(length);
+            expect(result.items[0].id).toBe(DECODED);
+            expect(result.items[0].friends[0].id).toBe(DECODED);
+            expect(result.items[0].friends[0].name.id).toBe(DECODED);
+        });
+
+        it('works with nested list of nested lists', () => {
+            const input = {
+                items: [
+                    {
+                        id: 'id',
+                        friends: [{ id: 'id' }, { id: 'id' }],
+                    },
+                    {
+                        id: 'id',
+                        friends: [{ id: 'id' }, { id: 'id' }],
+                    },
+                ],
+            };
+
+            const result = idCodecService.decode(input);
+            expect(result).toEqual({
+                items: [
+                    {
+                        id: DECODED,
+                        friends: [{ id: DECODED }, { id: DECODED }],
+                    },
+                    {
+                        id: DECODED,
+                        friends: [{ id: DECODED }, { id: DECODED }],
+                    },
+                ],
+            });
+        });
+    });
+});

+ 91 - 0
server/src/service/id-codec.service.ts

@@ -0,0 +1,91 @@
+import { Injectable } from '@nestjs/common';
+import { ID, PaginatedList } from '../common/common-types';
+import { VendureEntity } from '../entity/base/base.entity';
+import { ConfigService } from './config.service';
+
+/**
+ * This service is responsible for encoding/decoding entity IDs according to the configured EntityIdStrategy.
+ * It should only need to be used in resolvers - the design is that once a request hits the business logic layer
+ * (ProductService etc) all entity IDs are in the form used as the primary key in the database.
+ */
+@Injectable()
+export class IdCodecService {
+    constructor(private configService: ConfigService) {}
+
+    /**
+     * Decode an id from the client into the format used as the database primary key.
+     * Acts recursively on all objects containing an "id" property.
+     */
+    decode<T extends string | number | object | undefined>(target: T): T {
+        return this.transformRecursive(target, input => this.configService.entityIdStrategy.decodeId(input));
+    }
+
+    /**
+     * Encode any entity ids according to the encode.
+     * Acts recursively on all objects containing an "id" property.
+     */
+    encode<T extends string | number | object | undefined>(target: T): T {
+        return this.transformRecursive(target, input => this.configService.entityIdStrategy.encodeId(input));
+    }
+
+    private transformRecursive<T>(target: T, transformFn: (input: any) => ID): T {
+        if (typeof target === 'string' || typeof target === 'number') {
+            return transformFn(target as any) as any;
+        }
+        this.transform(target, transformFn);
+
+        if (Array.isArray(target)) {
+            if (target.length === 0) {
+                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);
+                }
+            } else {
+                const length = target.length;
+                for (let i = 0; i < length; i++) {
+                    target[i] = this.transformRecursive(target[i], transformFn);
+                }
+            }
+        } else {
+            for (const key of Object.keys(target)) {
+                if (this.isObject(target[key])) {
+                    this.transformRecursive(target[key], transformFn);
+                }
+            }
+        }
+        return target;
+    }
+
+    private transform<T>(target: T, transformFn: (input: any) => ID): T {
+        if (this.isEntity(target)) {
+            target.id = transformFn(target.id);
+        }
+        return target;
+    }
+
+    private isSimpleObject(target: any): boolean {
+        const values = Object.values(target);
+        for (const value of values) {
+            if (this.isObject(value)) {
+                return false;
+            }
+        }
+        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;
+    }
+}