Skip to content

Commit 9918bfb

Browse files
authored
feat: add query layer (CM-1059) (#3942)
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent 68e5d89 commit 9918bfb

11 files changed

Lines changed: 527 additions & 16 deletions

File tree

backend/src/api/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as http from 'http'
77
import os from 'os'
88
import { QueryTypes } from 'sequelize'
99

10+
import { BadRequestError } from '@crowd/common'
1011
import { getDbConnection } from '@crowd/data-access-layer/src/database'
1112
import { getServiceLogger } from '@crowd/logging'
1213
import { getOpensearchClient } from '@crowd/opensearch'
@@ -147,6 +148,14 @@ setImmediate(async () => {
147148

148149
app.use(bodyParser.urlencoded({ limit: '5mb', extended: true }))
149150

151+
app.use((err: any, req: any, res: any, next: any) => {
152+
if (err.type === 'entity.parse.failed') {
153+
next(new BadRequestError('Invalid JSON body'))
154+
return
155+
}
156+
next(err)
157+
})
158+
150159
app.use((req, res, next) => {
151160
// @ts-ignore
152161
req.userData = {

backend/src/api/public/index.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
11
import { Router } from 'express'
22

3-
import { AUTH0_CONFIG } from '../../conf'
4-
53
import { errorHandler } from './middlewares/errorHandler'
6-
import { oauth2Middleware } from './middlewares/oauth2Middleware'
7-
import { staticApiKeyMiddleware } from './middlewares/staticApiKeyMiddleware'
84
import { v1Router } from './v1'
9-
import { devStatsRouter } from './v1/dev-stats'
105

116
export function publicRouter(): Router {
127
const router = Router()
138

14-
router.use('/v1/dev-stats', staticApiKeyMiddleware(), devStatsRouter())
15-
router.use('/v1', oauth2Middleware(AUTH0_CONFIG), v1Router())
9+
router.use('/v1', v1Router())
1610
router.use(errorHandler)
1711

1812
return router

backend/src/api/public/middlewares/errorHandler.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@ export const errorHandler: ErrorRequestHandler = (
3535
}
3636

3737
req.log.error(
38-
{ error, url: req.url, method: req.method, query: req.query, body: req.body },
38+
{
39+
error: { name: error?.name, message: error?.message, stack: error?.stack },
40+
url: req.url,
41+
method: req.method,
42+
query: req.query,
43+
body: req.body,
44+
},
3945
'Unhandled error in public API',
4046
)
4147

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type { Request, Response } from 'express'
2+
import { z } from 'zod'
3+
4+
import {
5+
findMembersByGithubHandles,
6+
findVerifiedEmailsByMemberIds,
7+
optionsQx,
8+
resolveAffiliationsByMemberIds,
9+
} from '@crowd/data-access-layer'
10+
11+
import { ok } from '@/utils/api'
12+
import { validateOrThrow } from '@/utils/validation'
13+
14+
const MAX_HANDLES = 100
15+
const DEFAULT_PAGE_SIZE = 20
16+
17+
const bodySchema = z.object({
18+
githubHandles: z
19+
.array(z.string().trim().min(1).toLowerCase())
20+
.min(1)
21+
.max(MAX_HANDLES, `Maximum ${MAX_HANDLES} handles per request`),
22+
})
23+
24+
const querySchema = z.object({
25+
page: z.coerce.number().int().min(1).default(1),
26+
pageSize: z.coerce.number().int().min(1).max(MAX_HANDLES).default(DEFAULT_PAGE_SIZE),
27+
})
28+
29+
export async function getAffiliations(req: Request, res: Response): Promise<void> {
30+
const { githubHandles } = validateOrThrow(bodySchema, req.body)
31+
const { page, pageSize } = validateOrThrow(querySchema, req.query)
32+
const qx = optionsQx(req)
33+
34+
const offset = (page - 1) * pageSize
35+
36+
// Step 1: find all verified members across all handles
37+
const allMemberRows = await findMembersByGithubHandles(qx, githubHandles)
38+
39+
const foundHandles = new Set(allMemberRows.map((r) => r.githubHandle.toLowerCase()))
40+
const notFound = githubHandles.filter((h) => !foundHandles.has(h))
41+
42+
const pageMemberRows = allMemberRows.slice(offset, offset + pageSize)
43+
44+
if (pageMemberRows.length === 0) {
45+
ok(res, {
46+
total: githubHandles.length,
47+
totalFound: allMemberRows.length,
48+
page,
49+
pageSize,
50+
contributorsInPage: 0,
51+
contributors: [],
52+
notFound,
53+
})
54+
return
55+
}
56+
57+
const memberIds = pageMemberRows.map((r) => r.memberId)
58+
59+
// Step 2: fetch verified emails for current page
60+
const emailRows = await findVerifiedEmailsByMemberIds(qx, memberIds)
61+
62+
const emailsByMember = new Map<string, string[]>()
63+
for (const row of emailRows) {
64+
const list = emailsByMember.get(row.memberId) ?? []
65+
list.push(row.email)
66+
emailsByMember.set(row.memberId, list)
67+
}
68+
69+
// Step 3: resolve affiliations for current page only
70+
const affiliationsByMember = await resolveAffiliationsByMemberIds(qx, memberIds)
71+
72+
// Step 4: build response
73+
const contributors = pageMemberRows.map((member) => ({
74+
githubHandle: member.githubHandle,
75+
name: member.displayName,
76+
emails: emailsByMember.get(member.memberId) ?? [],
77+
affiliations: affiliationsByMember.get(member.memberId) ?? [],
78+
}))
79+
80+
ok(res, {
81+
total: githubHandles.length,
82+
totalFound: allMemberRows.length,
83+
page,
84+
pageSize,
85+
contributorsInPage: contributors.length,
86+
contributors,
87+
notFound,
88+
})
89+
}

backend/src/api/public/v1/dev-stats/index.ts renamed to backend/src/api/public/v1/affiliations/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,19 @@ import { Router } from 'express'
22

33
import { createRateLimiter } from '@/api/apiRateLimiter'
44
import { requireScopes } from '@/api/public/middlewares/requireScopes'
5+
import { safeWrap } from '@/middlewares/errorMiddleware'
56
import { SCOPES } from '@/security/scopes'
67

8+
import { getAffiliations } from './getAffiliations'
9+
710
const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 })
811

9-
export function devStatsRouter(): Router {
12+
export function memberOrganizationAffiliationsRouter(): Router {
1013
const router = Router()
1114

1215
router.use(rateLimiter)
1316

14-
router.post('/affiliations', requireScopes([SCOPES.READ_AFFILIATIONS]), (_req, res) => {
15-
res.json({ status: 'ok' })
16-
})
17+
router.post('/', requireScopes([SCOPES.READ_AFFILIATIONS]), safeWrap(getAffiliations))
1718

1819
return router
1920
}

backend/src/api/public/v1/index.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
import { Router } from 'express'
22

3+
import { NotFoundError } from '@crowd/common'
4+
5+
import { AUTH0_CONFIG } from '../../../conf'
6+
import { oauth2Middleware } from '../middlewares/oauth2Middleware'
7+
import { staticApiKeyMiddleware } from '../middlewares/staticApiKeyMiddleware'
8+
9+
import { memberOrganizationAffiliationsRouter } from './affiliations'
310
import { membersRouter } from './members'
411
import { organizationsRouter } from './organizations'
512

613
export function v1Router(): Router {
714
const router = Router()
815

9-
router.use('/members', membersRouter())
10-
router.use('/organizations', organizationsRouter())
16+
router.use('/members', oauth2Middleware(AUTH0_CONFIG), membersRouter())
17+
router.use('/organizations', oauth2Middleware(AUTH0_CONFIG), organizationsRouter())
18+
router.use('/affiliations', staticApiKeyMiddleware(), memberOrganizationAffiliationsRouter())
19+
20+
router.use(() => {
21+
throw new NotFoundError()
22+
})
1123

1224
return router
1325
}

0 commit comments

Comments
 (0)