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
54 changes: 50 additions & 4 deletions src/repositories/apiRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ jest.mock('better-sqlite3', () => {

import {
InMemoryApiRepository,
listPublicDetailed,
type ApiDetails,
type ApiEndpointInfo,
} from './apiRepository.js';
Expand Down Expand Up @@ -146,12 +147,57 @@ describe('InMemoryApiRepository', () => {
// ── listByDeveloper ─────────────────────────────────────────────────────

describe('listByDeveloper', () => {
test('returns empty array (stub implementation)', async () => {
const repo = new InMemoryApiRepository([SAMPLE_API]);
test('returns matching apis for a developer id', async () => {
const repo = new InMemoryApiRepository([
{
...SAMPLE_API,
id: 10,
status: 'active',
},
{
...SAMPLE_API_MINIMAL,
id: 11,
status: 'draft',
},
]);

const result = await (repo as import('./apiRepository.js').ApiRepository).listByDeveloper(1);
const result = await repo.listByDeveloper(0);

assert.deepStrictEqual(result, []);
assert.equal(result.length, 2);
assert.deepEqual(
result.map((api) => api.id),
[10, 11],
);
});
});

describe('listPublicDetailed', () => {
test('returns active apis by default with endpoint pricing and total', async () => {
const endpointsMap = new Map<number, ApiEndpointInfo[]>();
endpointsMap.set(1, SAMPLE_ENDPOINTS);
const repo = new InMemoryApiRepository([SAMPLE_API, SAMPLE_API_MINIMAL], endpointsMap);

const result = await listPublicDetailed(repo, { limit: 20, offset: 0 });

assert.equal(result.total, 1);
assert.equal(result.items.length, 1);
assert.equal(result.items[0].id, 1);
assert.deepStrictEqual(result.items[0].endpoints, SAMPLE_ENDPOINTS);
});

test('applies explicit status filter with pagination', async () => {
const repo = new InMemoryApiRepository([SAMPLE_API, SAMPLE_API_MINIMAL]);

const result = await listPublicDetailed(repo, {
status: 'draft',
limit: 1,
offset: 0,
});

assert.equal(result.total, 1);
assert.equal(result.items.length, 1);
assert.equal(result.items[0].status, 'draft');
assert.equal(result.items[0].id, 2);
});
});

Expand Down
151 changes: 150 additions & 1 deletion src/repositories/apiRepository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { eq, and, like, type SQL } from 'drizzle-orm';
import { eq, and, like, type SQL, count } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import type { Api, ApiEndpoint, NewApi, NewApiEndpoint, ApiStatus, HttpMethod } from '../db/schema.js';

Expand Down Expand Up @@ -53,6 +53,15 @@ export interface ApiEndpointInfo {
description: string | null;
}

export interface ApiListItem extends ApiDetails {
endpoints: ApiEndpointInfo[];
}

export interface PaginatedApiListResult {
items: ApiListItem[];
total: number;
}

export interface ApiRepository {
create(api: ApiCreateInput): Promise<Api>;
createWithEndpoints(input: CreateApiInput): Promise<ApiWithEndpoints>;
Expand Down Expand Up @@ -398,6 +407,47 @@ export class InMemoryApiRepository implements ApiRepository {
return results;
}

async listPublicDetailed(filters: ApiListFilters = {}): Promise<PaginatedApiListResult> {
let results = this.apis;
if (filters.status) {
results = results.filter((api) => api.status === filters.status);
} else {
results = results.filter((api) => api.status === 'active');
}
if (filters.category) {
results = results.filter((api) => api.category === filters.category);
}
if (filters.search) {
const needle = filters.search.toLowerCase();
results = results.filter((api) => api.name.toLowerCase().includes(needle));
}

const total = results.length;
if (typeof filters.offset === 'number') {
results = results.slice(filters.offset);
}
if (typeof filters.limit === 'number') {
results = results.slice(0, filters.limit);
}

const items = results.map((api) => {
const details = this.detailsById.get(api.id);
return {
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: details?.developer ?? { name: null, website: null, description: null },
endpoints: this.endpointsByApiId.get(api.id) ?? [],
};
});

return { items, total };
}

async findById(id: number): Promise<ApiDetails | null> {
const item = this.detailsById.get(id) ?? null;
if (!item) return null;
Expand All @@ -409,6 +459,105 @@ export class InMemoryApiRepository implements ApiRepository {
}
}

export async function listPublicDetailed(
repository: ApiRepository,
filters: ApiListFilters = {},
): Promise<PaginatedApiListResult> {
const detailedRepository = repository as ApiRepository & {
listPublicDetailed?: (filters?: ApiListFilters) => Promise<PaginatedApiListResult>;
};

if (typeof detailedRepository.listPublicDetailed === 'function') {
return detailedRepository.listPublicDetailed(filters);
}

if (repository === defaultApiRepository) {
const conditions: SQL[] = [];
if (filters.status) {
conditions.push(eq(schema.apis.status, filters.status));
} else {
conditions.push(eq(schema.apis.status, 'active'));
}
if (filters.category) {
conditions.push(eq(schema.apis.category, filters.category));
}
if (filters.search) {
conditions.push(like(schema.apis.name, `%${filters.search}%`));
}

const whereClause = and(...conditions);
const [{ total }] = await db
.select({ total: count() })
.from(schema.apis)
.where(whereClause);

let query = 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(whereClause);

if (typeof filters.limit === 'number') {
query = query.limit(filters.limit) as typeof query;
}
if (typeof filters.offset === 'number') {
query = query.offset(filters.offset) as typeof query;
}

const rows = await query;
const items = await Promise.all(
rows.map(async (row) => ({
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,
},
endpoints: await repository.getEndpoints(row.id),
})),
);

return { items, total };
}

const apis = await repository.listPublic(filters);
const items = await Promise.all(
apis.map(async (api) => {
const details = await repository.findById(api.id);
return {
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: details?.developer ?? { name: null, website: null, description: null },
endpoints: await repository.getEndpoints(api.id),
};
}),
);

return { items, total: items.length };
}

// --- Create API (production) ---

export interface CreateEndpointInput {
Expand Down
123 changes: 123 additions & 0 deletions src/routes/apis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
jest.mock('better-sqlite3', () => {
return class MockDatabase {
prepare() { return { get: () => null }; }
exec() { return undefined; }
close() { return undefined; }
};
});

import express from 'express';
import request from 'supertest';
import { errorHandler } from '../middleware/errorHandler.js';
import { InMemoryApiRepository } from '../repositories/apiRepository.js';
import { createApisRouter } from './apis.js';

describe('createApisRouter', () => {
function buildApp() {
const repo = new InMemoryApiRepository(
[
{
id: 1,
name: 'Weather API',
description: 'Provides weather data',
base_url: 'https://api.weather.test',
logo_url: null,
category: 'weather',
status: 'active',
developer: {
name: 'Acme Corp',
website: 'https://acme.test',
description: 'Leading data provider',
},
},
{
id: 2,
name: 'Translate API',
description: null,
base_url: 'https://api.translate.test',
logo_url: null,
category: 'language',
status: 'draft',
developer: {
name: 'Draft Dev',
website: null,
description: null,
},
},
],
new Map([
[
1,
[
{
path: '/current',
method: 'GET',
price_per_call_usdc: '0.01',
description: 'Current weather',
},
],
],
]),
);

const app = express();
app.use('/api/apis', createApisRouter({ apiRepository: repo }));
app.use(errorHandler);
return app;
}

it('returns only active apis by default with pagination metadata', async () => {
const app = buildApp();

const res = await request(app).get('/api/apis');

expect(res.status).toBe(200);
expect(res.body.meta).toEqual({ total: 1, limit: 20, offset: 0 });
expect(res.body.data).toHaveLength(1);
expect(res.body.data[0]).toEqual(
expect.objectContaining({
id: 1,
name: 'Weather API',
status: 'active',
endpoints: [
expect.objectContaining({
path: '/current',
method: 'GET',
price_per_call_usdc: '0.01',
}),
],
}),
);
});

it('supports valid status filtering', async () => {
const app = buildApp();

const res = await request(app).get('/api/apis?status=draft');

expect(res.status).toBe(200);
expect(res.body.meta).toEqual({ total: 1, limit: 20, offset: 0 });
expect(res.body.data).toHaveLength(1);
expect(res.body.data[0].id).toBe(2);
expect(res.body.data[0].status).toBe('draft');
});

it('rejects unknown status filters with 400', async () => {
const app = buildApp();

const res = await request(app).get('/api/apis?status=invalid');

expect(res.status).toBe(400);
expect(res.body.message).toContain('status must be one of');
});

it('applies pagination params to the response metadata and items', async () => {
const app = buildApp();

const res = await request(app).get('/api/apis?status=active&limit=1&offset=0');

expect(res.status).toBe(200);
expect(res.body.meta).toEqual({ total: 1, limit: 1, offset: 0 });
expect(res.body.data).toHaveLength(1);
});
});
15 changes: 15 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export interface ApiSummary {
logo_url: string | null;
category: string | null;
status: string;
endpoints?: Array<{
path: string;
method: string;
price_per_call_usdc: string;
description: string | null;
}>;
developer: {
name: string | null;
website: string | null;
Expand All @@ -36,6 +42,15 @@ export interface ApisResponse {
apis: ApiSummary[];
}

export interface PaginatedApisResponse {
data: ApiSummary[];
meta: {
total?: number;
limit: number;
offset: number;
};
}

export interface UsageResponse {
calls: number;
period: string;
Expand Down
Loading
Loading