Skip to content

Commit 88efa96

Browse files
authored
Merge pull request #13 from StackExchange/PS-328-search-api
Search API + new pagination logic + dynamic filters
2 parents f29ad72 + 53e1167 commit 88efa96

File tree

13 files changed

+1249
-366
lines changed

13 files changed

+1249
-366
lines changed

plugins/stack-overflow-teams-backend/src/api/createStackOverflowApi.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const createStackOverflowApi = (baseUrl: string) => {
77
body?: any,
88
searchQuery?: string,
99
pageSize?: number,
10+
page?: number,
1011
additionalParams?: Record<string, string>
1112
): Promise<T> => {
1213
let url = teamName
@@ -23,6 +24,10 @@ export const createStackOverflowApi = (baseUrl: string) => {
2324
queryParams.append('pageSize', pageSize.toString());
2425
}
2526

27+
if (page) {
28+
queryParams.append('page', page.toString());
29+
}
30+
2631
if (additionalParams) {
2732
Object.entries(additionalParams).forEach(([key, value]) => {
2833
queryParams.append(key, value);
@@ -57,12 +62,12 @@ export const createStackOverflowApi = (baseUrl: string) => {
5762

5863
return {
5964
GET: <T>(endpoint: string, authToken: string, teamName?: string, additionalParams?: Record<string, string>) =>
60-
request<T>(endpoint, 'GET', authToken, teamName, undefined, undefined, undefined, additionalParams),
65+
request<T>(endpoint, 'GET', authToken, teamName, undefined, undefined, undefined, undefined, additionalParams),
6166

6267
POST: <T>(endpoint: string, body: any, authToken: string, teamName?: string) =>
6368
request<T>(endpoint, 'POST', authToken, teamName, body),
6469

65-
SEARCH: <T>(endpoint: string, searchQuery: string, authToken: string, teamName?: string) =>
66-
request<T>(endpoint, 'GET', authToken, teamName, undefined, searchQuery, 30),
70+
SEARCH: <T>(endpoint: string, searchQuery: string, authToken: string, teamName?: string, page?: number) =>
71+
request<T>(endpoint, 'GET', authToken, teamName, undefined, searchQuery, 30, page),
6772
};
68-
};
73+
};

plugins/stack-overflow-teams-backend/src/router.ts

Lines changed: 128 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Router from 'express-promise-router';
44
import {
55
StackOverflowAPI,
66
StackOverflowConfig,
7+
QuestionFilters,
78
} from './services/StackOverflowService/types';
89
import { createStackOverflowAuth } from './api';
910

@@ -51,6 +52,45 @@ export async function createRouter({
5152
}
5253
}
5354

55+
// Helper function to build question filters from query parameters
56+
function buildQuestionFilters(query: any): QuestionFilters | undefined {
57+
const filters: QuestionFilters = {};
58+
let hasFilters = false;
59+
60+
if (query.sort && ['activity', 'creation', 'score'].includes(query.sort)) {
61+
filters.sort = query.sort;
62+
hasFilters = true;
63+
}
64+
65+
if (query.order && ['asc', 'desc'].includes(query.order)) {
66+
filters.order = query.order;
67+
hasFilters = true;
68+
}
69+
70+
if (query.isAnswered !== undefined) {
71+
filters.isAnswered = query.isAnswered === 'true';
72+
hasFilters = true;
73+
}
74+
75+
if (query.page !== undefined) {
76+
const page = parseInt(query.page, 10);
77+
if (!isNaN(page) && page > 0) {
78+
filters.page = page;
79+
hasFilters = true;
80+
}
81+
}
82+
83+
if (query.pageSize !== undefined) {
84+
const pageSize = parseInt(query.pageSize, 10);
85+
if (!isNaN(pageSize) && pageSize > 0) {
86+
filters.pageSize = pageSize;
87+
hasFilters = true;
88+
}
89+
}
90+
91+
return hasFilters ? filters : undefined;
92+
}
93+
5494
// OAuth Authentication routes
5595

5696
router.get('/auth/start', async (_req: Request, res: Response) => {
@@ -256,6 +296,7 @@ export async function createRouter({
256296
}
257297
});
258298

299+
// Updated questions route with filtering support
259300
router.get('/questions', async (req: Request, res: Response) => {
260301
try {
261302
const authToken = getValidAuthToken(req, res);
@@ -264,17 +305,99 @@ export async function createRouter({
264305
.status(401)
265306
.json({ error: 'Missing Stack Overflow Teams Access Token' });
266307
}
267-
const questions = await stackOverflowService.getQuestions(authToken);
308+
309+
const filters = buildQuestionFilters(req.query);
310+
const questions = await stackOverflowService.getQuestions(authToken, filters);
268311
return res.send(questions);
269312
} catch (error: any) {
270-
// Fix type issue when including the error for some reason
271313
logger.error('Error fetching questions', { error });
272314
return res.status(500).send({
273315
error: `Failed to fetch questions from the Stack Overflow instance`,
274316
});
275317
}
276318
});
277319

320+
// Convenience routes for common question filtering scenarios
321+
router.get('/questions/active', async (req: Request, res: Response) => {
322+
try {
323+
const authToken = getValidAuthToken(req, res);
324+
if (!authToken) {
325+
return res
326+
.status(401)
327+
.json({ error: 'Missing Stack Overflow Teams Access Token' });
328+
}
329+
330+
const page = req.query.page ? parseInt(req.query.page as string, 10) : undefined;
331+
const questions = await stackOverflowService.getActiveQuestions(authToken, page);
332+
return res.send(questions);
333+
} catch (error: any) {
334+
logger.error('Error fetching active questions', { error });
335+
return res.status(500).send({
336+
error: `Failed to fetch active questions from the Stack Overflow instance`,
337+
});
338+
}
339+
});
340+
341+
router.get('/questions/newest', async (req: Request, res: Response) => {
342+
try {
343+
const authToken = getValidAuthToken(req, res);
344+
if (!authToken) {
345+
return res
346+
.status(401)
347+
.json({ error: 'Missing Stack Overflow Teams Access Token' });
348+
}
349+
350+
const page = req.query.page ? parseInt(req.query.page as string, 10) : undefined;
351+
const questions = await stackOverflowService.getNewestQuestions(authToken, page);
352+
return res.send(questions);
353+
} catch (error: any) {
354+
logger.error('Error fetching newest questions', { error });
355+
return res.status(500).send({
356+
error: `Failed to fetch newest questions from the Stack Overflow instance`,
357+
});
358+
}
359+
});
360+
361+
router.get('/questions/top-scored', async (req: Request, res: Response) => {
362+
try {
363+
const authToken = getValidAuthToken(req, res);
364+
if (!authToken) {
365+
return res
366+
.status(401)
367+
.json({ error: 'Missing Stack Overflow Teams Access Token' });
368+
}
369+
370+
const page = req.query.page ? parseInt(req.query.page as string, 10) : undefined;
371+
const questions = await stackOverflowService.getTopScoredQuestions(authToken, page);
372+
return res.send(questions);
373+
} catch (error: any) {
374+
logger.error('Error fetching top scored questions', { error });
375+
return res.status(500).send({
376+
error: `Failed to fetch top scored questions from the Stack Overflow instance`,
377+
});
378+
}
379+
});
380+
381+
router.get('/questions/unanswered', async (req: Request, res: Response) => {
382+
try {
383+
const authToken = getValidAuthToken(req, res);
384+
if (!authToken) {
385+
return res
386+
.status(401)
387+
.json({ error: 'Missing Stack Overflow Teams Access Token' });
388+
}
389+
390+
const page = req.query.page ? parseInt(req.query.page as string, 10) : undefined;
391+
const questions = await stackOverflowService.getUnansweredQuestions(authToken, page);
392+
return res.send(questions);
393+
} catch (error: any) {
394+
logger.error('Error fetching unanswered questions', { error });
395+
return res.status(500).send({
396+
error: `Failed to fetch unanswered questions from the Stack Overflow instance`,
397+
});
398+
}
399+
});
400+
278401
router.get('/tags', async (req: Request, res: Response) => {
279402
try {
280403
const authToken = getValidAuthToken(req, res);
@@ -315,7 +438,7 @@ export async function createRouter({
315438
router.post('/search', async (req: Request, res: Response) => {
316439
try {
317440
const authToken = getValidAuthToken(req, res);
318-
const { query } = req.body;
441+
const { query, page } = req.body;
319442

320443
if (!authToken) {
321444
return res
@@ -325,6 +448,7 @@ export async function createRouter({
325448
const searchResults = await stackOverflowService.getSearch(
326449
query,
327450
authToken,
451+
page
328452
);
329453
return res.status(201).json(searchResults);
330454
} catch (error: any) {
@@ -367,4 +491,4 @@ export async function createRouter({
367491
});
368492

369493
return router;
370-
}
494+
}

plugins/stack-overflow-teams-backend/src/services/StackOverflowService/createStackOverflowService.ts

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
SearchItem,
66
StackOverflowAPI,
77
StackOverflowConfig,
8+
QuestionFilters,
89
Tag,
910
User,
1011
} from './types';
@@ -18,7 +19,6 @@ export async function createStackOverflowService({
1819
logger: LoggerService;
1920
}): Promise<StackOverflowAPI> {
2021
// LOGGER
21-
2222
logger.info('Initializing Stack Overflow Service');
2323

2424
if (config.baseUrl && config.teamName) {
@@ -32,11 +32,32 @@ export async function createStackOverflowService({
3232
baseUrl || 'https://api.stackoverflowteams.com',
3333
);
3434

35+
// Helper function to build query parameters
36+
const buildParams = (filters: Record<string, any>): Record<string, string> => {
37+
const params: Record<string, string> = {};
38+
39+
Object.entries(filters).forEach(([key, value]) => {
40+
if (value !== undefined && value !== null) {
41+
params[key] = value.toString();
42+
}
43+
});
44+
45+
return params;
46+
};
47+
3548
return {
36-
// GET
37-
getQuestions: authToken =>
38-
api.GET<PaginatedResponse<Question>>('/questions', authToken, teamName, { sort: 'creation', order: 'desc' }),
39-
getTags: (authToken, search?: string) => {
49+
getQuestions: (authToken: string, filters?: QuestionFilters) => {
50+
const params = buildParams({
51+
sort: filters?.sort || 'creation',
52+
order: filters?.order || 'desc',
53+
isAnswered: filters?.isAnswered,
54+
page: filters?.page,
55+
pageSize: filters?.pageSize || 30,
56+
});
57+
58+
return api.GET<PaginatedResponse<Question>>('/questions', authToken, teamName, params);
59+
},
60+
getTags: (authToken: string, search?: string) => {
4061
const params: Record<string, string> = { sort: 'postCount', order: 'desc' };
4162
if (search) {
4263
params.partialName = search;
@@ -46,6 +67,24 @@ export async function createStackOverflowService({
4667
getUsers: authToken =>
4768
api.GET<PaginatedResponse<User>>('/users', authToken, teamName),
4869
getMe: authToken => api.GET<User>('/users/me', authToken, teamName),
70+
71+
// Convenience methods for common filtering scenarios
72+
getActiveQuestions: (authToken: string, page?: number) =>
73+
api.GET<PaginatedResponse<Question>>('/questions', authToken, teamName,
74+
buildParams({ sort: 'activity', order: 'desc', page })),
75+
76+
getNewestQuestions: (authToken: string, page?: number) =>
77+
api.GET<PaginatedResponse<Question>>('/questions', authToken, teamName,
78+
buildParams({ sort: 'creation', order: 'desc', page })),
79+
80+
getTopScoredQuestions: (authToken: string, page?: number) =>
81+
api.GET<PaginatedResponse<Question>>('/questions', authToken, teamName,
82+
buildParams({ sort: 'score', order: 'desc', page })),
83+
84+
getUnansweredQuestions: (authToken: string, page?: number) =>
85+
api.GET<PaginatedResponse<Question>>('/questions', authToken, teamName,
86+
buildParams({ isAnswered: false, sort: 'creation', order: 'desc', page })),
87+
4988
// POST
5089
postQuestions: (
5190
title: string,
@@ -60,12 +99,13 @@ export async function createStackOverflowService({
6099
teamName,
61100
),
62101
// SEARCH
63-
getSearch: (query: string, authToken: string) =>
102+
getSearch: (query: string, authToken: string, page?: number) =>
64103
api.SEARCH<PaginatedResponse<SearchItem>>(
65104
'/search',
66105
query,
67106
authToken,
68107
teamName,
108+
page,
69109
),
70110
};
71111
}

plugins/stack-overflow-teams-backend/src/services/StackOverflowService/types.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,19 +60,32 @@ export type StackOverflowConfig = {
6060
authUrl?: string;
6161
};
6262

63-
export interface StackOverflowAPI {
64-
getQuestions(authToken: string): Promise<PaginatedResponse<Question>>;
65-
getTags(authToken: string, search?: string): Promise<PaginatedResponse<Tag>>;
66-
getUsers(authToken: string): Promise<PaginatedResponse<User>>;
67-
getMe(authToken: string): Promise<User>;
68-
postQuestions(
69-
title: string,
70-
body: string,
71-
tags: string[],
72-
authToken: string,
73-
): Promise<Question>;
74-
getSearch(
75-
query: string,
76-
authToken: string,
77-
): Promise<PaginatedResponse<SearchItem>>;
63+
export interface QuestionFilters {
64+
sort?: 'activity' | 'creation' | 'score';
65+
order?: 'asc' | 'desc';
66+
isAnswered?: boolean;
67+
page?: number;
68+
pageSize?: number;
7869
}
70+
71+
export interface StackOverflowAPI {
72+
// Enhanced questions method with filtering
73+
getQuestions: (authToken: string, filters?: QuestionFilters) => Promise<PaginatedResponse<Question>>;
74+
75+
// Keep original signatures for tags and users
76+
getTags: (authToken: string, search?: string) => Promise<PaginatedResponse<Tag>>;
77+
getUsers: (authToken: string) => Promise<PaginatedResponse<User>>;
78+
getMe: (authToken: string) => Promise<User>;
79+
80+
// Convenience methods for common question filtering scenarios
81+
getActiveQuestions: (authToken: string, page?: number) => Promise<PaginatedResponse<Question>>;
82+
getNewestQuestions: (authToken: string, page?: number) => Promise<PaginatedResponse<Question>>;
83+
getTopScoredQuestions: (authToken: string, page?: number) => Promise<PaginatedResponse<Question>>;
84+
getUnansweredQuestions: (authToken: string, page?: number) => Promise<PaginatedResponse<Question>>;
85+
86+
// POST
87+
postQuestions: (title: string, body: string, tags: string[], authToken: string) => Promise<Question>;
88+
89+
// SEARCH
90+
getSearch: (query: string, authToken: string, page?: number) => Promise<PaginatedResponse<SearchItem>>;
91+
}

0 commit comments

Comments
 (0)