mirror of
https://github.com/DIYgod/RSSHub.git
synced 2026-09-01 14:57:14 +08:00
feat(api): add cache probing (#21300)
* feat(api): add cache probing * fix(tests): correct query parameter from 'path' to 'requestPath' in status tests * feat(cache): add has to workers kv
This commit is contained in:
@@ -45,6 +45,7 @@ const QuerySchema = z.object({
|
||||
const route = createRoute({
|
||||
method: 'get',
|
||||
path: '/category/{category}',
|
||||
description: 'Namespace list filtered by category',
|
||||
tags: ['Category'],
|
||||
request: {
|
||||
query: QuerySchema,
|
||||
@@ -52,7 +53,7 @@ const route = createRoute({
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Namespace list by categories and language',
|
||||
description: 'Namespaces matching the requested category',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,10 +7,11 @@ import { gitDate, gitHash } from '@/utils/git-hash';
|
||||
const route = createRoute({
|
||||
method: 'get',
|
||||
path: '/follow/config',
|
||||
description: 'Follow configuration for the current instance',
|
||||
tags: ['Follow'],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Follow config',
|
||||
description: 'Follow configuration for the current instance',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+31
-1
@@ -8,6 +8,7 @@ import { handler as namespaceAllHandler, route as namespaceAllRoute } from '@/ap
|
||||
import { handler as namespaceOneHandler, route as namespaceOneRoute } from '@/api/namespace/one';
|
||||
import { handler as radarRulesAllHandler, route as radarRulesAllRoute } from '@/api/radar/rules/all';
|
||||
import { handler as radarRulesOneHandler, route as radarRulesOneRoute } from '@/api/radar/rules/one';
|
||||
import { handler as routeStatusHandler, route as routeStatusRoute } from '@/api/route/status';
|
||||
|
||||
const app = new OpenAPIHono();
|
||||
|
||||
@@ -16,6 +17,7 @@ app.openapi(namespaceOneRoute, namespaceOneHandler);
|
||||
app.openapi(radarRulesAllRoute, radarRulesAllHandler);
|
||||
app.openapi(radarRulesOneRoute, radarRulesOneHandler);
|
||||
app.openapi(categoryOneRoute, categoryOneHandler);
|
||||
app.openapi(routeStatusRoute, routeStatusHandler);
|
||||
app.openapi(followConfigRoute, followConfigHandler);
|
||||
|
||||
const docs = app.getOpenAPI31Document({
|
||||
@@ -30,6 +32,34 @@ for (const path in docs.paths) {
|
||||
delete docs.paths[path];
|
||||
}
|
||||
app.get('/openapi.json', (ctx) => ctx.json(docs));
|
||||
app.get('/reference', Scalar({ content: docs }));
|
||||
app.get(
|
||||
'/reference',
|
||||
Scalar({
|
||||
content: docs,
|
||||
hiddenClients: {
|
||||
c: true,
|
||||
clojure: true,
|
||||
csharp: true,
|
||||
dart: true,
|
||||
fsharp: true,
|
||||
go: false,
|
||||
http: true,
|
||||
java: true,
|
||||
js: true,
|
||||
kotlin: true,
|
||||
node: ['axios'], // allow fetch, ofetch, undici
|
||||
objc: true,
|
||||
ocaml: true,
|
||||
php: false,
|
||||
powershell: true,
|
||||
python: false,
|
||||
r: true,
|
||||
ruby: true,
|
||||
rust: true,
|
||||
shell: ['httpie', 'wget'], // allow curl
|
||||
swift: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -6,10 +6,11 @@ import { namespaces } from '@/registry';
|
||||
const route = createRoute({
|
||||
method: 'get',
|
||||
path: '/namespace',
|
||||
description: 'Information about all namespaces',
|
||||
tags: ['Namespace'],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Information about all namespaces',
|
||||
description: 'Namespace registry data for all namespaces',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,13 +16,14 @@ const ParamsSchema = z.object({
|
||||
const route = createRoute({
|
||||
method: 'get',
|
||||
path: '/namespace/{namespace}',
|
||||
description: 'Information about a namespace',
|
||||
tags: ['Namespace'],
|
||||
request: {
|
||||
params: ParamsSchema,
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Information about a namespace',
|
||||
description: 'Namespace registry data for a namespace',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,10 +45,11 @@ for (const namespace in namespaces) {
|
||||
const route = createRoute({
|
||||
method: 'get',
|
||||
path: '/radar/rules',
|
||||
description: 'All Radar rules grouped by domain',
|
||||
tags: ['Radar'],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'All Radar rules',
|
||||
description: 'Radar rules grouped by domain',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,13 +55,14 @@ const ParamsSchema = z.object({
|
||||
const route = createRoute({
|
||||
method: 'get',
|
||||
path: '/radar/rules/{domain}',
|
||||
description: 'Radar rules for a domain name',
|
||||
tags: ['Radar'],
|
||||
request: {
|
||||
params: ParamsSchema,
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Radar rules for a domain name (does not support subdomains)',
|
||||
description: 'Radar rules for a domain name (no subdomains)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import api from '@/api';
|
||||
|
||||
const mockHas = vi.hoisted(() => vi.fn());
|
||||
const mockGet = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/utils/cache/index', () => ({
|
||||
default: {
|
||||
status: { available: true },
|
||||
globalCache: {
|
||||
has: mockHas,
|
||||
get: mockGet,
|
||||
set: vi.fn(),
|
||||
},
|
||||
tryGet: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('GET /api/route/status', () => {
|
||||
beforeEach(() => {
|
||||
mockHas.mockReset();
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
it('returns 404 when cache is cold', async () => {
|
||||
mockHas.mockResolvedValue(false);
|
||||
|
||||
const response = await api.request('/route/status?requestPath=/github/comments/DIYgod/RSSHub/20768');
|
||||
expect(response.status).toBe(404);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.cached).toBe(false);
|
||||
expect(data.lastBuildDate).toBeNull();
|
||||
});
|
||||
|
||||
it('returns cached: true with lastBuildDate when cache is warm', async () => {
|
||||
const mockBuildDate = 'Mon, 1 Jan 2026 10:00:00 GMT';
|
||||
mockHas.mockResolvedValue(true);
|
||||
mockGet.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
lastBuildDate: mockBuildDate,
|
||||
items: [],
|
||||
})
|
||||
);
|
||||
|
||||
const response = await api.request('/route/status?requestPath=/github/comments/DIYgod/RSSHub/20768');
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.cached).toBe(true);
|
||||
expect(data.lastBuildDate).toBe(mockBuildDate);
|
||||
});
|
||||
|
||||
it('returns 503 when cache is unavailable', async () => {
|
||||
const { default: cacheModule } = await import('@/utils/cache/index');
|
||||
(cacheModule.status as { available: boolean }).available = false;
|
||||
|
||||
try {
|
||||
const response = await api.request('/route/status?requestPath=/github/comments/DIYgod/RSSHub/20768');
|
||||
expect(response.status).toBe(503);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.cached).toBe(false);
|
||||
} finally {
|
||||
(cacheModule.status as { available: boolean }).available = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { RouteHandler } from '@hono/zod-openapi';
|
||||
import { createRoute, z } from '@hono/zod-openapi';
|
||||
import xxhash from 'xxhash-wasm';
|
||||
|
||||
import cacheModule from '@/utils/cache/index';
|
||||
|
||||
const QuerySchema = z.object({
|
||||
requestPath: z.string().openapi({
|
||||
param: {
|
||||
name: 'requestPath',
|
||||
in: 'query',
|
||||
},
|
||||
example: '/github/comments/DIYgod/RSSHub/20768',
|
||||
description: 'The route path to check cache status for',
|
||||
}),
|
||||
});
|
||||
|
||||
const ResponseSchema = z.object({
|
||||
cached: z.boolean(),
|
||||
lastBuildDate: z.string().nullable(),
|
||||
});
|
||||
|
||||
const route = createRoute({
|
||||
method: 'get',
|
||||
path: '/route/status',
|
||||
description: 'Check if a route path is cached',
|
||||
tags: ['Route'],
|
||||
request: {
|
||||
query: QuerySchema,
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ResponseSchema,
|
||||
},
|
||||
},
|
||||
description: 'Cache found',
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ResponseSchema,
|
||||
},
|
||||
},
|
||||
description: 'Cache not found',
|
||||
},
|
||||
503: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ResponseSchema,
|
||||
},
|
||||
},
|
||||
description: 'Cache module unavailable',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handler: RouteHandler<typeof route> = async (ctx) => {
|
||||
if (!cacheModule.status.available) {
|
||||
return ctx.json({ cached: false, lastBuildDate: null }, 503);
|
||||
}
|
||||
|
||||
const { requestPath } = ctx.req.valid('query');
|
||||
const { h64ToString } = await xxhash();
|
||||
const key = 'rsshub:koa-redis-cache:' + h64ToString(requestPath + ':rss');
|
||||
const cached = await cacheModule.globalCache.has(key);
|
||||
|
||||
if (!cached) {
|
||||
return ctx.json({ cached: false, lastBuildDate: null }, 404);
|
||||
}
|
||||
|
||||
let lastBuildDate: string | null = null;
|
||||
|
||||
try {
|
||||
const cachedData = await cacheModule.globalCache.get(key);
|
||||
if (cachedData) {
|
||||
const parsed = JSON.parse(cachedData);
|
||||
lastBuildDate = parsed.lastBuildDate || null;
|
||||
}
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
|
||||
return ctx.json({ cached, lastBuildDate }, 200);
|
||||
};
|
||||
|
||||
export { handler, route };
|
||||
@@ -25,9 +25,11 @@ describe('cache', () => {
|
||||
}
|
||||
await cache.set('mock', undefined);
|
||||
expect(await cache.get('mock')).toBe('');
|
||||
expect(await cache.has('mock')).toBe(true);
|
||||
|
||||
await cache.globalCache.set('mock', undefined);
|
||||
expect(await cache.globalCache.get('mock')).toBe('');
|
||||
expect(await cache.globalCache.has('mock')).toBe(true);
|
||||
await cache.globalCache.set('mock', {
|
||||
mock: 1,
|
||||
});
|
||||
@@ -37,6 +39,7 @@ describe('cache', () => {
|
||||
it('memory get returns null before init', async () => {
|
||||
const memory = (await import('@/utils/cache/memory')).default;
|
||||
expect(await memory.get('missing')).toBeNull();
|
||||
expect(await memory.has('missing')).toBe(false);
|
||||
});
|
||||
|
||||
it('redis', async () => {
|
||||
@@ -51,6 +54,7 @@ describe('cache', () => {
|
||||
await cache.set('mock2', '2');
|
||||
await cache.set('mock2', '2');
|
||||
expect(await cache.get('mock2')).toBe('2');
|
||||
expect(await cache.has('mock2')).toBe(true);
|
||||
await cache.clients.redisClient?.quit();
|
||||
}, 10000);
|
||||
|
||||
@@ -64,6 +68,7 @@ describe('cache', () => {
|
||||
}
|
||||
await cache.set('mock2', '2');
|
||||
expect(await cache.get('mock2')).toBe(null);
|
||||
expect(await cache.has('mock2')).toBe(false);
|
||||
});
|
||||
|
||||
it('redis with error', async () => {
|
||||
@@ -72,6 +77,7 @@ describe('cache', () => {
|
||||
const cache = (await import('@/utils/cache')).default;
|
||||
await cache.set('mock2', '2');
|
||||
expect(await cache.get('mock2')).toBe(null);
|
||||
expect(await cache.has('mock2')).toBe(false);
|
||||
cache.clients.redisClient?.disconnect();
|
||||
});
|
||||
|
||||
@@ -81,7 +87,9 @@ describe('cache', () => {
|
||||
await cache.init();
|
||||
await cache.set('mock2', '2');
|
||||
expect(await cache.get('mock2')).toBe(null);
|
||||
expect(await cache.has('mock2')).toBe(false);
|
||||
expect(await cache.globalCache.get('mock2')).toBeNull();
|
||||
expect(await cache.globalCache.has('mock2')).toBe(false);
|
||||
expect(cache.globalCache.set('mock2', '2')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
Vendored
+1
@@ -4,6 +4,7 @@ import type { LRUCache } from 'lru-cache';
|
||||
type CacheModule = {
|
||||
init: () => void;
|
||||
get: (key: string, refresh?: boolean) => Promise<string | null> | string | null;
|
||||
has: (key: string) => Promise<boolean> | boolean;
|
||||
set: (key: string, value?: string | Record<string, any>, maxAge?: number) => any;
|
||||
status: {
|
||||
available: boolean;
|
||||
|
||||
Vendored
+17
@@ -8,9 +8,11 @@ import redis from './redis';
|
||||
|
||||
const globalCache: {
|
||||
get: (key: string) => Promise<string | null | undefined> | string | null | undefined;
|
||||
has: (key: string) => Promise<boolean> | boolean;
|
||||
set: (key: string, value?: string | Record<string, any>, maxAge?: number) => any;
|
||||
} = {
|
||||
get: () => null,
|
||||
has: () => false,
|
||||
set: () => null,
|
||||
};
|
||||
|
||||
@@ -21,6 +23,7 @@ if (isWorker) {
|
||||
cacheModule = {
|
||||
init: () => null,
|
||||
get: () => null,
|
||||
has: () => false,
|
||||
set: () => null,
|
||||
status: {
|
||||
available: false,
|
||||
@@ -37,6 +40,13 @@ if (isWorker) {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
globalCache.has = async (key) => {
|
||||
if (key && cacheModule.status.available && redisClient) {
|
||||
const result = await redisClient.exists(key);
|
||||
return result > 0;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
globalCache.set = cacheModule.set;
|
||||
} else if (config.cache.type === 'memory') {
|
||||
cacheModule = memory;
|
||||
@@ -47,6 +57,12 @@ if (isWorker) {
|
||||
return memoryCache.get(key, { updateAgeOnGet: false }) as string | undefined;
|
||||
}
|
||||
};
|
||||
globalCache.has = (key) => {
|
||||
if (key && cacheModule.status.available && memoryCache) {
|
||||
return memoryCache.has(key);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
globalCache.set = (key, value, maxAge = config.cache.routeExpire) => {
|
||||
if (!value || value === 'undefined') {
|
||||
value = '';
|
||||
@@ -62,6 +78,7 @@ if (isWorker) {
|
||||
cacheModule = {
|
||||
init: () => null,
|
||||
get: () => null,
|
||||
has: () => false,
|
||||
set: () => null,
|
||||
status: {
|
||||
available: false,
|
||||
|
||||
Vendored
+7
@@ -45,6 +45,13 @@ export default {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
has: async (key: string) => {
|
||||
if (key && status.available && kvNamespace) {
|
||||
const value = await kvNamespace.get(key);
|
||||
return value !== null;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
set: async (key: string, value?: string | Record<string, any>, maxAge = config.cache.contentExpire) => {
|
||||
if (!status.available || !kvNamespace) {
|
||||
return;
|
||||
|
||||
Vendored
+6
@@ -28,6 +28,12 @@ export default {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
has: (key: string) => {
|
||||
if (key && status.available && clients.memoryCache) {
|
||||
return clients.memoryCache.has(key);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
set: (key, value, maxAge = config.cache.contentExpire) => {
|
||||
if (!value || value === 'undefined') {
|
||||
value = '';
|
||||
|
||||
Vendored
+6
@@ -13,6 +13,7 @@ vi.mock('@/utils/logger', () => ({
|
||||
class RedisMock extends EventTarget {
|
||||
mget = vi.fn();
|
||||
expire = vi.fn();
|
||||
exists = vi.fn();
|
||||
set = vi.fn();
|
||||
|
||||
on(event: string, listener: (...args: any[]) => void) {
|
||||
@@ -47,6 +48,7 @@ describe('redis cache module', () => {
|
||||
const redisCache = (await import('@/utils/cache/redis')).default;
|
||||
const client = new RedisMock() as any;
|
||||
client.mget.mockResolvedValue(['value', '30']);
|
||||
client.exists.mockResolvedValue(true);
|
||||
redisCache.status.available = true;
|
||||
redisCache.clients.redisClient = client;
|
||||
|
||||
@@ -54,6 +56,10 @@ describe('redis cache module', () => {
|
||||
expect(value).toBe('value');
|
||||
expect(client.expire).toHaveBeenCalledWith('rsshub:cacheTtl:mock', '30');
|
||||
expect(client.expire).toHaveBeenCalledWith('mock', '30');
|
||||
|
||||
await expect(redisCache.has('mock')).resolves.toBe(true);
|
||||
client.exists.mockResolvedValue(false);
|
||||
await expect(redisCache.has('missing')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('marks redis unavailable on error', async () => {
|
||||
|
||||
Vendored
+7
@@ -54,6 +54,13 @@ export default {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
has: async (key: string) => {
|
||||
if (key && status.available && clients.redisClient) {
|
||||
const result = await clients.redisClient.exists(key);
|
||||
return result > 0;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
set: (key: string, value?: string | Record<string, any>, maxAge = config.cache.contentExpire) => {
|
||||
if (!status.available || !clients.redisClient) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user