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:
Ajax Davis 2025-12-04 16:12:40 +10:00
parent 148207bcaa
commit b00eae9fa2
2 changed files with 242 additions and 82 deletions

View file

@ -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 }
);
}
}

View file

@ -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)