Skip to content

Commit 61896b1

Browse files
authored
feat: add packages router and mock (CM-1218) (#4185)
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent 6d1be2b commit 61896b1

9 files changed

Lines changed: 1271 additions & 0 deletions

File tree

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,23 @@ import { Router } from 'express'
22

33
import { NotFoundError } from '@crowd/common'
44

5+
import { createRateLimiter } from '@/api/apiRateLimiter'
6+
import { safeWrap } from '@/middlewares/errorMiddleware'
7+
8+
// TODO: restore once read:stewardships is added to Auth0 staging tenant
9+
// import { SCOPES } from '@/security/scopes'
510
import { AUTH0_CONFIG } from '../../../conf'
611
import { oauth2Middleware } from '../middlewares/oauth2Middleware'
12+
// import { requireScopes } from '../middlewares/requireScopes'
713
import { staticApiKeyMiddleware } from '../middlewares/staticApiKeyMiddleware'
814

915
import { memberOrganizationAffiliationsRouter } from './affiliations'
1016
import { membersRouter } from './members'
1117
import { organizationsRouter } from './organizations'
18+
import { packagesRouter } from './packages'
19+
import { batchGetStewardship } from './packages/batchGetStewardship'
20+
21+
const packagesRateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 })
1222

1323
export function v1Router(): Router {
1424
const router = Router()
@@ -17,6 +27,16 @@ export function v1Router(): Router {
1727
router.use('/organizations', oauth2Middleware(AUTH0_CONFIG), organizationsRouter())
1828
router.use('/affiliations', staticApiKeyMiddleware(), memberOrganizationAffiliationsRouter())
1929

30+
router.post(
31+
/^\/packages:batch-stewardship\/?$/,
32+
oauth2Middleware(AUTH0_CONFIG),
33+
packagesRateLimiter,
34+
// TODO: restore once read:stewardships is added to Auth0 staging tenant
35+
// requireScopes([SCOPES.READ_STEWARDSHIPS]),
36+
safeWrap(batchGetStewardship),
37+
)
38+
router.use('/packages', oauth2Middleware(AUTH0_CONFIG), packagesRouter())
39+
2040
router.use(() => {
2141
throw new NotFoundError()
2242
})
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { Request, Response } from 'express'
2+
import { z } from 'zod'
3+
4+
import { ok } from '@/utils/api'
5+
import { validateOrThrow } from '@/utils/validation'
6+
7+
import { MOCK_DETAILS } from './mockData'
8+
9+
const MAX_PURLS = 100
10+
11+
const bodySchema = z.object({
12+
purls: z
13+
.array(z.string().trim().min(1))
14+
.min(1)
15+
.max(MAX_PURLS, `Maximum ${MAX_PURLS} purls per request`),
16+
})
17+
18+
interface StewardshipSummary {
19+
name: string
20+
ecosystem: string
21+
lifecycle: string
22+
health: number
23+
impact: number
24+
openVulns: { low: number; medium: number; high: number; critical: number }
25+
stewardship: string
26+
stewards: null
27+
lastActivityAt: null
28+
lastActivityDescription: null
29+
}
30+
31+
// TODO: replace with real DB queries once stewardship tables land
32+
export async function batchGetStewardship(req: Request, res: Response): Promise<void> {
33+
const { purls } = validateOrThrow(bodySchema, req.body)
34+
35+
const packages: Record<string, StewardshipSummary | null> = {}
36+
for (const purl of purls) {
37+
const detail = MOCK_DETAILS[purl]
38+
if (!detail) {
39+
packages[purl] = null
40+
} else {
41+
const openVulns = { low: 0, medium: 0, high: 0, critical: 0 }
42+
for (const advisory of detail.security.advisories) {
43+
if (advisory.severity in openVulns) {
44+
openVulns[advisory.severity] += 1
45+
}
46+
}
47+
packages[purl] = {
48+
name: detail.name,
49+
ecosystem: detail.ecosystem,
50+
lifecycle: detail.general.riskSignals.lifecycle,
51+
health: detail.general.healthScore.total,
52+
impact: detail.general.impact.impactScore,
53+
openVulns,
54+
stewardship: 'unassigned',
55+
stewards: null,
56+
lastActivityAt: null,
57+
lastActivityDescription: null,
58+
}
59+
}
60+
}
61+
62+
ok(res, { packages })
63+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { Request, Response } from 'express'
2+
import { z } from 'zod'
3+
4+
import { BadRequestError, NotFoundError } from '@crowd/common'
5+
6+
import { ok } from '@/utils/api'
7+
import { validateOrThrow } from '@/utils/validation'
8+
9+
import { MOCK_DETAILS } from './mockData'
10+
11+
const querySchema = z.object({
12+
purl: z.string().trim().min(1),
13+
})
14+
15+
// TODO: replace with real DB queries once packages DB is wired into the backend
16+
export async function getPackage(req: Request, res: Response): Promise<void> {
17+
const { purl } = validateOrThrow(querySchema, req.query)
18+
19+
if (!purl.startsWith('pkg:')) {
20+
throw new BadRequestError('Invalid purl format: must start with pkg:')
21+
}
22+
23+
const detail = MOCK_DETAILS[purl]
24+
if (!detail) {
25+
throw new NotFoundError()
26+
}
27+
28+
ok(res, detail)
29+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { Request, Response } from 'express'
2+
3+
import { ok } from '@/utils/api'
4+
5+
import { MOCK_METRICS } from './mockData'
6+
7+
// TODO: replace with real DB queries once packages DB is wired into the backend
8+
export async function getPackagesMetrics(req: Request, res: Response): Promise<void> {
9+
ok(res, MOCK_METRICS)
10+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Router } from 'express'
2+
3+
import { createRateLimiter } from '@/api/apiRateLimiter'
4+
// TODO: restore once read:packages + read:stewardships are added to Auth0 staging tenant
5+
// import { requireScopes } from '@/api/public/middlewares/requireScopes'
6+
import { safeWrap } from '@/middlewares/errorMiddleware'
7+
8+
// import { SCOPES } from '@/security/scopes'
9+
import { getPackage } from './getPackage'
10+
import { getPackagesMetrics } from './getPackagesMetrics'
11+
import { listPackages } from './listPackages'
12+
13+
const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 })
14+
15+
export function packagesRouter(): Router {
16+
const router = Router()
17+
18+
router.use(rateLimiter)
19+
20+
router.get(
21+
'/',
22+
// TODO: restore once read:packages + read:stewardships are added to Auth0 staging tenant
23+
// requireScopes([SCOPES.READ_PACKAGES, SCOPES.READ_STEWARDSHIPS], 'any'),
24+
safeWrap(listPackages),
25+
)
26+
27+
router.get(
28+
'/metrics',
29+
// TODO: restore once read:packages + read:stewardships are added to Auth0 staging tenant
30+
// requireScopes([SCOPES.READ_PACKAGES, SCOPES.READ_STEWARDSHIPS], 'any'),
31+
safeWrap(getPackagesMetrics),
32+
)
33+
34+
router.get(
35+
'/detail',
36+
// TODO: restore once read:packages + read:stewardships are added to Auth0 staging tenant
37+
// requireScopes([SCOPES.READ_PACKAGES, SCOPES.READ_STEWARDSHIPS], 'any'),
38+
safeWrap(getPackage),
39+
)
40+
41+
return router
42+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { Request, Response } from 'express'
2+
import { z } from 'zod'
3+
4+
import { ok } from '@/utils/api'
5+
import { validateOrThrow } from '@/utils/validation'
6+
7+
import { MOCK_DETAILS, MOCK_PACKAGES } from './mockData'
8+
9+
const DEFAULT_PAGE_SIZE = 20
10+
const MAX_PAGE_SIZE = 100
11+
const STALE_MONTHS = 18
12+
13+
const booleanQueryParam = z.preprocess((v) => v === 'true', z.boolean()).default(false)
14+
15+
const lifecycleValues = ['active', 'stable', 'declining', 'abandoned'] as const
16+
17+
const querySchema = z.object({
18+
page: z.coerce.number().int().min(1).default(1),
19+
pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
20+
ecosystem: z.string().trim().optional(),
21+
lifecycle: z.enum(lifecycleValues).optional(),
22+
busFactor1Only: booleanQueryParam,
23+
staleOnly: booleanQueryParam,
24+
unstewardedOnly: booleanQueryParam,
25+
sortBy: z.enum(['name', 'health', 'impact', 'openVulns']).default('name'),
26+
sortDir: z.enum(['asc', 'desc']).default('asc'),
27+
})
28+
29+
// TODO: replace with real DB queries once packages DB is wired into the backend
30+
export async function listPackages(req: Request, res: Response): Promise<void> {
31+
const {
32+
page,
33+
pageSize,
34+
ecosystem,
35+
lifecycle,
36+
busFactor1Only,
37+
staleOnly,
38+
unstewardedOnly,
39+
sortBy,
40+
sortDir,
41+
} = validateOrThrow(querySchema, req.query)
42+
43+
const staleThreshold = new Date()
44+
staleThreshold.setMonth(staleThreshold.getMonth() - STALE_MONTHS)
45+
46+
let filtered = MOCK_PACKAGES.filter((p) => {
47+
if (ecosystem && p.ecosystem !== ecosystem) return false
48+
if (lifecycle && p.lifecycle !== lifecycle) return false
49+
if (busFactor1Only && p.maintainerBusFactor !== 1) return false
50+
if (unstewardedOnly && p.stewardship !== 'unassigned') return false
51+
if (staleOnly) {
52+
const lastRelease = MOCK_DETAILS[p.purl]?.general.riskSignals.lastRelease
53+
if (!lastRelease || new Date(lastRelease) >= staleThreshold) return false
54+
}
55+
return true
56+
})
57+
58+
filtered = filtered.sort((a, b) => {
59+
let cmp = 0
60+
if (sortBy === 'name') {
61+
cmp = a.name.localeCompare(b.name)
62+
} else if (sortBy === 'health') {
63+
cmp = (a.health ?? 0) - (b.health ?? 0)
64+
} else if (sortBy === 'impact') {
65+
cmp = (a.impact ?? 0) - (b.impact ?? 0)
66+
} else if (sortBy === 'openVulns') {
67+
const sumA = a.openVulns.low + a.openVulns.medium + a.openVulns.high + a.openVulns.critical
68+
const sumB = b.openVulns.low + b.openVulns.medium + b.openVulns.high + b.openVulns.critical
69+
cmp = sumA - sumB
70+
}
71+
return sortDir === 'desc' ? -cmp : cmp
72+
})
73+
74+
const total = filtered.length
75+
const start = (page - 1) * pageSize
76+
const packages = filtered.slice(start, start + pageSize)
77+
78+
ok(res, {
79+
page,
80+
pageSize,
81+
total,
82+
filters: {
83+
ecosystem: ecosystem ?? null,
84+
lifecycle: lifecycle ?? null,
85+
busFactor1Only,
86+
staleOnly,
87+
unstewardedOnly,
88+
},
89+
sort: { by: sortBy, dir: sortDir },
90+
packages,
91+
})
92+
}

0 commit comments

Comments
 (0)