Переглянути джерело

feat(core): Add API option to configure Apollo server cache

David Höck 4 місяців тому
батько
коміт
cb28c51fba

+ 239 - 0
packages/core/e2e/apollo-cache.e2e-spec.ts

@@ -0,0 +1,239 @@
+import { KeyValueCache } from '@apollo/utils.keyvaluecache';
+import { mergeConfig } from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+class MockCache implements KeyValueCache<string> {
+    static getCalls: Array<{ key: string; result: string | undefined }> = [];
+    static setCalls: Array<{ key: string; value: string; options?: { ttl?: number } }> = [];
+    static deleteCalls: Array<{ key: string; result: boolean }> = [];
+
+    private store = new Map<string, string>();
+
+    static reset() {
+        this.getCalls = [];
+        this.setCalls = [];
+        this.deleteCalls = [];
+    }
+
+    async get(key: string): Promise<string | undefined> {
+        // eslint-disable-next-line
+        console.log(`MockCache get: ${key}`);
+        const result = this.store.get(key);
+        MockCache.getCalls.push({ key, result });
+        return result;
+    }
+
+    async set(key: string, value: string, options?: { ttl?: number }): Promise<void> {
+        // eslint-disable-next-line
+        console.log(`MockCache set: ${key}`, value);
+        this.store.set(key, value);
+        MockCache.setCalls.push({ key, value, options });
+    }
+
+    async delete(key: string): Promise<boolean> {
+        const result = this.store.delete(key);
+        MockCache.deleteCalls.push({ key, result });
+        return result;
+    }
+}
+
+describe('Apollo cache configuration', () => {
+    describe('with custom cache implementation', () => {
+        const mockCache = new MockCache();
+        const { server, adminClient, shopClient } = createTestEnvironment(
+            mergeConfig(testConfig(), {
+                apiOptions: {
+                    cache: mockCache,
+                },
+            }),
+        );
+
+        beforeAll(async () => {
+            await server.init({
+                initialData,
+                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+                customerCount: 1,
+            });
+            await adminClient.asSuperAdmin();
+        }, TEST_SETUP_TIMEOUT_MS);
+
+        afterAll(async () => {
+            await server.destroy();
+        });
+
+        it('should configure Apollo Server with custom cache', async () => {
+            MockCache.reset();
+
+            // Make a GraphQL query that could potentially be cached
+            const result = await shopClient.query(gql`
+                query GetProduct {
+                    product(id: "T_1") {
+                        id
+                        name
+                        slug
+                    }
+                }
+            `);
+
+            expect(result.product).toBeDefined();
+            expect(result.product.id).toBe('T_1');
+            expect(result.product.name).toBe('Laptop');
+        });
+
+        it('should handle cache operations without errors', async () => {
+            MockCache.reset();
+
+            // Test multiple queries to potentially trigger cache operations
+            await shopClient.query(gql`
+                query GetProducts {
+                    products(options: { take: 5 }) {
+                        items {
+                            id
+                            name
+                        }
+                    }
+                }
+            `);
+
+            await adminClient.query(gql`
+                query GetProducts {
+                    products(options: { take: 3 }) {
+                        items {
+                            id
+                            name
+                            slug
+                        }
+                    }
+                }
+            `);
+
+            // The cache instance should be properly configured and accessible
+            // We don't verify specific cache calls as Apollo Server's internal
+            // caching behavior may vary, but we ensure no errors occur
+            expect(true).toBe(true); // Test passes if no errors thrown
+        });
+
+        it('should work with both shop and admin APIs', async () => {
+            MockCache.reset();
+
+            const [shopResult, adminResult] = await Promise.all([
+                shopClient.query(gql`
+                    query ShopQuery {
+                        product(id: "T_1") {
+                            id
+                            name
+                        }
+                    }
+                `),
+                adminClient.query(gql`
+                    query AdminQuery {
+                        product(id: "T_1") {
+                            id
+                            name
+                            slug
+                        }
+                    }
+                `),
+            ]);
+
+            expect(shopResult.product).toBeDefined();
+            expect(adminResult.product).toBeDefined();
+            expect(shopResult.product.id).toBe(adminResult.product.id);
+        });
+    });
+
+    describe('with bounded cache', () => {
+        const { server, adminClient, shopClient } = createTestEnvironment(
+            mergeConfig(testConfig(), {
+                apiOptions: {
+                    cache: 'bounded',
+                },
+            }),
+        );
+
+        beforeAll(async () => {
+            await server.init({
+                initialData,
+                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+                customerCount: 1,
+            });
+            await adminClient.asSuperAdmin();
+        }, TEST_SETUP_TIMEOUT_MS);
+
+        afterAll(async () => {
+            await server.destroy();
+        });
+
+        it('should configure Apollo Server with bounded cache', async () => {
+            const result = await shopClient.query(gql`
+                query GetProduct {
+                    product(id: "T_1") {
+                        id
+                        name
+                    }
+                }
+            `);
+
+            expect(result.product).toBeDefined();
+            expect(result.product.id).toBe('T_1');
+        });
+
+        it('should handle concurrent requests with bounded cache', async () => {
+            const queries = Array.from({ length: 5 }, (_, i) =>
+                shopClient.query(gql`
+                    query GetProduct${i} {
+                        product(id: "T_${i + 1}") {
+                            id
+                            name
+                        }
+                    }
+                `),
+            );
+
+            const results = await Promise.all(queries);
+
+            results.forEach((result, index) => {
+                if (result.product) {
+                    expect(result.product.id).toBe(`T_${index + 1}`);
+                }
+            });
+        });
+    });
+
+    describe('without cache configuration', () => {
+        const { server, adminClient, shopClient } = createTestEnvironment(testConfig());
+
+        beforeAll(async () => {
+            await server.init({
+                initialData,
+                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+                customerCount: 1,
+            });
+            await adminClient.asSuperAdmin();
+        }, TEST_SETUP_TIMEOUT_MS);
+
+        afterAll(async () => {
+            await server.destroy();
+        });
+
+        it('should work without cache configuration', async () => {
+            const result = await shopClient.query(gql`
+                query GetProduct {
+                    product(id: "T_1") {
+                        id
+                        name
+                    }
+                }
+            `);
+
+            expect(result.product).toBeDefined();
+            expect(result.product.id).toBe('T_1');
+        });
+    });
+});

+ 2 - 0
packages/core/src/api/config/configure-graphql-module.ts

@@ -85,6 +85,7 @@ async function createGraphQLOptions(
         new AssetInterceptorPlugin(configService),
         ...configService.apiOptions.apolloServerPlugins,
     ];
+
     // We only need to add the IdCodecPlugin if the user has configured
     // a non-default EntityIdStrategy. This is a performance optimization
     // that prevents unnecessary traversal of each response when no
@@ -113,6 +114,7 @@ async function createGraphQLOptions(
         context: (req: any) => req,
         // This is handled by the Express cors plugin
         cors: false,
+        cache: configService.apiOptions.cache,
         plugins: apolloServerPlugins,
         validationRules: options.validationRules,
         introspection: configService.apiOptions.introspection ?? true,

+ 1 - 0
packages/core/src/config/default-config.ts

@@ -91,6 +91,7 @@ export const defaultConfig: RuntimeVendureConfig = {
         middleware: [],
         introspection: true,
         apolloServerPlugins: [],
+        cache: 'bounded',
     },
     entityIdStrategy: new AutoIncrementIdStrategy(),
     authOptions: {

+ 10 - 0
packages/core/src/config/vendure-config.ts

@@ -1,4 +1,5 @@
 import { ApolloServerPlugin } from '@apollo/server';
+import type { KeyValueCache } from '@apollo/utils.keyvaluecache';
 import { RenderPageOptions } from '@apollographql/graphql-playground-html';
 import { DynamicModule, Type } from '@nestjs/common';
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
@@ -209,6 +210,15 @@ export interface ApiOptions {
      * @default []
      */
     apolloServerPlugins?: ApolloServerPlugin[];
+    /**
+     * @description
+     * Pass a [custom cache](https://www.apollographql.com/docs/apollo-server/performance/caching) for server-side caching to the Apollo server,
+     * which is the underlying GraphQL server used by Vendure.
+     *
+     * @default undefined
+     * @since 3.5.0
+     */
+    cache?: KeyValueCache<string> | 'bounded';
     /**
      * @description
      * Controls whether introspection of the GraphQL APIs is enabled. For production, it is recommended to disable