Skip to content

Commit dc75a53

Browse files
Merge pull request #99 from AbuTuraab/feature/rest-developer-list-apis
Feature/rest developer list apis
2 parents 53900e8 + cda04c7 commit dc75a53

File tree

5 files changed

+326
-0
lines changed

5 files changed

+326
-0
lines changed

src/app.test.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import request from 'supertest';
44

55
import { createApp } from './app.js';
66
import { InMemoryUsageEventsRepository } from './repositories/usageEventsRepository.js';
7+
import type { Api } from './db/schema.js';
8+
import type { ApiRepository, ApiListFilters } from './repositories/apiRepository.js';
9+
import type { Developer } from './db/schema.js';
10+
import type { DeveloperRepository } from './repositories/developerRepository.js';
711
import { InMemoryApiRepository } from './repositories/apiRepository.js';
812

913
const seedRepository = () =>
@@ -55,6 +59,130 @@ const seedRepository = () =>
5559
},
5660
]);
5761

62+
const developerProfile: Developer = {
63+
id: 11,
64+
user_id: 'dev-1',
65+
name: 'Test Developer',
66+
website: null,
67+
description: null,
68+
category: null,
69+
created_at: 1,
70+
updated_at: 1,
71+
};
72+
73+
const sampleApis: Api[] = [
74+
{
75+
id: 101,
76+
developer_id: 11,
77+
name: 'Search API',
78+
description: null,
79+
base_url: 'https://search.example.com',
80+
logo_url: null,
81+
category: 'search',
82+
status: 'active',
83+
created_at: 1,
84+
updated_at: 1,
85+
},
86+
{
87+
id: 102,
88+
developer_id: 11,
89+
name: 'Chat API',
90+
description: null,
91+
base_url: 'https://chat.example.com',
92+
logo_url: null,
93+
category: 'chat',
94+
status: 'active',
95+
created_at: 1,
96+
updated_at: 1,
97+
},
98+
{
99+
id: 103,
100+
developer_id: 11,
101+
name: 'Archived API',
102+
description: null,
103+
base_url: 'https://archive.example.com',
104+
logo_url: null,
105+
category: 'archive',
106+
status: 'archived',
107+
created_at: 1,
108+
updated_at: 1,
109+
},
110+
];
111+
112+
class FakeApiRepository implements ApiRepository {
113+
constructor(private readonly apis: Api[]) {}
114+
115+
async listByDeveloper(developerId: number, filters: ApiListFilters = {}): Promise<Api[]> {
116+
let results = this.apis.filter((api) => api.developer_id === developerId);
117+
if (filters.status) {
118+
results = results.filter((api) => api.status === filters.status);
119+
}
120+
if (typeof filters.offset === 'number') {
121+
results = results.slice(filters.offset);
122+
}
123+
if (typeof filters.limit === 'number') {
124+
results = results.slice(0, filters.limit);
125+
}
126+
return results;
127+
}
128+
}
129+
130+
const createDeveloperRepository = (profile?: Developer): DeveloperRepository => ({
131+
async findByUserId(userId: string) {
132+
if (profile && profile.user_id === userId) {
133+
return profile;
134+
}
135+
return undefined;
136+
},
137+
});
138+
139+
const usageEventsForApis = () =>
140+
new InMemoryUsageEventsRepository([
141+
{
142+
id: 'evt-search-1',
143+
developerId: 'dev-1',
144+
apiId: '101',
145+
endpoint: '/v1/search',
146+
userId: 'user-a',
147+
occurredAt: new Date('2026-02-01T01:00:00.000Z'),
148+
revenue: 100n,
149+
},
150+
{
151+
id: 'evt-search-2',
152+
developerId: 'dev-1',
153+
apiId: '101',
154+
endpoint: '/v1/search',
155+
userId: 'user-b',
156+
occurredAt: new Date('2026-02-01T02:00:00.000Z'),
157+
revenue: 200n,
158+
},
159+
{
160+
id: 'evt-chat-1',
161+
developerId: 'dev-1',
162+
apiId: '102',
163+
endpoint: '/v1/send',
164+
userId: 'user-c',
165+
occurredAt: new Date('2026-02-02T01:00:00.000Z'),
166+
revenue: 150n,
167+
},
168+
{
169+
id: 'evt-other',
170+
developerId: 'dev-2',
171+
apiId: '101',
172+
endpoint: '/v1/search',
173+
userId: 'user-z',
174+
occurredAt: new Date('2026-02-03T01:00:00.000Z'),
175+
revenue: 999n,
176+
},
177+
]);
178+
179+
const createDeveloperApisApp = () =>
180+
createApp({
181+
usageEventsRepository: usageEventsForApis(),
182+
developerRepository: createDeveloperRepository(developerProfile),
183+
apiRepository: new FakeApiRepository(sampleApis),
184+
});
185+
58186
test('GET /api/developers/analytics returns 401 when unauthenticated', async () => {
59187
const app = createApp({ usageEventsRepository: seedRepository() });
60188
const response = await request(app).get('/api/developers/analytics');
@@ -136,6 +264,51 @@ test('GET /api/developers/analytics filters by apiId and blocks non-owned API',
136264
assert.equal(blocked.status, 403);
137265
});
138266

267+
test('GET /api/developers/apis returns 401 when unauthenticated', async () => {
268+
const response = await request(createDeveloperApisApp()).get('/api/developers/apis');
269+
assert.equal(response.status, 401);
270+
});
271+
272+
test('GET /api/developers/apis returns 404 when developer profile is missing', async () => {
273+
const app = createApp({
274+
usageEventsRepository: usageEventsForApis(),
275+
developerRepository: createDeveloperRepository(undefined),
276+
apiRepository: new FakeApiRepository(sampleApis),
277+
});
278+
const response = await request(app).get('/api/developers/apis').set('x-user-id', 'dev-1');
279+
assert.equal(response.status, 404);
280+
});
281+
282+
test('GET /api/developers/apis validates status query parameter', async () => {
283+
const response = await request(createDeveloperApisApp())
284+
.get('/api/developers/apis?status=unknown')
285+
.set('x-user-id', 'dev-1');
286+
assert.equal(response.status, 400);
287+
});
288+
289+
test('GET /api/developers/apis lists APIs with stats, filters, and pagination', async () => {
290+
const app = createDeveloperApisApp();
291+
const fullResponse = await request(app).get('/api/developers/apis').set('x-user-id', 'dev-1');
292+
assert.equal(fullResponse.status, 200);
293+
assert.deepEqual(fullResponse.body.data, [
294+
{ id: 101, name: 'Search API', status: 'active', callCount: 2, revenue: '300' },
295+
{ id: 102, name: 'Chat API', status: 'active', callCount: 1, revenue: '150' },
296+
{ id: 103, name: 'Archived API', status: 'archived', callCount: 0 },
297+
]);
298+
299+
const limited = await request(app)
300+
.get('/api/developers/apis?limit=1&offset=1')
301+
.set('x-user-id', 'dev-1');
302+
assert.deepEqual(limited.body.data, [
303+
{ id: 102, name: 'Chat API', status: 'active', callCount: 1, revenue: '150' },
304+
]);
305+
306+
const filtered = await request(app)
307+
.get('/api/developers/apis?status=archived')
308+
.set('x-user-id', 'dev-1');
309+
assert.deepEqual(filtered.body.data, [
310+
{ id: 103, name: 'Archived API', status: 'archived', callCount: 0 },
311+
]);
139312
// ── GET /api/apis/:id ────────────────────────────────────────────────────────
140313

141314
const buildApiRepo = () => {

src/app.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import {
66
type GroupBy,
77
type UsageEventsRepository,
88
} from './repositories/usageEventsRepository.js';
9+
import { defaultApiRepository, type ApiRepository } from './repositories/apiRepository.js';
10+
import { defaultDeveloperRepository, type DeveloperRepository } from './repositories/developerRepository.js';
11+
import { apiStatusEnum, type ApiStatus } from './db/schema.js';
912
import type { ApiRepository } from './repositories/apiRepository.js';
1013
import { requireAuth, type AuthenticatedLocals } from './middleware/requireAuth.js';
1114
import { buildDeveloperAnalytics } from './services/developerAnalytics.js';
@@ -16,6 +19,7 @@ import { requestLogger } from './middleware/logging.js';
1619
interface AppDependencies {
1720
usageEventsRepository: UsageEventsRepository;
1821
apiRepository: ApiRepository;
22+
developerRepository: DeveloperRepository;
1923
}
2024

2125
const isValidGroupBy = (value: string): value is GroupBy =>
@@ -33,10 +37,26 @@ const parseDate = (value: unknown): Date | null => {
3337
return date;
3438
};
3539

40+
const parseNonNegativeIntegerParam = (
41+
value: unknown
42+
): { value?: number; invalid: boolean } => {
43+
if (typeof value !== 'string' || value.trim() === '') {
44+
return { value: undefined, invalid: false };
45+
}
46+
47+
const parsed = Number(value);
48+
if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed)) {
49+
return { value: undefined, invalid: true };
50+
}
51+
return { value: parsed, invalid: false };
52+
};
53+
3654
export const createApp = (dependencies?: Partial<AppDependencies>) => {
3755
const app = express();
3856
const usageEventsRepository =
3957
dependencies?.usageEventsRepository ?? new InMemoryUsageEventsRepository();
58+
const apiRepository = dependencies?.apiRepository ?? defaultApiRepository;
59+
const developerRepository = dependencies?.developerRepository ?? defaultDeveloperRepository;
4060

4161
app.use(requestIdMiddleware);
4262
// Lazy singleton for production Drizzle repo; injected repo is used in tests.
@@ -120,6 +140,69 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
120140
res.json({ calls: 0, period: 'current' });
121141
});
122142

143+
app.get('/api/developers/apis', requireAuth, async (req, res: express.Response<unknown, AuthenticatedLocals>) => {
144+
const user = res.locals.authenticatedUser;
145+
if (!user) {
146+
res.status(401).json({ error: 'Unauthorized' });
147+
return;
148+
}
149+
150+
const developer = await developerRepository.findByUserId(user.id);
151+
if (!developer) {
152+
res.status(404).json({ error: 'Developer profile not found' });
153+
return;
154+
}
155+
156+
const statusParam = typeof req.query.status === 'string' ? req.query.status : undefined;
157+
let statusFilter: ApiStatus | undefined;
158+
if (statusParam) {
159+
if (!apiStatusEnum.includes(statusParam as ApiStatus)) {
160+
res
161+
.status(400)
162+
.json({ error: `status must be one of: ${apiStatusEnum.join(', ')}` });
163+
return;
164+
}
165+
statusFilter = statusParam as ApiStatus;
166+
}
167+
168+
const limitParam = parseNonNegativeIntegerParam(req.query.limit);
169+
if (limitParam.invalid) {
170+
res.status(400).json({ error: 'limit must be a non-negative integer' });
171+
return;
172+
}
173+
174+
const offsetParam = parseNonNegativeIntegerParam(req.query.offset);
175+
if (offsetParam.invalid) {
176+
res.status(400).json({ error: 'offset must be a non-negative integer' });
177+
return;
178+
}
179+
180+
const apis = await apiRepository.listByDeveloper(developer.id, {
181+
status: statusFilter,
182+
...(typeof limitParam.value === 'number' ? { limit: limitParam.value } : {}),
183+
...(typeof offsetParam.value === 'number' ? { offset: offsetParam.value } : {}),
184+
});
185+
186+
const usageStats = await usageEventsRepository.aggregateByDeveloper(user.id);
187+
const statsByApi = new Map(usageStats.map((stat) => [stat.apiId, stat]));
188+
189+
const payload = apis.map((api) => {
190+
const stats = statsByApi.get(String(api.id));
191+
const entry: { id: number; name: string; status: ApiStatus; callCount: number; revenue?: string } = {
192+
id: api.id,
193+
name: api.name,
194+
status: api.status,
195+
callCount: stats?.calls ?? 0,
196+
};
197+
if (stats) {
198+
entry.revenue = stats.revenue.toString();
199+
}
200+
return entry;
201+
});
202+
203+
res.json({ data: payload });
204+
});
205+
123206
app.get('/api/developers/analytics', requireAuth, async (req, res: express.Response<unknown, AuthenticatedLocals>) => {
124207
const user = res.locals.authenticatedUser;
125208
if (!user) {

src/repositories/apiRepository.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,36 @@
1+
import { eq } from 'drizzle-orm';
2+
import { db, schema } from '../db/index.js';
3+
import type { Api, ApiStatus } from '../db/schema.js';
4+
5+
export interface ApiListFilters {
6+
status?: ApiStatus;
7+
limit?: number;
8+
offset?: number;
9+
}
10+
11+
export interface ApiRepository {
12+
listByDeveloper(developerId: number, filters?: ApiListFilters): Promise<Api[]>;
13+
}
14+
15+
export const defaultApiRepository: ApiRepository = {
16+
async listByDeveloper(developerId, filters = {}) {
17+
let query = db.select().from(schema.apis).where(eq(schema.apis.developer_id, developerId));
18+
19+
if (filters.status) {
20+
query = query.where(eq(schema.apis.status, filters.status));
21+
}
22+
23+
if (typeof filters.limit === 'number') {
24+
query = query.limit(filters.limit);
25+
}
26+
27+
if (typeof filters.offset === 'number') {
28+
query = query.offset(filters.offset);
29+
}
30+
31+
return query;
32+
},
33+
};
134
export interface ApiDeveloperInfo {
235
name: string | null;
336
website: string | null;

src/repositories/developerRepository.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ import { eq } from 'drizzle-orm';
22
import { db, schema } from '../db/index.js';
33
import type { Developer, NewDeveloper } from '../db/schema.js';
44

5+
export interface DeveloperRepository {
6+
findByUserId(userId: string): Promise<Developer | undefined>;
7+
}
8+
9+
export const defaultDeveloperRepository: DeveloperRepository = {
10+
findByUserId,
11+
};
12+
513
export async function findByUserId(userId: string): Promise<Developer | undefined> {
614
const rows = await db
715
.select()

0 commit comments

Comments
 (0)