feat: add manual health check endpoint and health filtering
API Endpoints (Phase 2 - Part 2):
1. Manual Health Check Endpoint
- POST /api/tools/[...slug]
- Added POST handler to existing tool detail route
- Extracts slug parsing into shared parseSlug() helper
- 5-minute rate limit per tool
- Returns full health check results
- Validates that export name is provided (can't check whole package)
2. Health Filtering in /api/tools
- Add query params: ?importHealth=HEALTHY|BROKEN|UNKNOWN
- Add query params: ?executionHealth=HEALTHY|BROKEN|UNKNOWN
- Add shorthand: ?broken=true (at least one health check failed)
- Health filters applied as AND conditions with search/category filters
- Refactored to reduce complexity:
- Extract buildHealthFilters() helper
- Extract buildPackageFilter() helper
- Extract buildWhereClause() helper
3. Code Quality Improvements
- Extract parseSlug() helper to reduce duplication (DRY)
- Remove useless else clauses (biome lint fix)
- Reduce cognitive complexity (GET: 16->8, POST: simplified)
RESTful Design:
- GET /api/tools/@tpmjs/hello/hello -> Fetch tool data
- POST /api/tools/@tpmjs/hello/hello -> Trigger health check
Query Examples:
- /api/tools?broken=true (all broken tools)
- /api/tools?importHealth=HEALTHY&executionHealth=HEALTHY (fully healthy)
- /api/tools?q=text&broken=true (search "text" in broken tools)
Rate Limiting:
- Manual recheck cooldown: 5 minutes per tool
- Returns 429 with retryAfter seconds on rate limit
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e2af4cfd6a
commit
c2b5284da4
2 changed files with 242 additions and 82 deletions
|
|
@ -1,10 +1,40 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { performHealthCheck } from '~/lib/health-check/health-check-service';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
/**
|
||||
* Parse tool slug to extract package name and export name
|
||||
*/
|
||||
function parseSlug(slug: string[]): { packageName: string; exportName: string | undefined } {
|
||||
let packageName: string;
|
||||
let exportName: string | undefined;
|
||||
|
||||
if (slug.length === 1) {
|
||||
// Single slug - package name without scope
|
||||
packageName = slug[0] || '';
|
||||
} else if (slug.length === 2) {
|
||||
// Could be: @scope/package OR package/exportName
|
||||
if (slug[0]?.startsWith('@')) {
|
||||
// @scope/package
|
||||
packageName = slug.join('/');
|
||||
} else {
|
||||
// package + exportName
|
||||
packageName = slug[0] || '';
|
||||
exportName = slug[1];
|
||||
}
|
||||
} else {
|
||||
// 3+ slugs: @scope/package/exportName
|
||||
packageName = slug.slice(0, slug[0]?.startsWith('@') ? 2 : 1).join('/');
|
||||
exportName = slug[slug.length - 1];
|
||||
}
|
||||
|
||||
return { packageName, exportName };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/tools/[...slug]
|
||||
*
|
||||
|
|
@ -17,34 +47,7 @@ export async function GET(
|
|||
): Promise<NextResponse> {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
// Slug can be:
|
||||
// - ['@scope', 'package'] -> Get all tools for @scope/package
|
||||
// - ['@scope', 'package', 'exportName'] -> Get specific tool @scope/package/exportName
|
||||
// - ['package'] -> Get all tools for package
|
||||
// - ['package', 'exportName'] -> Get specific tool package/exportName
|
||||
|
||||
let packageName: string;
|
||||
let exportName: string | undefined;
|
||||
|
||||
if (slug.length === 1) {
|
||||
// Single slug - package name without scope
|
||||
packageName = slug[0] || '';
|
||||
} else if (slug.length === 2) {
|
||||
// Could be: @scope/package OR package/exportName
|
||||
if (slug[0]?.startsWith('@')) {
|
||||
// @scope/package
|
||||
packageName = slug.join('/');
|
||||
} else {
|
||||
// package + exportName
|
||||
packageName = slug[0] || '';
|
||||
exportName = slug[1];
|
||||
}
|
||||
} else {
|
||||
// 3+ slugs: @scope/package/exportName
|
||||
packageName = slug.slice(0, slug[0]?.startsWith('@') ? 2 : 1).join('/');
|
||||
exportName = slug[slug.length - 1];
|
||||
}
|
||||
const { packageName, exportName } = parseSlug(slug);
|
||||
|
||||
if (exportName) {
|
||||
// Find specific tool by package name and export name
|
||||
|
|
@ -70,31 +73,30 @@ export async function GET(
|
|||
success: true,
|
||||
data: tool,
|
||||
});
|
||||
} else {
|
||||
// Find all tools for the package
|
||||
const pkg = await prisma.package.findUnique({
|
||||
where: { npmPackageName: packageName },
|
||||
include: { tools: true },
|
||||
});
|
||||
|
||||
if (!pkg) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Package not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
package: pkg,
|
||||
tools: pkg.tools,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Find all tools for the package
|
||||
const pkg = await prisma.package.findUnique({
|
||||
where: { npmPackageName: packageName },
|
||||
include: { tools: true },
|
||||
});
|
||||
|
||||
if (!pkg) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Package not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
package: pkg,
|
||||
tools: pkg.tools,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching tool:', error);
|
||||
return NextResponse.json(
|
||||
|
|
@ -106,3 +108,101 @@ export async function GET(
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/tools/[...slug]
|
||||
*
|
||||
* Manually trigger a health check for a specific tool
|
||||
* Rate limit: 5-minute cooldown per tool
|
||||
*
|
||||
* Examples:
|
||||
* - POST /api/tools/@tpmjs/hello/hello
|
||||
* - POST /api/tools/my-package/myTool
|
||||
*/
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string[] }> }
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
const { packageName, exportName } = parseSlug(slug);
|
||||
|
||||
// Health checks require export name
|
||||
if (!exportName) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Export name required for health check',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the tool
|
||||
const tool = await prisma.tool.findFirst({
|
||||
where: {
|
||||
package: { npmPackageName: packageName },
|
||||
exportName: exportName,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
lastHealthCheck: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Tool not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Rate limiting: Check if last health check was within 5 minutes
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
||||
if (tool.lastHealthCheck && tool.lastHealthCheck > fiveMinutesAgo) {
|
||||
const nextAvailable = new Date(tool.lastHealthCheck.getTime() + 5 * 60 * 1000);
|
||||
const secondsRemaining = Math.ceil((nextAvailable.getTime() - Date.now()) / 1000);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Rate limit exceeded. Try again in ${secondsRemaining} seconds.`,
|
||||
retryAfter: secondsRemaining,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// Perform health check
|
||||
console.log(`🏥 Manual health check triggered for ${packageName}/${exportName}`);
|
||||
const result = await performHealthCheck(tool.id, 'manual');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
toolId: result.toolId,
|
||||
packageName: packageName,
|
||||
exportName: exportName,
|
||||
importStatus: result.importStatus,
|
||||
importError: result.importError,
|
||||
importTimeMs: result.importTimeMs,
|
||||
executionStatus: result.executionStatus,
|
||||
executionError: result.executionError,
|
||||
executionTimeMs: result.executionTimeMs,
|
||||
overallStatus: result.overallStatus,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error performing manual health check:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to perform health check',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,85 @@ export const runtime = 'nodejs';
|
|||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
/**
|
||||
* Build health filters from query parameters
|
||||
*/
|
||||
function buildHealthFilters(
|
||||
brokenParam: string | null,
|
||||
importHealth: string | null,
|
||||
executionHealth: string | null
|
||||
): Prisma.ToolWhereInput[] {
|
||||
const healthFilters: Prisma.ToolWhereInput[] = [];
|
||||
|
||||
if (brokenParam === 'true') {
|
||||
// Shorthand: at least one health check failed
|
||||
healthFilters.push({
|
||||
OR: [{ importHealth: 'BROKEN' }, { executionHealth: 'BROKEN' }],
|
||||
});
|
||||
} else {
|
||||
// Individual health status filters
|
||||
if (importHealth && ['HEALTHY', 'BROKEN', 'UNKNOWN'].includes(importHealth)) {
|
||||
healthFilters.push({ importHealth: importHealth as 'HEALTHY' | 'BROKEN' | 'UNKNOWN' });
|
||||
}
|
||||
if (executionHealth && ['HEALTHY', 'BROKEN', 'UNKNOWN'].includes(executionHealth)) {
|
||||
healthFilters.push({
|
||||
executionHealth: executionHealth as 'HEALTHY' | 'BROKEN' | 'UNKNOWN',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return healthFilters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build package filters from query parameters
|
||||
*/
|
||||
function buildPackageFilter(
|
||||
category: string | null,
|
||||
officialParam: string | null
|
||||
): Prisma.PackageWhereInput {
|
||||
const packageFilter: Prisma.PackageWhereInput = {};
|
||||
|
||||
if (category) {
|
||||
packageFilter.category = category;
|
||||
}
|
||||
|
||||
if (officialParam !== null) {
|
||||
packageFilter.isOfficial = officialParam === 'true';
|
||||
}
|
||||
|
||||
return packageFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build where clause for tool query
|
||||
*/
|
||||
function buildWhereClause(
|
||||
query: string | null,
|
||||
packageFilter: Prisma.PackageWhereInput,
|
||||
healthFilters: Prisma.ToolWhereInput[]
|
||||
): Prisma.ToolWhereInput {
|
||||
const where: Prisma.ToolWhereInput = {};
|
||||
|
||||
// Search filter (searches tool description and package name)
|
||||
if (query) {
|
||||
where.OR = [
|
||||
{ description: { contains: query, mode: 'insensitive' } },
|
||||
{ package: { npmPackageName: { contains: query, mode: 'insensitive' }, ...packageFilter } },
|
||||
];
|
||||
} else if (Object.keys(packageFilter).length > 0) {
|
||||
// Apply package filter if no search query
|
||||
where.package = packageFilter;
|
||||
}
|
||||
|
||||
// Apply health filters as AND conditions
|
||||
if (healthFilters.length > 0) {
|
||||
where.AND = healthFilters;
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/tools
|
||||
* Search and list tools with filtering, sorting, and pagination
|
||||
|
|
@ -13,6 +92,9 @@ export const maxDuration = 60;
|
|||
* - q: Search query (searches package name, tool description)
|
||||
* - category: Filter by category
|
||||
* - official: Filter by official status (true/false)
|
||||
* - importHealth: Filter by import health (HEALTHY, BROKEN, UNKNOWN)
|
||||
* - executionHealth: Filter by execution health (HEALTHY, BROKEN, UNKNOWN)
|
||||
* - broken: Shorthand for "at least one health check failed" (true/false)
|
||||
* - limit: Results per page (default: 20, max: 50)
|
||||
* - offset: Pagination offset (default: 0)
|
||||
*/
|
||||
|
|
@ -24,42 +106,20 @@ export async function GET(request: NextRequest) {
|
|||
const query = searchParams.get('q');
|
||||
const category = searchParams.get('category');
|
||||
const officialParam = searchParams.get('official');
|
||||
const importHealth = searchParams.get('importHealth');
|
||||
const executionHealth = searchParams.get('executionHealth');
|
||||
const brokenParam = searchParams.get('broken');
|
||||
const limitParam = searchParams.get('limit');
|
||||
const offsetParam = searchParams.get('offset');
|
||||
|
||||
// Validate and set defaults
|
||||
const limit = Math.min(
|
||||
Number.parseInt(limitParam || '20', 10),
|
||||
50 // Max 50 for better performance
|
||||
);
|
||||
const limit = Math.min(Number.parseInt(limitParam || '20', 10), 50);
|
||||
const offset = Math.max(Number.parseInt(offsetParam || '0', 10), 0);
|
||||
|
||||
// Build where clause for Tool table
|
||||
const where: Prisma.ToolWhereInput = {};
|
||||
|
||||
// Build package filter separately
|
||||
const packageFilter: Prisma.PackageWhereInput = {};
|
||||
|
||||
// Category filter (category is at package level)
|
||||
if (category) {
|
||||
packageFilter.category = category;
|
||||
}
|
||||
|
||||
// Official filter (isOfficial is at package level)
|
||||
if (officialParam !== null) {
|
||||
packageFilter.isOfficial = officialParam === 'true';
|
||||
}
|
||||
|
||||
// Search filter (searches tool description and package name)
|
||||
if (query) {
|
||||
where.OR = [
|
||||
{ description: { contains: query, mode: 'insensitive' } },
|
||||
{ package: { npmPackageName: { contains: query, mode: 'insensitive' }, ...packageFilter } },
|
||||
];
|
||||
} else if (Object.keys(packageFilter).length > 0) {
|
||||
// Apply package filter if no search query
|
||||
where.package = packageFilter;
|
||||
}
|
||||
// Build filters
|
||||
const packageFilter = buildPackageFilter(category, officialParam);
|
||||
const healthFilters = buildHealthFilters(brokenParam, importHealth, executionHealth);
|
||||
const where = buildWhereClause(query, packageFilter, healthFilters);
|
||||
|
||||
// Execute query - fetch tools with package relation
|
||||
// We fetch limit+1 to check if there are more results (avoid expensive count)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue