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:
parent
fa962d3527
commit
7ebb4f88fa
5 changed files with 331 additions and 0 deletions
|
|
@ -11,6 +11,7 @@
|
|||
"clean": "rm -rf .next .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tpmjs/db": "workspace:*",
|
||||
"@tpmjs/env": "workspace:*",
|
||||
"@tpmjs/types": "workspace:*",
|
||||
"@tpmjs/ui": "workspace:*",
|
||||
|
|
|
|||
90
apps/web/src/app/api/stats/route.ts
Normal file
90
apps/web/src/app/api/stats/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
62
apps/web/src/app/api/tools/[id]/route.ts
Normal file
62
apps/web/src/app/api/tools/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
106
apps/web/src/app/api/tools/route.ts
Normal file
106
apps/web/src/app/api/tools/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
72
apps/web/src/app/api/tools/validate/route.ts
Normal file
72
apps/web/src/app/api/tools/validate/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue