Skip to content

Commit 20e01cf

Browse files
fix(public-api): address PR review feedback
- Unskip custom feed posts test (uses executeGraphql, doesn't need external service) - Clarify orderBy defaults to algorithmic ranking when not provided - Improve disableEngagementFilter description to explain what it does - Remove redundant ranking parameter from custom feed posts endpoint (controlled by feed settings) - Remove unnecessary profile fields (company, title, readme) from public API - Move tools search endpoint to stack routes for better organization (/profile/stack/search) - Update AGENTS.md with guidelines to prevent similar issues: - Test coverage best practices - API documentation clarity - Avoiding redundant query parameters - Endpoint organization - Profile field exposure considerations Co-authored-by: Ido Shamun <idoshamun@users.noreply.github.com>
1 parent 98b547a commit 20e01cf

File tree

9 files changed

+327
-179
lines changed

9 files changed

+327
-179
lines changed

__tests__/routes/public/customFeeds.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,7 @@ describe('GET /public/v1/feeds/custom', () => {
7272
});
7373

7474
describe('GET /public/v1/feeds/custom/:feedId', () => {
75-
// This test requires external feed service on port 6000 which isn't available in tests
76-
it.skip('should get custom feed posts', async () => {
75+
it('should get custom feed posts', async () => {
7776
const token = await createTokenForUser(state.con, '5');
7877

7978
// Create a feed first

__tests__/routes/public/stack.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,76 @@ const createTestTool = async (title: string) => {
1111
title,
1212
titleNormalized: title.toLowerCase(),
1313
faviconUrl: 'https://example.com/icon.png',
14+
faviconSource: 'custom',
1415
});
1516
};
1617

18+
describe('GET /public/v1/profile/stack/search', () => {
19+
beforeEach(async () => {
20+
// Create some test tools
21+
await state.con.getRepository(DatasetTool).save([
22+
{
23+
title: 'TypeScript',
24+
titleNormalized: 'typescript',
25+
faviconUrl: 'https://example.com/ts.png',
26+
faviconSource: 'custom',
27+
},
28+
{
29+
title: 'JavaScript',
30+
titleNormalized: 'javascript',
31+
faviconUrl: 'https://example.com/js.png',
32+
faviconSource: 'custom',
33+
},
34+
{
35+
title: 'Python',
36+
titleNormalized: 'python',
37+
faviconUrl: 'https://example.com/py.png',
38+
faviconSource: 'custom',
39+
},
40+
]);
41+
});
42+
43+
it('should search for tools', async () => {
44+
const token = await createTokenForUser(state.con, '5');
45+
46+
const { body } = await request(state.app.server)
47+
.get('/public/v1/profile/stack/search')
48+
.query({ query: 'Type' })
49+
.set('Authorization', `Bearer ${token}`)
50+
.expect(200);
51+
52+
expect(Array.isArray(body.data)).toBe(true);
53+
expect(body.data.length).toBeGreaterThan(0);
54+
expect(body.data[0]).toMatchObject({
55+
id: expect.any(String),
56+
title: expect.any(String),
57+
});
58+
});
59+
60+
it('should require query parameter', async () => {
61+
const token = await createTokenForUser(state.con, '5');
62+
63+
// Server returns 500 for schema validation errors due to global error handler
64+
await request(state.app.server)
65+
.get('/public/v1/profile/stack/search')
66+
.set('Authorization', `Bearer ${token}`)
67+
.expect(500);
68+
});
69+
70+
it('should return empty array for no matches', async () => {
71+
const token = await createTokenForUser(state.con, '5');
72+
73+
const { body } = await request(state.app.server)
74+
.get('/public/v1/profile/stack/search')
75+
.query({ query: 'xyznonexistent123' })
76+
.set('Authorization', `Bearer ${token}`)
77+
.expect(200);
78+
79+
expect(Array.isArray(body.data)).toBe(true);
80+
expect(body.data.length).toBe(0);
81+
});
82+
});
83+
1784
describe('GET /public/v1/profile/stack', () => {
1885
it('should return user stack', async () => {
1986
const token = await createTokenForUser(state.con, '5');

__tests__/routes/public/tools.ts

Lines changed: 0 additions & 71 deletions
This file was deleted.

src/routes/public/AGENTS.md

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,186 @@ IP limiting runs first to prevent token validation flooding. The generous IP lim
330330

331331
## Common Pitfalls
332332

333+
### Test Coverage
334+
335+
**Never skip tests without clear justification.** When the comment says "requires external service", verify this is actually true:
336+
337+
```typescript
338+
// BAD: Skipping test without investigation
339+
it.skip('should get custom feed posts', async () => {
340+
// This test requires external feed service on port 6000 which isn't available in tests
341+
...
342+
});
343+
344+
// GOOD: Test runs because executeGraphql doesn't need external services
345+
it('should get custom feed posts', async () => {
346+
const token = await createTokenForUser(state.con, '5');
347+
348+
const { body: createBody } = await request(state.app.server)
349+
.post('/public/v1/feeds/custom')
350+
.set('Authorization', `Bearer ${token}`)
351+
.send({ name: 'Posts Test Feed' })
352+
.expect(200);
353+
354+
const { body } = await request(state.app.server)
355+
.get(`/public/v1/feeds/custom/${createBody.id}`)
356+
.set('Authorization', `Bearer ${token}`)
357+
.expect(200);
358+
359+
expect(Array.isArray(body.data)).toBe(true);
360+
});
361+
```
362+
363+
**Most public API endpoints use `executeGraphql()`** which runs queries directly against the GraphQL schema - no external services needed. Check similar tests to confirm your endpoint works the same way before skipping.
364+
365+
### API Documentation Clarity
366+
367+
**Clarify technical terms and defaults in field descriptions.** API users may not understand domain-specific jargon:
368+
369+
```typescript
370+
// BAD: Unclear what happens by default
371+
orderBy: {
372+
type: 'string',
373+
enum: ['DATE', 'UPVOTES', 'DOWNVOTES', 'COMMENTS', 'CLICKS'],
374+
description: 'Sort order for the feed',
375+
}
376+
377+
// GOOD: Clear default behavior
378+
orderBy: {
379+
type: 'string',
380+
enum: ['DATE', 'UPVOTES', 'DOWNVOTES', 'COMMENTS', 'CLICKS'],
381+
description: 'Sort order for the feed (defaults to algorithmic ranking if not provided)',
382+
}
383+
384+
// BAD: Technical term without explanation
385+
disableEngagementFilter: {
386+
type: 'boolean',
387+
description: 'Disable engagement filter',
388+
}
389+
390+
// GOOD: Explains what the feature does
391+
disableEngagementFilter: {
392+
type: 'boolean',
393+
description: 'Disable engagement filter (when true, shows posts the user already clicked or saw in the feed)',
394+
}
395+
```
396+
397+
### Redundant Query Parameters
398+
399+
**Remove parameters that are controlled by other settings.** If a feed's ranking is stored in its configuration, don't expose it as a query parameter:
400+
401+
```typescript
402+
// BAD: Ranking is controlled by feed settings, shouldn't be a parameter
403+
fastify.get<{
404+
Params: { feedId: string };
405+
Querystring: { limit?: string; cursor?: string; ranking?: string };
406+
}>(
407+
'/:feedId',
408+
{
409+
querystring: {
410+
properties: {
411+
ranking: {
412+
type: 'string',
413+
enum: ['POPULARITY', 'TIME'],
414+
description: 'Ranking method', // Feed already has this setting!
415+
},
416+
},
417+
},
418+
},
419+
async (request, reply) => {
420+
const { ranking } = request.query;
421+
// Uses user-provided ranking instead of feed's setting
422+
}
423+
);
424+
425+
// GOOD: Use the feed's own settings
426+
fastify.get<{
427+
Params: { feedId: string };
428+
Querystring: { limit?: string; cursor?: string };
429+
}>(
430+
'/:feedId',
431+
{
432+
querystring: {
433+
properties: {
434+
limit: { ... },
435+
cursor: { ... },
436+
// No ranking parameter - feed controls its own ranking
437+
},
438+
},
439+
},
440+
async (request, reply) => {
441+
// Feed's orderBy setting controls ranking
442+
}
443+
);
444+
```
445+
446+
### Endpoint Organization
447+
448+
**Group related endpoints logically.** Tools/technologies used for stack management belong in the stack routes, not separate:
449+
450+
```typescript
451+
// BAD: Separate tools endpoint when it's only used for stack
452+
// src/routes/public/tools.ts
453+
export default async function (fastify: FastifyInstance): Promise<void> {
454+
fastify.get('/search', ...); // Search tools for adding to stack
455+
}
456+
457+
// src/routes/public/index.ts
458+
await fastify.register(toolsRoutes, { prefix: '/tools' });
459+
await fastify.register(stackRoutes, { prefix: '/profile/stack' });
460+
// Results in: /public/v1/tools/search and /public/v1/profile/stack
461+
462+
// GOOD: Tools search is part of stack routes
463+
// src/routes/public/stack.ts
464+
export default async function (fastify: FastifyInstance): Promise<void> {
465+
fastify.get('/search', ...); // Search tools for stack
466+
fastify.get('/', ...); // List stack items
467+
fastify.post('/', ...); // Add to stack
468+
// ... other stack operations
469+
}
470+
471+
// src/routes/public/index.ts
472+
await fastify.register(stackRoutes, { prefix: '/profile/stack' });
473+
// Results in: /public/v1/profile/stack/search and /public/v1/profile/stack
474+
```
475+
476+
This keeps related functionality together and makes the API more intuitive.
477+
478+
### Profile Field Exposure
479+
480+
**Only expose fields that make sense for the public API.** Some fields are better managed through the main app UI:
481+
482+
```typescript
483+
// BAD: Exposing fields that shouldn't be in public API
484+
fastify.patch<{
485+
Body: {
486+
name?: string;
487+
bio?: string;
488+
readme?: string; // Complex markdown field - better in UI
489+
company?: string; // Better managed in main app
490+
title?: string; // Better managed in main app
491+
timezone?: string;
492+
// ...
493+
};
494+
}>(...)
495+
496+
// GOOD: Only fields appropriate for API automation
497+
fastify.patch<{
498+
Body: {
499+
name?: string;
500+
bio?: string;
501+
timezone?: string;
502+
weekStart?: number;
503+
acceptedMarketing?: boolean;
504+
experienceLevel?: string;
505+
socialLinks?: Array<{ url: string; platform?: string }>;
506+
// No readme, company, title - use main app for those
507+
};
508+
}>(...)
509+
```
510+
511+
**Consider the use case**: The public API is for automation and AI agents. Complex or UI-heavy fields should stay in the web app where users can interact with rich editors and previews.
512+
333513
### Boolean Parameter Handling
334514

335515
**Never use `||` to provide null defaults for boolean parameters** - it incorrectly converts `false` to `null`:

0 commit comments

Comments
 (0)