diff --git a/src/app.test.ts b/src/app.test.ts index 8eae584..77d2cb8 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -4,6 +4,7 @@ import request from 'supertest'; import { createApp } from './app.js'; import { InMemoryUsageEventsRepository } from './repositories/usageEventsRepository.js'; +import { InMemoryApiRepository } from './repositories/apiRepository.js'; const seedRepository = () => new InMemoryUsageEventsRepository([ @@ -134,3 +135,126 @@ test('GET /api/developers/analytics filters by apiId and blocks non-owned API', .set('x-user-id', 'dev-1'); assert.equal(blocked.status, 403); }); + +// ── GET /api/apis/:id ──────────────────────────────────────────────────────── + +const buildApiRepo = () => { + const activeApi = { + id: 1, + name: 'Weather API', + description: 'Real-time weather data', + base_url: 'https://api.weather.example.com', + logo_url: 'https://cdn.example.com/logo.png', + category: 'weather', + status: 'active', + developer: { + name: 'Alice Dev', + website: 'https://alice.example.com', + description: 'Building climate tools', + }, + }; + const endpoints = new Map([ + [ + 1, + [ + { + path: '/v1/current', + method: 'GET', + price_per_call_usdc: '0.001', + description: 'Current conditions', + }, + { + path: '/v1/forecast', + method: 'GET', + price_per_call_usdc: '0.002', + description: null, + }, + ], + ], + ]); + return new InMemoryApiRepository([activeApi], endpoints); +}; + +test('GET /api/apis/:id returns 400 for non-integer id', async () => { + const app = createApp({ apiRepository: buildApiRepo() }); + + const resAlpha = await request(app).get('/api/apis/abc'); + assert.equal(resAlpha.status, 400); + assert.equal(typeof resAlpha.body.error, 'string'); + + const resFloat = await request(app).get('/api/apis/1.5'); + assert.equal(resFloat.status, 400); + + const resZero = await request(app).get('/api/apis/0'); + assert.equal(resZero.status, 400); + + const resNeg = await request(app).get('/api/apis/-1'); + assert.equal(resNeg.status, 400); +}); + +test('GET /api/apis/:id returns 404 when api not found', async () => { + const app = createApp({ apiRepository: buildApiRepo() }); + const res = await request(app).get('/api/apis/999'); + assert.equal(res.status, 404); + assert.equal(typeof res.body.error, 'string'); +}); + +test('GET /api/apis/:id returns full API details with endpoints', async () => { + const app = createApp({ apiRepository: buildApiRepo() }); + const res = await request(app).get('/api/apis/1'); + + assert.equal(res.status, 200); + assert.equal(res.body.id, 1); + assert.equal(res.body.name, 'Weather API'); + assert.equal(res.body.description, 'Real-time weather data'); + assert.equal(res.body.base_url, 'https://api.weather.example.com'); + assert.equal(res.body.logo_url, 'https://cdn.example.com/logo.png'); + assert.equal(res.body.category, 'weather'); + assert.equal(res.body.status, 'active'); + assert.deepEqual(res.body.developer, { + name: 'Alice Dev', + website: 'https://alice.example.com', + description: 'Building climate tools', + }); + assert.equal(res.body.endpoints.length, 2); + assert.deepEqual(res.body.endpoints[0], { + path: '/v1/current', + method: 'GET', + price_per_call_usdc: '0.001', + description: 'Current conditions', + }); + assert.deepEqual(res.body.endpoints[1], { + path: '/v1/forecast', + method: 'GET', + price_per_call_usdc: '0.002', + description: null, + }); +}); + +test('GET /api/apis/:id is a public route (no auth required)', async () => { + const app = createApp({ apiRepository: buildApiRepo() }); + // Request without any auth header must succeed + const res = await request(app).get('/api/apis/1'); + assert.equal(res.status, 200); +}); + +test('GET /api/apis/:id returns api with empty endpoints list', async () => { + const apiRepo = new InMemoryApiRepository([ + { + id: 2, + name: 'Empty API', + description: null, + base_url: 'https://empty.example.com', + logo_url: null, + category: null, + status: 'active', + developer: { name: null, website: null, description: null }, + }, + ]); + const app = createApp({ apiRepository: apiRepo }); + const res = await request(app).get('/api/apis/2'); + + assert.equal(res.status, 200); + assert.equal(res.body.name, 'Empty API'); + assert.deepEqual(res.body.endpoints, []); +}); diff --git a/src/app.ts b/src/app.ts index 3c909b5..4ae727d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,7 @@ import { type GroupBy, type UsageEventsRepository, } from './repositories/usageEventsRepository.js'; +import type { ApiRepository } from './repositories/apiRepository.js'; import { requireAuth, type AuthenticatedLocals } from './middleware/requireAuth.js'; import { buildDeveloperAnalytics } from './services/developerAnalytics.js'; import { errorHandler } from './middleware/errorHandler.js'; @@ -13,6 +14,7 @@ import { requestLogger } from './middleware/logging.js'; interface AppDependencies { usageEventsRepository: UsageEventsRepository; + apiRepository: ApiRepository; } const isValidGroupBy = (value: string): value is GroupBy => @@ -35,6 +37,18 @@ export const createApp = (dependencies?: Partial) => { const usageEventsRepository = dependencies?.usageEventsRepository ?? new InMemoryUsageEventsRepository(); + // Lazy singleton for production Drizzle repo; injected repo is used in tests. + const _injectedApiRepo = dependencies?.apiRepository; + let _drizzleApiRepo: ApiRepository | undefined; + async function getApiRepo(): Promise { + if (_injectedApiRepo) return _injectedApiRepo; + if (!_drizzleApiRepo) { + const { DrizzleApiRepository } = await import('./repositories/apiRepository.drizzle.js'); + _drizzleApiRepo = new DrizzleApiRepository(); + } + return _drizzleApiRepo; + } + app.use(requestLogger); const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS ?? 'http://localhost:5173') .split(',') @@ -64,6 +78,42 @@ export const createApp = (dependencies?: Partial) => { res.json({ apis: [] }); }); + app.get('/api/apis/:id', async (req, res) => { + const rawId = req.params.id; + const id = Number(rawId); + + if (!Number.isInteger(id) || id <= 0) { + res.status(400).json({ error: 'id must be a positive integer' }); + return; + } + + const apiRepo = await getApiRepo(); + const api = await apiRepo.findById(id); + if (!api) { + res.status(404).json({ error: 'API not found or not active' }); + return; + } + + const endpoints = await apiRepo.getEndpoints(id); + + res.json({ + id: api.id, + name: api.name, + description: api.description, + base_url: api.base_url, + logo_url: api.logo_url, + category: api.category, + status: api.status, + developer: api.developer, + endpoints: endpoints.map((ep) => ({ + path: ep.path, + method: ep.method, + price_per_call_usdc: ep.price_per_call_usdc, + description: ep.description, + })), + }); + }); + app.get('/api/usage', (_req, res) => { res.json({ calls: 0, period: 'current' }); }); diff --git a/src/migrations.test.ts b/src/migrations.test.ts index 3d5ee2b..7682202 100644 --- a/src/migrations.test.ts +++ b/src/migrations.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; import fs from 'node:fs'; import path from 'node:path'; import { describe, it } from 'node:test'; diff --git a/src/repositories/apiRepository.drizzle.ts b/src/repositories/apiRepository.drizzle.ts new file mode 100644 index 0000000..99fe995 --- /dev/null +++ b/src/repositories/apiRepository.drizzle.ts @@ -0,0 +1,62 @@ +import { eq, and } from 'drizzle-orm'; +import { db, schema } from '../db/index.js'; +import type { ApiDetails, ApiEndpointInfo, ApiRepository } from './apiRepository.js'; + +export class DrizzleApiRepository implements ApiRepository { + async findById(id: number): Promise { + const rows = await db + .select({ + id: schema.apis.id, + name: schema.apis.name, + description: schema.apis.description, + base_url: schema.apis.base_url, + logo_url: schema.apis.logo_url, + category: schema.apis.category, + status: schema.apis.status, + developer_name: schema.developers.name, + developer_website: schema.developers.website, + developer_description: schema.developers.description, + }) + .from(schema.apis) + .leftJoin(schema.developers, eq(schema.apis.developer_id, schema.developers.id)) + .where(and(eq(schema.apis.id, id), eq(schema.apis.status, 'active'))) + .limit(1); + + const row = rows[0]; + if (!row) return null; + + return { + id: row.id, + name: row.name, + description: row.description, + base_url: row.base_url, + logo_url: row.logo_url, + category: row.category, + status: row.status, + developer: { + name: row.developer_name ?? null, + website: row.developer_website ?? null, + description: row.developer_description ?? null, + }, + }; + } + + async getEndpoints(apiId: number): Promise { + const rows = await db + .select({ + path: schema.apiEndpoints.path, + method: schema.apiEndpoints.method, + price_per_call_usdc: schema.apiEndpoints.price_per_call_usdc, + description: schema.apiEndpoints.description, + }) + .from(schema.apiEndpoints) + .where(eq(schema.apiEndpoints.api_id, apiId)); + + return rows.map((r) => ({ + path: r.path, + method: r.method, + price_per_call_usdc: r.price_per_call_usdc, + description: r.description, + })); + } +} diff --git a/src/repositories/apiRepository.ts b/src/repositories/apiRepository.ts new file mode 100644 index 0000000..c027b8f --- /dev/null +++ b/src/repositories/apiRepository.ts @@ -0,0 +1,51 @@ +export interface ApiDeveloperInfo { + name: string | null; + website: string | null; + description: string | null; +} + +export interface ApiDetails { + id: number; + name: string; + description: string | null; + base_url: string; + logo_url: string | null; + category: string | null; + status: string; + developer: ApiDeveloperInfo; +} + +export interface ApiEndpointInfo { + path: string; + method: string; + price_per_call_usdc: string; + description: string | null; +} + +export interface ApiRepository { + findById(id: number): Promise; + getEndpoints(apiId: number): Promise; +} + +// --- In-Memory implementation (for testing) --- + +export class InMemoryApiRepository implements ApiRepository { + private readonly apis: ApiDetails[]; + private readonly endpointsByApiId: Map; + + constructor( + apis: ApiDetails[] = [], + endpointsByApiId: Map = new Map() + ) { + this.apis = [...apis]; + this.endpointsByApiId = new Map(endpointsByApiId); + } + + async findById(id: number): Promise { + return this.apis.find((a) => a.id === id) ?? null; + } + + async getEndpoints(apiId: number): Promise { + return this.endpointsByApiId.get(apiId) ?? []; + } +}