feat(api): implement Phase 2 Core API Routes for tool registry

Add 4 REST API endpoints for the TPMJS tool registry:

**GET /api/tools**
- Search and list tools with filtering, sorting, pagination
- Query params: q (search), category, official, limit, offset
- Returns tools with pagination metadata
- Sorts by quality score and download count

**GET /api/tools/[id]**
- Get tool details by ID (cuid) or package name
- Supports both lookup methods with OR query
- Returns full tool metadata

**POST /api/tools/validate**
- Validate tpmjs field schema
- Determines tier (minimal vs rich)
- Returns validation errors with detailed messages
- Uses @tpmjs/types validateTpmjsField function

**GET /api/stats**
- Aggregate statistics about the registry
- Total tools, official tools, category breakdown
- Recent tools (last 7 days), total downloads
- Efficient parallel queries with Promise.all

All endpoints include proper error handling, TypeScript types,
and follow Next.js 16 App Router conventions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2025-11-28 02:35:25 +10:00
parent fa962d3527
commit 7ebb4f88fa
5 changed files with 331 additions and 0 deletions

View file

@ -11,6 +11,7 @@
"clean": "rm -rf .next .turbo"
},
"dependencies": {
"@tpmjs/db": "workspace:*",
"@tpmjs/env": "workspace:*",
"@tpmjs/types": "workspace:*",
"@tpmjs/ui": "workspace:*",

View file

@ -0,0 +1,90 @@
import { prisma } from '@tpmjs/db';
import { NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/stats
* Get aggregated statistics about tools in the registry
*
* Returns:
* - totalTools: Total number of tools
* - officialTools: Number of official tools (with tpmjs-tool keyword)
* - categories: Breakdown by category with counts
* - recentTools: Count of tools added in last 7 days
* - totalDownloads: Sum of all npm downloads
*/
export async function GET() {
try {
// Run all aggregations in parallel
const [totalTools, officialTools, categoryStats, recentCount, downloadSum] = await Promise.all([
// Total tools count
prisma.tool.count(),
// Official tools count
prisma.tool.count({
where: { isOfficial: true },
}),
// Group by category
prisma.tool.groupBy({
by: ['category'],
_count: {
id: true,
},
orderBy: {
_count: {
id: 'desc',
},
},
}),
// Recent tools (last 7 days)
prisma.tool.count({
where: {
createdAt: {
gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
},
},
}),
// Sum of all downloads
prisma.tool.aggregate({
_sum: {
npmDownloadsLastMonth: true,
},
}),
]);
// Format category stats
const categories = categoryStats.reduce<Record<string, number>>((acc, stat) => {
if (stat.category) {
acc[stat.category] = stat._count.id;
}
return acc;
}, {});
return NextResponse.json({
success: true,
data: {
totalTools,
officialTools,
categories,
recentTools: recentCount,
totalDownloads: downloadSum._sum.npmDownloadsLastMonth || 0,
},
});
} catch (error) {
console.error('Error fetching stats:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch stats',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,62 @@
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/tools/[id]
* Get tool details by ID or package name
*
* Params:
* - id: Tool ID (number) or NPM package name (string)
*/
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
if (!id) {
return NextResponse.json(
{
success: false,
error: 'Missing ID parameter',
},
{ status: 400 }
);
}
// Try to find by ID first (cuid), then by package name
const tool = await prisma.tool.findFirst({
where: {
OR: [{ id }, { npmPackageName: id }],
},
});
if (!tool) {
return NextResponse.json(
{
success: false,
error: 'Tool not found',
message: `No tool found with ID or package name: ${id}`,
},
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: tool,
});
} catch (error) {
console.error('Error fetching tool details:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch tool details',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,106 @@
import { type Prisma, prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/tools
* Search and list tools with filtering, sorting, and pagination
*
* Query params:
* - q: Search query (searches name, description, tags)
* - category: Filter by category
* - official: Filter by official status (true/false)
* - limit: Results per page (default: 20, max: 100)
* - offset: Pagination offset (default: 0)
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
// Parse query parameters
const query = searchParams.get('q');
const category = searchParams.get('category');
const officialParam = searchParams.get('official');
const limitParam = searchParams.get('limit');
const offsetParam = searchParams.get('offset');
// Validate and set defaults
const limit = Math.min(
Number.parseInt(limitParam || '20', 10),
100 // Max 100 results per page
);
const offset = Math.max(Number.parseInt(offsetParam || '0', 10), 0);
// Build where clause
const where: Prisma.ToolWhereInput = {};
// Search filter (case-insensitive partial match)
if (query) {
where.OR = [
{ npmPackageName: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } },
{
tags: {
hasSome: [query],
},
},
];
}
// Category filter
if (category) {
where.category = category;
}
// Official filter
if (officialParam !== null) {
where.isOfficial = officialParam === 'true';
}
// Execute query with pagination
const [tools, totalCount] = await Promise.all([
prisma.tool.findMany({
where,
orderBy: [
{ qualityScore: 'desc' },
{ npmDownloadsLastMonth: 'desc' },
{ createdAt: 'desc' },
],
take: limit,
skip: offset,
}),
prisma.tool.count({ where }),
]);
// Calculate pagination metadata
const hasMore = offset + limit < totalCount;
const totalPages = Math.ceil(totalCount / limit);
const currentPage = Math.floor(offset / limit) + 1;
return NextResponse.json({
success: true,
data: tools,
pagination: {
total: totalCount,
limit,
offset,
hasMore,
totalPages,
currentPage,
},
});
} catch (error) {
console.error('Error fetching tools:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch tools',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,72 @@
import { validateTpmjsField } from '@tpmjs/types/tpmjs';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
/**
* POST /api/tools/validate
* Validate a tpmjs field and determine its tier
*
* Body: JSON object representing the tpmjs field
*
* Returns:
* - valid: boolean indicating if the field is valid
* - tier: 'minimal' | 'rich' | null
* - data: validated data if valid
* - errors: validation errors if invalid
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate the tpmjs field
const result = validateTpmjsField(body);
// Format errors for better readability
if (!result.valid && result.errors) {
return NextResponse.json(
{
success: false,
valid: false,
tier: null,
errors: result.errors.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
code: issue.code,
})),
},
{ status: 400 }
);
}
return NextResponse.json({
success: true,
valid: result.valid,
tier: result.tier,
data: result.data,
});
} catch (error) {
console.error('Error validating tpmjs field:', error);
// Handle JSON parsing errors
if (error instanceof SyntaxError) {
return NextResponse.json(
{
success: false,
error: 'Invalid JSON',
message: 'The request body must be valid JSON',
},
{ status: 400 }
);
}
return NextResponse.json(
{
success: false,
error: 'Validation failed',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}