Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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, []);
});
50 changes: 50 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ 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';
import { requestLogger } from './middleware/logging.js';

interface AppDependencies {
usageEventsRepository: UsageEventsRepository;
apiRepository: ApiRepository;
}

const isValidGroupBy = (value: string): value is GroupBy =>
Expand All @@ -35,6 +37,18 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
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<ApiRepository> {
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(',')
Expand Down Expand Up @@ -64,6 +78,42 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
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' });
});
Expand Down
1 change: 1 addition & 0 deletions src/migrations.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
62 changes: 62 additions & 0 deletions src/repositories/apiRepository.drizzle.ts
Original file line number Diff line number Diff line change
@@ -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<ApiDetails | null> {
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<ApiEndpointInfo[]> {
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,
}));
}
}
51 changes: 51 additions & 0 deletions src/repositories/apiRepository.ts
Original file line number Diff line number Diff line change
@@ -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<ApiDetails | null>;
getEndpoints(apiId: number): Promise<ApiEndpointInfo[]>;
}

// --- In-Memory implementation (for testing) ---

export class InMemoryApiRepository implements ApiRepository {
private readonly apis: ApiDetails[];
private readonly endpointsByApiId: Map<number, ApiEndpointInfo[]>;

constructor(
apis: ApiDetails[] = [],
endpointsByApiId: Map<number, ApiEndpointInfo[]> = new Map()
) {
this.apis = [...apis];
this.endpointsByApiId = new Map(endpointsByApiId);
}

async findById(id: number): Promise<ApiDetails | null> {
return this.apis.find((a) => a.id === id) ?? null;
}

async getEndpoints(apiId: number): Promise<ApiEndpointInfo[]> {
return this.endpointsByApiId.get(apiId) ?? [];
}
}
Loading