Initial release v1.0.0
Open-source marketplace for AI Agent skills. Features: - Next.js 15 web app with i18n (en/fa) - CLI tool for skill installation (npx skillhub) - GitHub crawler/indexer with multi-strategy discovery - Security scanning for all indexed skills - Self-hostable with Docker Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
91
apps/web/app/api/stats/route.test.ts
Normal file
91
apps/web/app/api/stats/route.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// Mock rate limiting - must be before route import
|
||||
vi.mock('@/lib/rate-limit', () => ({
|
||||
withRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 100, limit: 120, resetAt: Date.now() + 60000 }),
|
||||
createRateLimitResponse: vi.fn(),
|
||||
createRateLimitHeaders: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// Mock cache - must be before route import
|
||||
vi.mock('@/lib/cache', () => ({
|
||||
getCached: vi.fn().mockResolvedValue(null),
|
||||
setCache: vi.fn().mockResolvedValue(undefined),
|
||||
cacheKeys: { stats: () => 'stats' },
|
||||
cacheTTL: { stats: 3600 },
|
||||
}));
|
||||
|
||||
// Mock the db module - must be before imports that use it
|
||||
vi.mock('@skillhub/db', () => {
|
||||
return {
|
||||
createDb: vi.fn(() => ({
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockResolvedValue([
|
||||
{ totalSkills: 100, totalDownloads: 5000, totalContributors: 50 },
|
||||
]),
|
||||
}),
|
||||
})),
|
||||
skills: { downloadCount: 'download_count', githubOwner: 'github_owner' },
|
||||
categories: {},
|
||||
sql: vi.fn(() => 'mock-sql'),
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from './route';
|
||||
|
||||
// Helper to create mock request
|
||||
function createMockRequest(url = 'http://localhost:3000/api/stats') {
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
describe('GET /api/stats', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return totalSkills count', async () => {
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.totalSkills).toBeDefined();
|
||||
expect(typeof data.totalSkills).toBe('number');
|
||||
});
|
||||
|
||||
it('should return totalDownloads sum', async () => {
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.totalDownloads).toBeDefined();
|
||||
expect(typeof data.totalDownloads).toBe('number');
|
||||
});
|
||||
|
||||
it('should return totalCategories count', async () => {
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.totalCategories).toBeDefined();
|
||||
expect(typeof data.totalCategories).toBe('number');
|
||||
});
|
||||
|
||||
it('should return totalContributors count', async () => {
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.totalContributors).toBeDefined();
|
||||
expect(typeof data.totalContributors).toBe('number');
|
||||
});
|
||||
|
||||
it('should return platforms count', async () => {
|
||||
const request = createMockRequest();
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.platforms).toBe(5);
|
||||
});
|
||||
});
|
||||
79
apps/web/app/api/stats/route.ts
Normal file
79
apps/web/app/api/stats/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { createDb, skills, categories, sql } from '@skillhub/db';
|
||||
import { getCached, setCache, cacheKeys, cacheTTL } from '@/lib/cache';
|
||||
import { withRateLimit, createRateLimitResponse, createRateLimitHeaders } from '@/lib/rate-limit';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
interface StatsData {
|
||||
totalSkills: number;
|
||||
totalDownloads: number;
|
||||
totalCategories: number;
|
||||
totalContributors: number;
|
||||
platforms: number;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Rate limiting
|
||||
const rateLimitResult = await withRateLimit(request, 'anonymous');
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get from cache first
|
||||
const cacheKey = cacheKeys.stats();
|
||||
const cached = await getCached<StatsData>(cacheKey);
|
||||
if (cached) {
|
||||
return NextResponse.json(cached, {
|
||||
headers: {
|
||||
'X-Cache': 'HIT',
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200',
|
||||
...createRateLimitHeaders(rateLimitResult),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Consolidate all skill stats into a single query for better performance
|
||||
const statsResult = await db
|
||||
.select({
|
||||
totalSkills: sql<number>`count(*)::int`,
|
||||
totalDownloads: sql<number>`coalesce(sum(${skills.downloadCount}), 0)::int`,
|
||||
totalContributors: sql<number>`count(distinct ${skills.githubOwner})::int`,
|
||||
})
|
||||
.from(skills);
|
||||
|
||||
// Get category count in separate query (different table)
|
||||
const categoryResult = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(categories);
|
||||
|
||||
const stats = statsResult[0];
|
||||
const totalCategories = categoryResult[0]?.count ?? 0;
|
||||
|
||||
const data: StatsData = {
|
||||
totalSkills: stats?.totalSkills ?? 0,
|
||||
totalDownloads: stats?.totalDownloads ?? 0,
|
||||
totalCategories,
|
||||
totalContributors: stats?.totalContributors ?? 0,
|
||||
platforms: 5, // Claude, Codex, Copilot, Cursor, Windsurf
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
await setCache(cacheKey, data, cacheTTL.stats);
|
||||
|
||||
return NextResponse.json(data, {
|
||||
headers: {
|
||||
'X-Cache': 'MISS',
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=7200',
|
||||
...createRateLimitHeaders(rateLimitResult),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch stats' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user