Skip to content

Commit bc1a7fc

Browse files
committed
feat: implment bulk radius api
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent 370e3f0 commit bc1a7fc

6 files changed

Lines changed: 541 additions & 8 deletions

File tree

backend/src/api/public/v1/akrites-external/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import { getAkritesExternalContactDetailBatch } from '../packages/getAkritesExte
1212
import { getAkritesExternalPackageDetail } from '../packages/getAkritesExternalPackageDetail'
1313
import { getAkritesExternalPackageDetailBatch } from '../packages/getAkritesExternalPackageDetailBatch'
1414
import { getBlastRadiusJob } from '../packages/getBlastRadiusJob'
15+
import { getBlastRadiusJobBatch } from '../packages/getBlastRadiusJobBatch'
1516
import { submitBlastRadiusJob } from '../packages/submitBlastRadiusJob'
17+
import { submitBlastRadiusJobBatch } from '../packages/submitBlastRadiusJobBatch'
1618

1719
const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 })
1820

@@ -75,7 +77,22 @@ export function akritesExternalRouter(): Router {
7577
const blastRadiusSubRouter = Router()
7678
blastRadiusSubRouter.use(requireScopes([SCOPES.READ_PACKAGES]))
7779
blastRadiusSubRouter.post('/jobs', blastRadiusRateLimiter, safeWrap(submitBlastRadiusJob))
80+
// Bulk submit multiplies Temporal workflow starts per request (up to
81+
// MAX_BLAST_RADIUS_JOBS_PER_BATCH), so it sits behind the same strict
82+
// blastRadiusRateLimiter as the single-job route, not the regular one.
83+
blastRadiusSubRouter.post(
84+
/^\/jobs:batch\/?$/,
85+
blastRadiusRateLimiter,
86+
safeWrap(submitBlastRadiusJobBatch),
87+
)
7888
blastRadiusSubRouter.get('/jobs/:analysisId', rateLimiter, safeWrap(getBlastRadiusJob))
89+
// Bulk poll is read-only, same cost profile as the other batch endpoints, so
90+
// it uses the regular rateLimiter.
91+
blastRadiusSubRouter.post(
92+
/^\/jobs:batch\/poll\/?$/,
93+
rateLimiter,
94+
safeWrap(getBlastRadiusJobBatch),
95+
)
7996
router.use('/blast-radius', blastRadiusSubRouter)
8097

8198
return router

backend/src/api/public/v1/akrites-external/openapi.yaml

Lines changed: 189 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,16 @@ tags:
5151
- name: Blast Radius
5252
description: >
5353
Advisory reachability analysis — submit (2a) and poll (2b) are both
54-
implemented. Submitting kicks off a Temporal workflow that runs the
55-
npm reachability pipeline (other ecosystems fail fast with
56-
ECOSYSTEM_NOT_SUPPORTED); poll returns job status and, once done,
57-
results. Rate-limited independently of the other akrites-external
58-
endpoints (default 5 requests/hour, configurable via
59-
AKRITES_BLAST_RADIUS_RATE_LIMIT_MAX / _WINDOW_MS). Same interim scope
60-
note as Advisories applies (read:packages, pending a dedicated
61-
read:advisories scope).
54+
implemented, each with a bulk counterpart. Submitting kicks off a
55+
Temporal workflow that runs the npm reachability pipeline (other
56+
ecosystems fail fast with ECOSYSTEM_NOT_SUPPORTED); poll returns job
57+
status and, once done, results. Bulk submit (jobs:batch) is capped at
58+
20 jobs per request (10 recommended as the default batch size) — each
59+
entry starts its own workflow — and stays behind the same strict rate
60+
limiter as the single-job route; bulk poll
61+
(jobs:batch/poll) is read-only and capped at 100 like the other batch
62+
endpoints. Same interim scope note as Advisories applies (read:packages,
63+
pending a dedicated read:advisories scope).
6264
6365
components:
6466
securitySchemes:
@@ -429,6 +431,69 @@ components:
429431
enum: [pending, running, done, failed]
430432
description: Always pending — the response is returned before the Temporal workflow runs.
431433

434+
BlastRadiusJobBatchRequest:
435+
type: object
436+
required: [jobs]
437+
properties:
438+
jobs:
439+
type: array
440+
minItems: 1
441+
maxItems: 20
442+
description: >
443+
Capped much lower than the 100-item read batches — each entry
444+
starts its own Temporal workflow, so the batch multiplies
445+
workflow starts (and reachability-analysis cost) per request.
446+
10 is the recommended default batch size; 20 is the hard limit.
447+
items:
448+
$ref: '#/components/schemas/BlastRadiusJobRequest'
449+
450+
BlastRadiusJobBatchResponse:
451+
type: object
452+
required: [results]
453+
description: >
454+
Plain array in request order, one entry per submitted job — unlike the
455+
read batches there is no found/not-found case, every job is submitted.
456+
properties:
457+
results:
458+
type: array
459+
items:
460+
$ref: '#/components/schemas/BlastRadiusJobEntry'
461+
462+
BlastRadiusJobPollBatchRequest:
463+
type: object
464+
required: [analysisIds]
465+
properties:
466+
analysisIds:
467+
type: array
468+
minItems: 1
469+
maxItems: 100
470+
items:
471+
type: string
472+
format: uuid
473+
page:
474+
type: integer
475+
minimum: 1
476+
default: 1
477+
pageSize:
478+
type: integer
479+
minimum: 1
480+
maximum: 100
481+
default: 20
482+
483+
BlastRadiusAnalysisBulkEntry:
484+
type: object
485+
required: [requestedAnalysisId, found, analysis]
486+
properties:
487+
requestedAnalysisId:
488+
type: string
489+
found:
490+
type: boolean
491+
analysis:
492+
type: object
493+
nullable: true
494+
allOf:
495+
- $ref: '#/components/schemas/BlastRadiusAnalysis'
496+
432497
BlastRadiusResultConfidence:
433498
type: string
434499
enum: [high, medium, low]
@@ -1045,6 +1110,122 @@ paths:
10451110
schema:
10461111
$ref: '#/components/schemas/Error'
10471112

1113+
/akrites-external/blast-radius/jobs:batch:
1114+
post:
1115+
operationId: submitBlastRadiusJobBatch
1116+
summary: 2a bulk — Submit multiple blast-radius analysis jobs
1117+
description: >
1118+
One job per array entry, same semantics as the single-job submit —
1119+
omit package for an advisory-wide analysis, provide it to narrow to a
1120+
single package. Each entry starts its own Temporal workflow, so the
1121+
batch is capped at 20 jobs (10 recommended as the default batch
1122+
size), much lower than the 100-item read batches, and stays behind
1123+
the same strict rate limiter as the single-job route.
1124+
1125+
1126+
A per-job failure does not fail the whole batch — that entry comes
1127+
back with status: 'failed' and the rest still submit.
1128+
tags: [Blast Radius]
1129+
security:
1130+
- M2MBearer:
1131+
- read:packages
1132+
requestBody:
1133+
required: true
1134+
content:
1135+
application/json:
1136+
schema:
1137+
$ref: '#/components/schemas/BlastRadiusJobBatchRequest'
1138+
responses:
1139+
'202':
1140+
description: Jobs accepted, in request order.
1141+
content:
1142+
application/json:
1143+
schema:
1144+
$ref: '#/components/schemas/BlastRadiusJobBatchResponse'
1145+
'400':
1146+
description: Validation error (empty array, >20 items, or an invalid job entry).
1147+
content:
1148+
application/json:
1149+
schema:
1150+
$ref: '#/components/schemas/Error'
1151+
'401':
1152+
description: Missing or invalid bearer token.
1153+
content:
1154+
application/json:
1155+
schema:
1156+
$ref: '#/components/schemas/Error'
1157+
'403':
1158+
description: Token missing read:packages scope.
1159+
content:
1160+
application/json:
1161+
schema:
1162+
$ref: '#/components/schemas/Error'
1163+
'429':
1164+
description: Too many requests — rate-limited independently of the other endpoints.
1165+
content:
1166+
application/json:
1167+
schema:
1168+
$ref: '#/components/schemas/Error'
1169+
1170+
/akrites-external/blast-radius/jobs:batch/poll:
1171+
post:
1172+
operationId: getBlastRadiusJobBatch
1173+
summary: 2b bulk — Poll multiple blast-radius analysis jobs
1174+
description: >
1175+
Same found/not-found echo shape as the other batch endpoints: an
1176+
unknown analysisId comes back { found: false, analysis: null }
1177+
instead of 404ing the whole request. Read-only, so it is rate-limited
1178+
the same as the other non-blast-radius endpoints, not the strict
1179+
submit limiter.
1180+
tags: [Blast Radius]
1181+
security:
1182+
- M2MBearer:
1183+
- read:packages
1184+
requestBody:
1185+
required: true
1186+
content:
1187+
application/json:
1188+
schema:
1189+
$ref: '#/components/schemas/BlastRadiusJobPollBatchRequest'
1190+
responses:
1191+
'200':
1192+
description: One page of results, in request order.
1193+
content:
1194+
application/json:
1195+
schema:
1196+
type: object
1197+
required: [page, pageSize, total, results]
1198+
properties:
1199+
page:
1200+
type: integer
1201+
pageSize:
1202+
type: integer
1203+
total:
1204+
type: integer
1205+
description: Total number of requested analysisIds, across all pages.
1206+
results:
1207+
type: array
1208+
items:
1209+
$ref: '#/components/schemas/BlastRadiusAnalysisBulkEntry'
1210+
'400':
1211+
description: Validation error (empty array, >100 items, or a non-uuid analysisId).
1212+
content:
1213+
application/json:
1214+
schema:
1215+
$ref: '#/components/schemas/Error'
1216+
'401':
1217+
description: Missing or invalid bearer token.
1218+
content:
1219+
application/json:
1220+
schema:
1221+
$ref: '#/components/schemas/Error'
1222+
'403':
1223+
description: Token missing read:packages scope.
1224+
content:
1225+
application/json:
1226+
schema:
1227+
$ref: '#/components/schemas/Error'
1228+
10481229
/akrites-external/blast-radius/jobs/{analysisId}:
10491230
get:
10501231
operationId: getBlastRadiusJob
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import {
4+
MAX_BLAST_RADIUS_JOBS_PER_BATCH,
5+
MAX_BLAST_RADIUS_POLL_IDS_PER_BATCH,
6+
blastRadiusJobBatchRequestSchema,
7+
blastRadiusJobPollBatchRequestSchema,
8+
paginateAnalysisIds,
9+
} from './blastRadiusBatch'
10+
11+
describe('blastRadiusJobBatchRequestSchema', () => {
12+
it('accepts a batch of valid job requests', () => {
13+
const result = blastRadiusJobBatchRequestSchema.safeParse({
14+
jobs: [
15+
{ advisoryId: 'GHSA-jf85-cpcp-j695', ecosystem: 'npm' },
16+
{ advisoryId: 'CVE-2024-12345', ecosystem: 'npm', package: 'pkg:npm/lodash' },
17+
],
18+
})
19+
expect(result.success).toBe(true)
20+
})
21+
22+
it('rejects an empty jobs array', () => {
23+
const result = blastRadiusJobBatchRequestSchema.safeParse({ jobs: [] })
24+
expect(result.success).toBe(false)
25+
})
26+
27+
it('rejects more than MAX_BLAST_RADIUS_JOBS_PER_BATCH jobs', () => {
28+
const jobs = Array.from({ length: MAX_BLAST_RADIUS_JOBS_PER_BATCH + 1 }, () => ({
29+
advisoryId: 'GHSA-jf85-cpcp-j695',
30+
ecosystem: 'npm',
31+
}))
32+
const result = blastRadiusJobBatchRequestSchema.safeParse({ jobs })
33+
expect(result.success).toBe(false)
34+
})
35+
36+
it('rejects a batch containing one invalid job', () => {
37+
const result = blastRadiusJobBatchRequestSchema.safeParse({
38+
jobs: [
39+
{ advisoryId: 'GHSA-jf85-cpcp-j695', ecosystem: 'npm' },
40+
{ advisoryId: 'not-an-advisory-id', ecosystem: 'npm' },
41+
],
42+
})
43+
expect(result.success).toBe(false)
44+
})
45+
})
46+
47+
describe('blastRadiusJobPollBatchRequestSchema', () => {
48+
const validId = '3fa85f64-5717-4562-b3fc-2c963f66afa6'
49+
50+
it('accepts a batch of valid analysisIds and defaults page/pageSize', () => {
51+
const result = blastRadiusJobPollBatchRequestSchema.parse({ analysisIds: [validId] })
52+
expect(result.page).toBe(1)
53+
expect(result.pageSize).toBe(20)
54+
})
55+
56+
it('rejects an empty analysisIds array', () => {
57+
const result = blastRadiusJobPollBatchRequestSchema.safeParse({ analysisIds: [] })
58+
expect(result.success).toBe(false)
59+
})
60+
61+
it('rejects a non-uuid analysisId', () => {
62+
const result = blastRadiusJobPollBatchRequestSchema.safeParse({ analysisIds: ['not-a-uuid'] })
63+
expect(result.success).toBe(false)
64+
})
65+
66+
it('rejects more than MAX_BLAST_RADIUS_POLL_IDS_PER_BATCH analysisIds', () => {
67+
const analysisIds = Array.from(
68+
{ length: MAX_BLAST_RADIUS_POLL_IDS_PER_BATCH + 1 },
69+
() => validId,
70+
)
71+
const result = blastRadiusJobPollBatchRequestSchema.safeParse({ analysisIds })
72+
expect(result.success).toBe(false)
73+
})
74+
})
75+
76+
describe('paginateAnalysisIds', () => {
77+
it('slices the requested page out of the full analysisIds array', () => {
78+
const analysisIds = ['a', 'b', 'c', 'd', 'e']
79+
const result = paginateAnalysisIds({ analysisIds, page: 2, pageSize: 2 })
80+
expect(result).toEqual({
81+
page: 2,
82+
pageSize: 2,
83+
total: 5,
84+
pagedAnalysisIds: ['c', 'd'],
85+
})
86+
})
87+
88+
it('returns an empty page past the end of the array', () => {
89+
const result = paginateAnalysisIds({ analysisIds: ['a'], page: 2, pageSize: 20 })
90+
expect(result.pagedAnalysisIds).toEqual([])
91+
expect(result.total).toBe(1)
92+
})
93+
})

0 commit comments

Comments
 (0)