Browse Source

refactor(core): Use schema introspection for entity ID encoding/decoding

Rather than needing to specify which fields need to be decoded via the `@Decode()` decorator, we now introspect the GraphQL schema to query the type of each field in the input & output of an operation. This solves issues such as #131 and also does away with the need to manually keep track of which fields need encoding & decoding.

Closes #131
Michael Bromley 6 years ago
parent
commit
8c02cf683e

+ 2 - 4
admin-ui/src/app/common/generated-types.ts

@@ -2938,8 +2938,8 @@ export type Sale = Node & StockMovement & {
 
 export type SearchInput = {
   term?: Maybe<Scalars['String']>,
-  facetValueIds?: Maybe<Array<Scalars['String']>>,
-  collectionId?: Maybe<Scalars['String']>,
+  facetValueIds?: Maybe<Array<Scalars['ID']>>,
+  collectionId?: Maybe<Scalars['ID']>,
   groupByProduct?: Maybe<Scalars['Boolean']>,
   take?: Maybe<Scalars['Int']>,
   skip?: Maybe<Scalars['Int']>,
@@ -2949,8 +2949,6 @@ export type SearchInput = {
 export type SearchReindexResponse = {
   __typename?: 'SearchReindexResponse',
   success: Scalars['Boolean'],
-  timeTaken: Scalars['Int'],
-  indexedItemCount: Scalars['Int'],
 };
 
 export type SearchResponse = {

+ 184 - 12
packages/core/e2e/entity-id-strategy.e2e-spec.ts

@@ -3,44 +3,216 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { EntityIdTest } from './graphql/generated-e2e-admin-types';
-import { TestShopClient } from './test-client';
+import {
+    IdTest1,
+    IdTest2,
+    IdTest3,
+    IdTest4,
+    IdTest5,
+    IdTest6,
+    IdTest7,
+    IdTest8,
+    LanguageCode,
+} from './graphql/generated-e2e-admin-types';
+import { TestAdminClient, TestShopClient } from './test-client';
 import { TestServer } from './test-server';
 
 describe('EntityIdStrategy', () => {
     const shopClient = new TestShopClient();
+    const adminClient = new TestAdminClient();
     const server = new TestServer();
 
     beforeAll(async () => {
         await server.init({
-            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
             customerCount: 1,
         });
         await shopClient.init();
+        await adminClient.init();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
         await server.destroy();
     });
 
+    it('encodes ids', async () => {
+        const { products } = await shopClient.query<IdTest1.Query>(gql`
+            query IdTest1 {
+                products(options: { take: 5 }) {
+                    items {
+                        id
+                    }
+                }
+            }
+        `);
+        expect(products).toEqual({
+            items: [{ id: 'T_1' }, { id: 'T_2' }, { id: 'T_3' }, { id: 'T_4' }, { id: 'T_5' }],
+        });
+    });
+
     it('Does not doubly-encode ids from resolved properties', async () => {
-        const result = await shopClient.query<EntityIdTest.Query>(gql`
-            query EntityIdTest {
+        const { products } = await shopClient.query<IdTest2.Query>(gql`
+            query IdTest2 {
+                products(options: { take: 1 }) {
+                    items {
+                        id
+                        variants {
+                            id
+                            options {
+                                id
+                                name
+                            }
+                        }
+                    }
+                }
+            }
+        `);
+
+        expect(products.items[0].id).toBe('T_1');
+        expect(products.items[0].variants[0].id).toBe('T_1');
+        expect(products.items[0].variants[0].options[0].id).toBe('T_1');
+    });
+
+    it('decodes embedded argument', async () => {
+        const { product } = await shopClient.query<IdTest3.Query>(gql`
+            query IdTest3 {
                 product(id: "T_1") {
                     id
-                    variants {
+                }
+            }
+        `);
+        expect(product).toEqual({
+            id: 'T_1',
+        });
+    });
+
+    it('decodes embedded nested id', async () => {
+        const { updateProduct } = await adminClient.query<IdTest4.Mutation>(gql`
+            mutation IdTest4 {
+                updateProduct(input: { id: "T_1", featuredAssetId: "T_3" }) {
+                    id
+                    featuredAsset {
+                        id
+                    }
+                }
+            }
+        `);
+        expect(updateProduct).toEqual({
+            id: 'T_1',
+            featuredAsset: {
+                id: 'T_3',
+            },
+        });
+    });
+
+    it('decodes embedded nested object id', async () => {
+        const { updateProduct } = await adminClient.query<IdTest5.Mutation>(gql`
+            mutation IdTest5 {
+                updateProduct(
+                    input: { id: "T_1", translations: [{ id: "T_1", languageCode: en, name: "changed" }] }
+                ) {
+                    id
+                    name
+                }
+            }
+        `);
+        expect(updateProduct).toEqual({
+            id: 'T_1',
+            name: 'changed',
+        });
+    });
+
+    it('decodes argument as variable', async () => {
+        const { product } = await shopClient.query<IdTest6.Query, IdTest6.Variables>(
+            gql`
+                query IdTest6($id: ID!) {
+                    product(id: $id) {
+                        id
+                    }
+                }
+            `,
+            { id: 'T_1' },
+        );
+        expect(product).toEqual({
+            id: 'T_1',
+        });
+    });
+
+    it('decodes nested id as variable', async () => {
+        const { updateProduct } = await adminClient.query<IdTest7.Mutation, IdTest7.Variables>(
+            gql`
+                mutation IdTest7($input: UpdateProductInput!) {
+                    updateProduct(input: $input) {
                         id
-                        options {
+                        featuredAsset {
                             id
-                            name
                         }
                     }
                 }
+            `,
+            {
+                input: {
+                    id: 'T_1',
+                    featuredAssetId: 'T_2',
+                },
+            },
+        );
+        expect(updateProduct).toEqual({
+            id: 'T_1',
+            featuredAsset: {
+                id: 'T_2',
+            },
+        });
+    });
+
+    it('decodes nested object id as variable', async () => {
+        const { updateProduct } = await adminClient.query<IdTest8.Mutation, IdTest8.Variables>(
+            gql`
+                mutation IdTest8($input: UpdateProductInput!) {
+                    updateProduct(input: $input) {
+                        id
+                        name
+                    }
+                }
+            `,
+            {
+                input: {
+                    id: 'T_1',
+                    translations: [{ id: 'T_1', languageCode: LanguageCode.en, name: 'changed again' }],
+                },
+            },
+        );
+        expect(updateProduct).toEqual({
+            id: 'T_1',
+            name: 'changed again',
+        });
+    });
+
+    it('encodes ids in fragment', async () => {
+        const { products } = await shopClient.query<IdTest1.Query>(gql`
+            query IdTest9 {
+                products(options: { take: 1 }) {
+                    items {
+                        ...ProdFragment
+                    }
+                }
+            }
+            fragment ProdFragment on Product {
+                id
+                featuredAsset {
+                    id
+                }
             }
         `);
-
-        expect(result.product!.id).toBe('T_1');
-        expect(result.product!.variants[0].id).toBe('T_1');
-        expect(result.product!.variants[0].options[0].id).toBe('T_1');
+        expect(products).toEqual({
+            items: [
+                {
+                    id: 'T_1',
+                    featuredAsset: {
+                        id: 'T_2',
+                    },
+                },
+            ],
+        });
     });
 });

+ 123 - 21
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -2814,8 +2814,8 @@ export type Sale = Node &
 
 export type SearchInput = {
     term?: Maybe<Scalars['String']>;
-    facetValueIds?: Maybe<Array<Scalars['String']>>;
-    collectionId?: Maybe<Scalars['String']>;
+    facetValueIds?: Maybe<Array<Scalars['ID']>>;
+    collectionId?: Maybe<Scalars['ID']>;
     groupByProduct?: Maybe<Scalars['Boolean']>;
     take?: Maybe<Scalars['Int']>;
     skip?: Maybe<Scalars['Int']>;
@@ -3570,20 +3570,76 @@ export type SearchGetPricesQuery = { __typename?: 'Query' } & {
     };
 };
 
-export type EntityIdTestQueryVariables = {};
+export type IdTest1QueryVariables = {};
 
-export type EntityIdTestQuery = { __typename?: 'Query' } & {
-    product: Maybe<
-        { __typename?: 'Product' } & Pick<Product, 'id'> & {
-                variants: Array<
-                    { __typename?: 'ProductVariant' } & Pick<ProductVariant, 'id'> & {
-                            options: Array<
-                                { __typename?: 'ProductOption' } & Pick<ProductOption, 'id' | 'name'>
-                            >;
-                        }
-                >;
-            }
-    >;
+export type IdTest1Query = { __typename?: 'Query' } & {
+    products: { __typename?: 'ProductList' } & {
+        items: Array<{ __typename?: 'Product' } & Pick<Product, 'id'>>;
+    };
+};
+
+export type IdTest2QueryVariables = {};
+
+export type IdTest2Query = { __typename?: 'Query' } & {
+    products: { __typename?: 'ProductList' } & {
+        items: Array<
+            { __typename?: 'Product' } & Pick<Product, 'id'> & {
+                    variants: Array<
+                        { __typename?: 'ProductVariant' } & Pick<ProductVariant, 'id'> & {
+                                options: Array<
+                                    { __typename?: 'ProductOption' } & Pick<ProductOption, 'id' | 'name'>
+                                >;
+                            }
+                    >;
+                }
+        >;
+    };
+};
+
+export type IdTest3QueryVariables = {};
+
+export type IdTest3Query = { __typename?: 'Query' } & {
+    product: Maybe<{ __typename?: 'Product' } & Pick<Product, 'id'>>;
+};
+
+export type IdTest4MutationVariables = {};
+
+export type IdTest4Mutation = { __typename?: 'Mutation' } & {
+    updateProduct: { __typename?: 'Product' } & Pick<Product, 'id'> & {
+            featuredAsset: Maybe<{ __typename?: 'Asset' } & Pick<Asset, 'id'>>;
+        };
+};
+
+export type IdTest5MutationVariables = {};
+
+export type IdTest5Mutation = { __typename?: 'Mutation' } & {
+    updateProduct: { __typename?: 'Product' } & Pick<Product, 'id' | 'name'>;
+};
+
+export type IdTest6QueryVariables = {
+    id: Scalars['ID'];
+};
+
+export type IdTest6Query = { __typename?: 'Query' } & {
+    product: Maybe<{ __typename?: 'Product' } & Pick<Product, 'id'>>;
+};
+
+export type IdTest7MutationVariables = {
+    input: UpdateProductInput;
+};
+
+export type IdTest7Mutation = { __typename?: 'Mutation' } & {
+    updateProduct: { __typename?: 'Product' } & Pick<Product, 'id'> & {
+            featuredAsset: Maybe<{ __typename?: 'Asset' } & Pick<Asset, 'id'>>;
+        };
+};
+
+export type IdTest8MutationVariables = {
+    input: UpdateProductInput;
+};
+
+export type IdTest8Mutation = { __typename?: 'Mutation' } & {
+    updateProduct: { __typename?: 'Product' } & Pick<Product, 'id' | 'name'>;
 };
 
 export type GetFacetWithValuesQueryVariables = {
@@ -4938,16 +4994,62 @@ export namespace SearchGetPrices {
     >;
 }
 
-export namespace EntityIdTest {
-    export type Variables = EntityIdTestQueryVariables;
-    export type Query = EntityIdTestQuery;
-    export type Product = NonNullable<EntityIdTestQuery['product']>;
-    export type Variants = NonNullable<(NonNullable<EntityIdTestQuery['product']>)['variants'][0]>;
+export namespace IdTest1 {
+    export type Variables = IdTest1QueryVariables;
+    export type Query = IdTest1Query;
+    export type Products = IdTest1Query['products'];
+    export type Items = NonNullable<IdTest1Query['products']['items'][0]>;
+}
+
+export namespace IdTest2 {
+    export type Variables = IdTest2QueryVariables;
+    export type Query = IdTest2Query;
+    export type Products = IdTest2Query['products'];
+    export type Items = NonNullable<IdTest2Query['products']['items'][0]>;
+    export type Variants = NonNullable<(NonNullable<IdTest2Query['products']['items'][0]>)['variants'][0]>;
     export type Options = NonNullable<
-        (NonNullable<(NonNullable<EntityIdTestQuery['product']>)['variants'][0]>)['options'][0]
+        (NonNullable<(NonNullable<IdTest2Query['products']['items'][0]>)['variants'][0]>)['options'][0]
     >;
 }
 
+export namespace IdTest3 {
+    export type Variables = IdTest3QueryVariables;
+    export type Query = IdTest3Query;
+    export type Product = NonNullable<IdTest3Query['product']>;
+}
+
+export namespace IdTest4 {
+    export type Variables = IdTest4MutationVariables;
+    export type Mutation = IdTest4Mutation;
+    export type UpdateProduct = IdTest4Mutation['updateProduct'];
+    export type FeaturedAsset = NonNullable<IdTest4Mutation['updateProduct']['featuredAsset']>;
+}
+
+export namespace IdTest5 {
+    export type Variables = IdTest5MutationVariables;
+    export type Mutation = IdTest5Mutation;
+    export type UpdateProduct = IdTest5Mutation['updateProduct'];
+}
+
+export namespace IdTest6 {
+    export type Variables = IdTest6QueryVariables;
+    export type Query = IdTest6Query;
+    export type Product = NonNullable<IdTest6Query['product']>;
+}
+
+export namespace IdTest7 {
+    export type Variables = IdTest7MutationVariables;
+    export type Mutation = IdTest7Mutation;
+    export type UpdateProduct = IdTest7Mutation['updateProduct'];
+    export type FeaturedAsset = NonNullable<IdTest7Mutation['updateProduct']['featuredAsset']>;
+}
+
+export namespace IdTest8 {
+    export type Variables = IdTest8MutationVariables;
+    export type Mutation = IdTest8Mutation;
+    export type UpdateProduct = IdTest8Mutation['updateProduct'];
+}
+
 export namespace GetFacetWithValues {
     export type Variables = GetFacetWithValuesQueryVariables;
     export type Query = GetFacetWithValuesQuery;

+ 2 - 2
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -1942,8 +1942,8 @@ export type Sale = Node &
 
 export type SearchInput = {
     term?: Maybe<Scalars['String']>;
-    facetValueIds?: Maybe<Array<Scalars['String']>>;
-    collectionId?: Maybe<Scalars['String']>;
+    facetValueIds?: Maybe<Array<Scalars['ID']>>;
+    collectionId?: Maybe<Scalars['ID']>;
     groupByProduct?: Maybe<Scalars['Boolean']>;
     take?: Maybe<Scalars['Int']>;
     skip?: Maybe<Scalars['Int']>;

+ 236 - 0
packages/core/src/api/common/graphql-value-transformer.ts

@@ -0,0 +1,236 @@
+import {
+    ASTKindToNode,
+    DocumentNode,
+    getNamedType,
+    GraphQLInputObjectType,
+    GraphQLNamedType,
+    GraphQLSchema,
+    isInputObjectType,
+    isListType,
+    isNonNullType,
+    OperationDefinitionNode,
+    TypeInfo,
+    visit,
+    Visitor,
+    visitWithTypeInfo,
+} from 'graphql';
+
+export type TypeTree = {
+    operation: TypeTreeNode;
+    fragments: { [name: string]: TypeTreeNode };
+};
+
+/**
+ * Represents a GraphQLNamedType which pertains to an input variables object or an output.
+ * Used when traversing the data object in order to provide the type for the field
+ * being visited.
+ */
+export type TypeTreeNode = {
+    type: GraphQLNamedType | undefined;
+    parent: TypeTreeNode | TypeTree;
+    isList: boolean;
+    fragmentRefs: string[];
+    children: { [name: string]: TypeTreeNode };
+};
+
+/**
+ * This class is used to transform the values of input variables or an output object.
+ */
+export class GraphqlValueTransformer {
+    constructor(private schema: GraphQLSchema) {}
+
+    /**
+     * Transforms the values in the `data` object into the return value of the `visitorFn`.
+     */
+    transformValues(
+        typeTree: TypeTree,
+        data: Record<string, any>,
+        visitorFn: (value: any, type: GraphQLNamedType) => any,
+    ) {
+        this.traverse(data, (key, value, path) => {
+            const typeTreeNode = this.getTypeNodeByPath(typeTree, path);
+            const type = (typeTreeNode && typeTreeNode.type) as GraphQLNamedType;
+            return visitorFn(value, type);
+        });
+    }
+
+    /**
+     * Constructs a tree of TypeTreeNodes for the output of a GraphQL operation.
+     */
+    getOutputTypeTree(document: DocumentNode): TypeTree {
+        const typeInfo = new TypeInfo(this.schema);
+        const typeTree: TypeTree = {
+            operation: {} as any,
+            fragments: {},
+        };
+        const rootNode: TypeTreeNode = {
+            type: undefined,
+            isList: false,
+            parent: typeTree,
+            fragmentRefs: [],
+            children: {},
+        };
+        typeTree.operation = rootNode;
+        let currentNode = rootNode;
+        const visitor: Visitor<ASTKindToNode> = {
+            enter: node => {
+                const type = typeInfo.getType();
+                const fieldDef = typeInfo.getFieldDef();
+                if (node.kind === 'Field') {
+                    const newNode: TypeTreeNode = {
+                        type: (type && getNamedType(type)) || undefined,
+                        isList: this.isList(type),
+                        fragmentRefs: [],
+                        parent: currentNode,
+                        children: {},
+                    };
+                    currentNode.children[fieldDef.name] = newNode;
+                    currentNode = newNode;
+                }
+                if (node.kind === 'FragmentSpread') {
+                    currentNode.fragmentRefs.push(node.name.value);
+                }
+                if (node.kind === 'FragmentDefinition') {
+                    const rootFragmentNode: TypeTreeNode = {
+                        type: undefined,
+                        isList: false,
+                        fragmentRefs: [],
+                        parent: typeTree,
+                        children: {},
+                    };
+                    currentNode = rootFragmentNode;
+                    typeTree.fragments[node.name.value] = rootFragmentNode;
+                }
+            },
+            leave: node => {
+                if (node.kind === 'Field') {
+                    if (!this.isTypeTree(currentNode.parent)) {
+                        currentNode = currentNode.parent;
+                    }
+                }
+            },
+        };
+        for (const operation of document.definitions) {
+            visit(operation, visitWithTypeInfo(typeInfo, visitor));
+        }
+        return typeTree;
+    }
+
+    /**
+     * Constructs a tree of TypeTreeNodes for the input variables of a GraphQL operation.
+     */
+    getInputTypeTree(definition: OperationDefinitionNode): TypeTree {
+        const typeInfo = new TypeInfo(this.schema);
+        const typeTree: TypeTree = {
+            operation: {} as any,
+            fragments: {},
+        };
+        const rootNode: TypeTreeNode = {
+            type: undefined,
+            isList: false,
+            parent: typeTree,
+            fragmentRefs: [],
+            children: {},
+        };
+        typeTree.operation = rootNode;
+        let currentNode = rootNode;
+        const visitor: Visitor<ASTKindToNode> = {
+            enter: node => {
+                if (node.kind === 'Argument') {
+                    const type = typeInfo.getType();
+                    const args = typeInfo.getArgument();
+                    if (args) {
+                        const inputType = getNamedType(args.type);
+                        const newNode: TypeTreeNode = {
+                            type: inputType || undefined,
+                            isList: this.isList(type),
+                            parent: currentNode,
+                            fragmentRefs: [],
+                            children: {},
+                        };
+                        currentNode.children[args.name] = newNode;
+                        if (isInputObjectType(inputType)) {
+                            if (isInputObjectType(inputType)) {
+                                newNode.children = this.getChildrenTreeNodes(inputType, newNode);
+                            }
+                        }
+                        currentNode = newNode;
+                    }
+                }
+            },
+            leave: node => {
+                if (node.kind === 'Argument') {
+                    if (!this.isTypeTree(currentNode.parent)) {
+                        currentNode = currentNode.parent;
+                    }
+                }
+            },
+        };
+        visit(definition, visitWithTypeInfo(typeInfo, visitor));
+        return typeTree;
+    }
+
+    private getChildrenTreeNodes(
+        inputType: GraphQLInputObjectType,
+        parent: TypeTreeNode,
+    ): { [name: string]: TypeTreeNode } {
+        return Object.entries(inputType.getFields()).reduce(
+            (result, [key, field]) => {
+                const namedType = getNamedType(field.type);
+                const child: TypeTreeNode = {
+                    type: namedType,
+                    isList: this.isList(field.type),
+                    parent,
+                    fragmentRefs: [],
+                    children: {},
+                };
+                if (isInputObjectType(namedType)) {
+                    child.children = this.getChildrenTreeNodes(namedType, child);
+                }
+                return { ...result, [key]: child };
+            },
+            {} as { [name: string]: TypeTreeNode },
+        );
+    }
+
+    private isList(t: any): boolean {
+        return isListType(t) || (isNonNullType(t) && isListType(t.ofType));
+    }
+
+    private getTypeNodeByPath(typeTree: TypeTree, path: Array<string | number>): TypeTreeNode | undefined {
+        let targetNode: TypeTreeNode | undefined = typeTree.operation;
+        for (const segment of path) {
+            if (Number.isNaN(Number.parseInt(segment as string, 10))) {
+                if (targetNode) {
+                    let children: { [name: string]: TypeTreeNode } = targetNode.children;
+                    if (targetNode.fragmentRefs.length) {
+                        for (const ref of targetNode.fragmentRefs) {
+                            children = { ...children, ...typeTree.fragments[ref].children };
+                        }
+                    }
+                    targetNode = children[segment];
+                }
+            }
+        }
+        return targetNode;
+    }
+
+    private traverse(
+        o: { [key: string]: any },
+        visitorFn: (key: string, value: any, path: Array<string | number>) => any,
+        path: Array<string | number> = [],
+    ) {
+        for (const key of Object.keys(o)) {
+            path.push(key);
+            o[key] = visitorFn(key, o[key], path);
+            if (o[key] !== null && typeof o[key] === 'object') {
+                this.traverse(o[key], visitorFn, path);
+            }
+            path.pop();
+        }
+    }
+
+    private isTypeTree(input: TypeTree | TypeTreeNode): input is TypeTree {
+        return input.hasOwnProperty('fragments');
+    }
+}

+ 1 - 11
packages/core/src/api/common/id-codec.ts

@@ -3,17 +3,7 @@ import { ID } from '@vendure/common/lib/shared-types';
 import { EntityIdStrategy } from '../../config/entity-id-strategy/entity-id-strategy';
 import { VendureEntity } from '../../entity/base/base.entity';
 
-const ID_KEYS = [
-    'id',
-    'productId',
-    'productVariantId',
-    'collectionIds',
-    'paymentId',
-    'fulfillmentId',
-    'orderItemIds',
-    'refundId',
-    'groupId',
-];
+const ID_KEYS = ['id'];
 
 /**
  * This service is responsible for encoding/decoding entity IDs according to the configured EntityIdStrategy.

+ 4 - 6
packages/core/src/api/config/configure-graphql-module.ts

@@ -16,7 +16,7 @@ import { getDynamicGraphQlModulesForPlugins } from '../../plugin/dynamic-plugin-
 import { getPluginAPIExtensions } from '../../plugin/plugin-metadata';
 import { ApiSharedModule } from '../api-internal-modules';
 import { IdCodecService } from '../common/id-codec.service';
-import { IdEncoderExtension } from '../middleware/id-encoder-extension';
+import { IdCodecPlugin } from '../middleware/id-codec-plugin';
 import { TranslateErrorExtension } from '../middleware/translate-errors-extension';
 
 import { generateListOptions } from './generate-list-options';
@@ -139,13 +139,11 @@ async function createGraphQLOptions(
         },
         debug: true,
         context: (req: any) => req,
-        extensions: [
-            () => new TranslateErrorExtension(i18nService),
-            () => new IdEncoderExtension(idCodecService),
-        ],
+        extensions: [() => new TranslateErrorExtension(i18nService)],
         // This is handled by the Express cors plugin
         cors: false,
-    };
+        plugins: [new IdCodecPlugin(idCodecService)],
+    } as GqlModuleOptions;
 
     /**
      * Generates the server's GraphQL schema by combining:

+ 51 - 0
packages/core/src/api/middleware/id-codec-plugin.ts

@@ -0,0 +1,51 @@
+import { ApolloServerPlugin, GraphQLRequestListener, GraphQLServiceContext } from 'apollo-server-plugin-base';
+import { DocumentNode, OperationDefinitionNode } from 'graphql';
+
+import { GraphqlValueTransformer } from '../common/graphql-value-transformer';
+import { IdCodecService } from '../common/id-codec.service';
+
+/**
+ * Encodes the ids of outgoing responses according to the configured EntityIdStrategy.
+ *
+ * This is done here and not via a Nest Interceptor because it's not possible
+ * according to https://github.com/nestjs/graphql/issues/320
+ */
+export class IdCodecPlugin implements ApolloServerPlugin {
+    private graphqlValueTransformer: GraphqlValueTransformer;
+    constructor(private idCodecService: IdCodecService) {}
+
+    serverWillStart(service: GraphQLServiceContext): Promise<void> | void {
+        this.graphqlValueTransformer = new GraphqlValueTransformer(service.schema);
+    }
+
+    requestDidStart(): GraphQLRequestListener {
+        return {
+            willSendResponse: requestContext => {
+                const { document } = requestContext;
+                if (document) {
+                    const data = requestContext.response.data;
+                    if (data) {
+                        this.encodeIdFields(document, data);
+                    }
+                }
+            },
+        };
+    }
+
+    private encodeIdFields(document: DocumentNode, data: Record<string, any>) {
+        const typeTree = this.graphqlValueTransformer.getOutputTypeTree(document);
+        this.graphqlValueTransformer.transformValues(typeTree, data, (value, type) => {
+            const isIdType = type && type.name === 'ID';
+            if (type && type.name === 'JSON') {
+                return this.idCodecService.encode(value, [
+                    'paymentId',
+                    'fulfillmentId',
+                    'orderItemIds',
+                    'refundId',
+                    'groupId',
+                ]);
+            }
+            return isIdType ? this.idCodecService.encode(value) : value;
+        });
+    }
+}

+ 0 - 29
packages/core/src/api/middleware/id-encoder-extension.ts

@@ -1,29 +0,0 @@
-import { Response } from 'express-serve-static-core';
-import { GraphQLResolveInfo } from 'graphql';
-import { GraphQLExtension, GraphQLResponse } from 'graphql-extensions';
-
-import { I18nRequest, I18nService } from '../../i18n/i18n.service';
-import { IdCodecService } from '../common/id-codec.service';
-
-/**
- * Encodes the ids of outgoing responses according to the configured EntityIdStrategy.
- *
- * This is done here and not via a Nest Interceptor because we only need to do the
- * encoding once, just before the response is sent. Doing the encoding in an interceptor's
- * `intercept()` method causes the encoding to be performed once for each GraphQL
- * property resolver in the hierarchy.
- */
-export class IdEncoderExtension implements GraphQLExtension {
-    constructor(private idCodecService: IdCodecService) {}
-
-    willSendResponse(o: {
-        graphqlResponse: GraphQLResponse;
-        context: { req: I18nRequest; res: Response };
-    }): void | {
-        graphqlResponse: GraphQLResponse;
-        context: { req: I18nRequest; res: Response };
-    } {
-        o.graphqlResponse.data = this.idCodecService.encode(o.graphqlResponse.data);
-        return o;
-    }
-}

+ 29 - 12
packages/core/src/api/middleware/id-interceptor.ts

@@ -1,11 +1,20 @@
 import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
-import { Reflector } from '@nestjs/core';
 import { GqlExecutionContext } from '@nestjs/graphql';
+import { VariableValues } from 'apollo-server-core';
+import { GraphQLNamedType, OperationDefinitionNode } from 'graphql';
 import { Observable } from 'rxjs';
 
+import { GraphqlValueTransformer } from '../common/graphql-value-transformer';
 import { IdCodecService } from '../common/id-codec.service';
 import { parseContext } from '../common/parse-context';
-import { DECODE_METADATA_KEY } from '../decorators/decode.decorator';
+
+export const ID_CODEC_TRANSFORM_KEYS = 'idCodecTransformKeys';
+type TypeTreeNode = {
+    type: GraphQLNamedType | undefined;
+    parent: TypeTreeNode | null;
+    isList: boolean;
+    children: { [name: string]: TypeTreeNode };
+};
 
 /**
  * This interceptor automatically decodes incoming requests so that any
@@ -16,21 +25,29 @@ import { DECODE_METADATA_KEY } from '../decorators/decode.decorator';
  */
 @Injectable()
 export class IdInterceptor implements NestInterceptor {
-    constructor(private idCodecService: IdCodecService, private readonly reflector: Reflector) {}
+    constructor(private idCodecService: IdCodecService) {}
 
     intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
-        const { isGraphQL } = parseContext(context);
+        const { isGraphQL, req } = parseContext(context);
         if (isGraphQL) {
             const args = GqlExecutionContext.create(context).getArgs();
-            const transformKeys = this.reflector.get<string[]>(DECODE_METADATA_KEY, context.getHandler());
-            const gqlRoot = context.getArgByIndex(0);
-            if (!gqlRoot) {
-                // Only need to decode ids if this is a root query/mutation.
-                // Internal (property-resolver) requests can then be assumed to
-                // be already decoded.
-                Object.assign(args, this.idCodecService.decode(args, transformKeys));
-            }
+            const info = GqlExecutionContext.create(context).getInfo();
+            const graphqlValueTransformer = new GraphqlValueTransformer(info.schema);
+            this.decodeIdArguments(graphqlValueTransformer, info.operation, args);
         }
         return next.handle();
     }
+
+    private decodeIdArguments(
+        graphqlValueTransformer: GraphqlValueTransformer,
+        definition: OperationDefinitionNode,
+        variables: VariableValues = {},
+    ) {
+        const typeTree = graphqlValueTransformer.getInputTypeTree(definition);
+        graphqlValueTransformer.transformValues(typeTree, variables, (value, type) => {
+            const isIdType = type && type.name === 'ID';
+            return isIdType ? this.idCodecService.decode(value) : value;
+        });
+        return variables;
+    }
 }

+ 3 - 3
packages/core/src/api/schema/type/product-search.type.graphql

@@ -30,10 +30,10 @@ type SearchResult {
     priceWithTax: SearchResultPrice!
     currencyCode: CurrencyCode!
     description: String!
-    facetIds: [String!]!
-    facetValueIds: [String!]!
+    facetIds: [ID!]!
+    facetValueIds: [ID!]!
     "An array of ids of the Collections in which this result appears"
-    collectionIds: [String!]!
+    collectionIds: [ID!]!
     "A relevence score for the result. Differs between database implementations"
     score: Float!
 }