Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,4 @@ If you encounter any issues, please feel free to open an issue on the [GitHub re

## 🛠️ Contributing

Contributions are welcome! If you have suggestions or improvements, please open an issue or submit a pull request.
Contributions are welcome! If you have suggestions or improvements, please open an issue or submit a pull request to the `dev` branch.
51 changes: 30 additions & 21 deletions server/src/middlewares/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,25 @@ import { decodeBufferToText, decompressBuffer, streamToBuffer } from '../utils/b
import { getCacheHeaderConfig, getHeadersToStore } from '../utils/header';

const middleware = async (ctx: Context, next: any) => {
const cacheService = strapi.plugin('strapi-cache').services.service as CacheService;
const { url, method } = ctx.request;

if (method !== 'GET') {
return next();
}

const cacheableEntities = strapi.plugin('strapi-cache').config('cacheableEntities') as
| string[]
| undefined;
string[] | undefined;
const cacheableRoutes = strapi.plugin('strapi-cache').config('cacheableRoutes') as string[];
const excludeRoutes = strapi.plugin('strapi-cache').config('excludeRoutes') as string[];
const keyGenerator = strapi.plugin('strapi-cache').config('keyGenerator') as
| CacheKeyGenerator
| undefined;
const { cacheHeaders, cacheHeadersDenyList, cacheHeadersAllowList, cacheAuthorizedRequests } =
getCacheHeaderConfig();
const cacheStore = cacheService.getCacheInstance();
const { url } = ctx.request;
const key = generateCacheKey(ctx, keyGenerator);
const cacheEntry = await cacheStore.get(key);
const cacheControlHeader = ctx.request.headers['cache-control'];
const noCache = cacheControlHeader && cacheControlHeader.includes('no-cache');
const restApiPrefix = strapi.config.get('api.rest.prefix', '/api');
const entityKey = generateEntityKey(url, restApiPrefix);

const routeIsExcluded = excludeRoutes.some((route) => url.startsWith(route));

if (routeIsExcluded) {
loggy.info(`Route excluded from cache: ${url}`);
await next();
return;
return next();
}

const entityKey = generateEntityKey(url, restApiPrefix);
const entityIsCacheable = cacheableEntities?.length
? cacheableEntities.includes(entityKey)
: undefined;
Expand All @@ -43,14 +34,32 @@ const middleware = async (ctx: Context, next: any) => {
(cacheableRoutes.length === 0 && url.startsWith(restApiPrefix));
const isCacheable = entityIsCacheable ?? routeIsCacheable;

if (!isCacheable) {
return next();
}

const { cacheHeaders, cacheHeadersDenyList, cacheHeadersAllowList, cacheAuthorizedRequests } =
getCacheHeaderConfig();
const cacheControlHeader = ctx.request.headers['cache-control'];
const noCache = cacheControlHeader && cacheControlHeader.includes('no-cache');
const authorizationHeader = ctx.request.headers['authorization'];

if (authorizationHeader && !cacheAuthorizedRequests) {
loggy.info(`Authorized request bypassing cache: ${key}`);
await next();
return;
loggy.info(`Authorized request bypassing cache: ${url}`);
return next();
}

if (noCache) {
return next();
}

const cacheService = strapi.plugin('strapi-cache').services.service as CacheService;
const keyGenerator = strapi.plugin('strapi-cache').config('keyGenerator') as
CacheKeyGenerator | undefined;
const cacheStore = cacheService.getCacheInstance();
const key = generateCacheKey(ctx, keyGenerator);
const cacheEntry = await cacheStore.get(key);

const middlewaresConfig = strapi.config.get('middlewares') as any[];
const corsMiddleware = middlewaresConfig.find((mw: any) => mw.name === 'strapi::cors');

Expand Down
41 changes: 25 additions & 16 deletions server/src/middlewares/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,32 @@ const middleware = async (ctx: any, next: any) => {
return;
}

const cacheService = strapi.plugin('strapi-cache').services.service as CacheService;
const keyGenerator = strapi.plugin('strapi-cache').config('keyGenerator') as
| CacheKeyGenerator
| undefined;
const isGet = method === 'GET';
if (!isGet && method !== 'POST') {
await next();
return;
}

const { cacheHeaders, cacheHeadersDenyList, cacheHeadersAllowList, cacheAuthorizedRequests } =
getCacheHeaderConfig();
const cacheStore = cacheService.getCacheInstance();
const authorizationHeader = ctx.request.headers['authorization'];

const isGet = method === 'GET';
if (authorizationHeader && !cacheAuthorizedRequests) {
loggy.info('Authorized request bypassing GraphQL cache');
await next();
return;
}

const cacheControlHeader = ctx.request.headers['cache-control'];
const noCache = cacheControlHeader && cacheControlHeader.includes('no-cache');

if (noCache) {
await next();
return;
}

const keyGenerator = strapi.plugin('strapi-cache').config('keyGenerator') as
CacheKeyGenerator | undefined;
let body: string;

if (isGet) {
Expand Down Expand Up @@ -72,18 +89,10 @@ const middleware = async (ctx: any, next: any) => {
await next();
return;
}
const cacheService = strapi.plugin('strapi-cache').services.service as CacheService;
const cacheStore = cacheService.getCacheInstance();
const cacheEntry = await cacheStore.get(key);

const cacheControlHeader = ctx.request.headers['cache-control'];
const noCache = cacheControlHeader && cacheControlHeader.includes('no-cache');
const authorizationHeader = ctx.request.headers['authorization'];

if (authorizationHeader && !cacheAuthorizedRequests) {
loggy.info(`Authorized request bypassing cache: ${key}`);
await next();
return;
}

const middlewaresConfig = strapi.config.get('middlewares') as any[];
const corsMiddleware = middlewaresConfig.find((mw: any) => mw.name === 'strapi::cors');

Expand Down
137 changes: 119 additions & 18 deletions test/middlewares/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,21 @@ describe('cache middleware', () => {
get: vi.fn(),
set: vi.fn(),
};
const getCacheInstance = vi.fn(() => mockCacheStore);

const keyGenerator = vi.fn((ctx: Context) => `custom:${ctx.request.method}:${ctx.request.url}`);
let cacheableRoutes: string[] = [];
let excludeRoutes: string[] = [];
let cacheAuthorizedRequests = false;

const pluginConfig = vi.fn((key: string) => {
switch (key) {
case 'cacheableEntities':
return undefined;
case 'cacheableRoutes':
return [];
return cacheableRoutes;
case 'excludeRoutes':
return [];
return excludeRoutes;
case 'keyGenerator':
return keyGenerator;
case 'cacheHeaders':
Expand All @@ -27,7 +31,7 @@ describe('cache middleware', () => {
case 'cacheHeadersAllowList':
return [];
case 'cacheAuthorizedRequests':
return false;
return cacheAuthorizedRequests;
default:
return undefined;
}
Expand All @@ -37,7 +41,7 @@ describe('cache middleware', () => {
plugin: vi.fn().mockReturnValue({
services: {
service: {
getCacheInstance: () => mockCacheStore,
getCacheInstance,
},
},
config: pluginConfig,
Expand All @@ -59,6 +63,9 @@ describe('cache middleware', () => {

beforeEach(() => {
vi.clearAllMocks();
cacheableRoutes = [];
excludeRoutes = [];
cacheAuthorizedRequests = false;
mockCacheStore.get.mockResolvedValue({
body: { cached: true },
headers: {},
Expand All @@ -69,21 +76,30 @@ describe('cache middleware', () => {
vi.clearAllMocks();
});

it('uses configured keyGenerator for cache lookup', async () => {
const ctx = {
request: {
url: '/api/articles?populate=*',
method: 'GET',
headers: {},
},
method: 'GET',
response: {
headers: {},
},
const createContext = ({
url = '/api/articles',
method = 'GET',
headers = {},
status = 200,
body,
}: {
url?: string;
method?: string;
headers?: Record<string, string>;
status?: number;
body?: unknown;
} = {}) =>
({
request: { url, method, headers },
method,
response: { headers: {} },
set: vi.fn(),
status: 200,
body: undefined,
} as unknown as Context;
status,
body,
}) as unknown as Context;

it('uses configured keyGenerator for cache lookup', async () => {
const ctx = createContext({ url: '/api/articles?populate=*' });

const next = vi.fn();

Expand All @@ -93,4 +109,89 @@ describe('cache middleware', () => {
expect(mockCacheStore.get).toHaveBeenCalledWith('custom:GET:/api/articles?populate=*');
expect(next).not.toHaveBeenCalled();
});

it('bypasses the cache before lookup for non-GET requests', async () => {
const ctx = createContext({ method: 'POST' });
const next = vi.fn();

await cacheMiddleware(ctx, next);

expect(getCacheInstance).not.toHaveBeenCalled();
expect(mockCacheStore.get).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledOnce();
});

it('bypasses excluded routes before lookup', async () => {
excludeRoutes = ['/api/private'];
const ctx = createContext({ url: '/api/private/profile' });
const next = vi.fn();

await cacheMiddleware(ctx, next);

expect(getCacheInstance).not.toHaveBeenCalled();
expect(mockCacheStore.get).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledOnce();
});

it('bypasses non-cacheable routes before lookup', async () => {
cacheableRoutes = ['/api/products'];
const ctx = createContext({ url: '/api/articles' });
const next = vi.fn();

await cacheMiddleware(ctx, next);

expect(getCacheInstance).not.toHaveBeenCalled();
expect(mockCacheStore.get).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledOnce();
});

it('bypasses authorized requests before lookup', async () => {
const ctx = createContext({ headers: { authorization: 'Bearer token' } });
const next = vi.fn();

await cacheMiddleware(ctx, next);

expect(getCacheInstance).not.toHaveBeenCalled();
expect(mockCacheStore.get).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledOnce();
});

it('still reads authorized requests when configured to cache them', async () => {
cacheAuthorizedRequests = true;
const ctx = createContext({ headers: { authorization: 'Bearer token' } });
const next = vi.fn();

await cacheMiddleware(ctx, next);

expect(mockCacheStore.get).toHaveBeenCalledWith('custom:GET:/api/articles');
expect(next).not.toHaveBeenCalled();
});

it('bypasses no-cache requests before lookup', async () => {
const ctx = createContext({ headers: { 'cache-control': 'no-cache' } });
const next = vi.fn();

await cacheMiddleware(ctx, next);

expect(getCacheInstance).not.toHaveBeenCalled();
expect(mockCacheStore.get).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledOnce();
});

it('stores a successful cacheable response after a miss', async () => {
mockCacheStore.get.mockResolvedValueOnce(null);
const ctx = createContext();
const next = vi.fn(async () => {
ctx.status = 200;
ctx.body = { data: { articles: [] } };
});

await cacheMiddleware(ctx, next);

expect(next).toHaveBeenCalledOnce();
expect(mockCacheStore.set).toHaveBeenCalledWith('custom:GET:/api/articles', {
body: { data: { articles: [] } },
headers: null,
});
});
});
Loading
Loading