From c2b5284da4f1a5d725b696df6986c2597bc3e202 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Thu, 4 Dec 2025 16:12:40 +1000 Subject: [PATCH] feat: add manual health check endpoint and health filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/src/app/api/tools/[...slug]/route.ts | 204 +++++++++++++----- apps/web/src/app/api/tools/route.ts | 120 ++++++++--- 2 files changed, 242 insertions(+), 82 deletions(-) diff --git a/apps/web/src/app/api/tools/[...slug]/route.ts b/apps/web/src/app/api/tools/[...slug]/route.ts index 48d22a8..1e67d45 100644 --- a/apps/web/src/app/api/tools/[...slug]/route.ts +++ b/apps/web/src/app/api/tools/[...slug]/route.ts @@ -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 { 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 { + 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 } + ); + } +} diff --git a/apps/web/src/app/api/tools/route.ts b/apps/web/src/app/api/tools/route.ts index 8b89ab8..7e01218 100644 --- a/apps/web/src/app/api/tools/route.ts +++ b/apps/web/src/app/api/tools/route.ts @@ -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)