Skip to content

Commit dcb8de0

Browse files
authored
fix: add pagination and remove mapping (CM-1322) (#4356)
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent aab51a3 commit dcb8de0

9 files changed

Lines changed: 161 additions & 120 deletions

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

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,10 @@ components:
8686

8787
HealthBand:
8888
type: string
89-
enum: [critical, low, medium, high]
89+
enum: [excellent, healthy, fair, concerning, critical]
9090
description: >
91-
UNCONFIRMED CROSSWALK — the internal health scale is
92-
excellent/healthy/fair/concerning/critical (good→bad); there is no
93-
agreed mapping to this critical/low/medium/high scale yet. See
94-
HEALTH_BAND_CROSSWALK in akritesExternalPackageDetail.ts. Confirm
95-
with Akrites/product before relying on exact band values.
91+
Health band on the internal scale (best→worst), returned verbatim —
92+
no external crosswalk.
9693
9794
PackageDetail:
9895
type: object
@@ -192,12 +189,11 @@ components:
192189
properties:
193190
lifecycle:
194191
type: string
195-
enum: [active, inactive, deprecated, abandoned, null]
192+
enum: [active, stable, declining, abandoned, archived, null]
196193
nullable: true
197194
description: >
198-
UNCONFIRMED CROSSWALK from the internal
199-
active/stable/declining/abandoned/archived scale — see
200-
LIFECYCLE_CROSSWALK in akritesExternalPackageDetail.ts.
195+
Lifecycle stage on the internal scale, returned verbatim — no
196+
external crosswalk. Null when unknown or unrecognized.
201197
maintainerBusFactor:
202198
type: integer
203199
nullable: true
@@ -372,13 +368,12 @@ components:
372368

373369
OverallConfidenceBand:
374370
type: string
375-
enum: [HIGH, MEDIUM, LOW, NONE]
371+
enum: [PRIMARY, SECONDARY, FALLBACK, NONE]
376372
description: >
377-
Aggregate confidence, derived from the highest-scoring contact.
378-
UNCONFIRMED CROSSWALK: the internal aggregate band is
379-
PRIMARY/SECONDARY/FALLBACK/NONE; this HIGH/MEDIUM/LOW/NONE scale is a
380-
4→4 tier rename (PRIMARY→HIGH, …) pending confirmation with Akrites —
381-
see OVERALL_CONFIDENCE_CROSSWALK in akritesExternalContactDetail.ts.
373+
Aggregate confidence, derived from the highest-scoring contact. Uses the
374+
internal SecurityContact confidence scale, returned verbatim — same scale
375+
as ContactConfidenceBand, no external crosswalk. NONE when there are no
376+
contacts.
382377
383378
SecurityContact:
384379
type: object
@@ -646,15 +641,31 @@ paths:
646641
maxItems: 100
647642
items:
648643
type: string
644+
page:
645+
type: integer
646+
minimum: 1
647+
default: 1
648+
pageSize:
649+
type: integer
650+
minimum: 1
651+
maximum: 100
652+
default: 20
649653
responses:
650654
'200':
651-
description: Results in the same order as the request.
655+
description: One page of results, in request order.
652656
content:
653657
application/json:
654658
schema:
655659
type: object
656-
required: [results]
660+
required: [page, pageSize, total, results]
657661
properties:
662+
page:
663+
type: integer
664+
pageSize:
665+
type: integer
666+
total:
667+
type: integer
668+
description: Total number of requested purls, across all pages.
658669
results:
659670
type: array
660671
items:
@@ -747,15 +758,31 @@ paths:
747758
maxItems: 100
748759
items:
749760
type: string
761+
page:
762+
type: integer
763+
minimum: 1
764+
default: 1
765+
pageSize:
766+
type: integer
767+
minimum: 1
768+
maximum: 100
769+
default: 20
750770
responses:
751771
'200':
752-
description: Results in the same order as the request.
772+
description: One page of results, in request order.
753773
content:
754774
application/json:
755775
schema:
756776
type: object
757-
required: [results]
777+
required: [page, pageSize, total, results]
758778
properties:
779+
page:
780+
type: integer
781+
pageSize:
782+
type: integer
783+
total:
784+
type: integer
785+
description: Total number of requested purls, across all pages.
759786
results:
760787
type: array
761788
items:
@@ -851,15 +878,31 @@ paths:
851878
maxItems: 100
852879
items:
853880
type: string
881+
page:
882+
type: integer
883+
minimum: 1
884+
default: 1
885+
pageSize:
886+
type: integer
887+
minimum: 1
888+
maximum: 100
889+
default: 20
854890
responses:
855891
'200':
856-
description: Results in the same order as the request.
892+
description: One page of results, in request order.
857893
content:
858894
application/json:
859895
schema:
860896
type: object
861-
required: [results]
897+
required: [page, pageSize, total, results]
862898
properties:
899+
page:
900+
type: integer
901+
pageSize:
902+
type: integer
903+
total:
904+
type: integer
905+
description: Total number of requested purls, across all pages.
863906
results:
864907
type: array
865908
items:

backend/src/api/public/v1/packages/akritesExternalContactDetail.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,12 @@ describe('toAkritesExternalContactDetail', () => {
5757
])
5858
})
5959

60-
it('derives overallConfidenceBand from the highest-scoring contact and crosswalks it', () => {
61-
// max score 0.94 -> PRIMARY -> HIGH (not SECONDARY/MEDIUM from the other contact).
62-
expect(toAkritesExternalContactDetail(baseRow()).overallConfidenceBand).toBe('HIGH')
60+
it('derives overallConfidenceBand from the highest-scoring contact, returned verbatim', () => {
61+
// max score 0.94 -> PRIMARY (not SECONDARY from the other, lower-scoring contact).
62+
expect(toAkritesExternalContactDetail(baseRow()).overallConfidenceBand).toBe('PRIMARY')
6363
})
6464

65-
it('crosswalks each internal band to the contract scale', () => {
65+
it('returns the aggregate band on the internal scale (no crosswalk)', () => {
6666
const band = (confidence: string, score: number) =>
6767
toAkritesExternalContactDetail(
6868
baseRow({
@@ -77,8 +77,8 @@ describe('toAkritesExternalContactDetail', () => {
7777
],
7878
}),
7979
).overallConfidenceBand
80-
expect(band('SECONDARY', 0.6)).toBe('MEDIUM')
81-
expect(band('FALLBACK', 0.4)).toBe('LOW')
80+
expect(band('SECONDARY', 0.6)).toBe('SECONDARY')
81+
expect(band('FALLBACK', 0.4)).toBe('FALLBACK')
8282
})
8383

8484
it('returns NONE band and an empty array when there are no contacts', () => {

backend/src/api/public/v1/packages/akritesExternalContactDetail.ts

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import {
44
securityContactConfidenceBand,
55
} from '@crowd/data-access-layer'
66

7-
// Per-contact band mirrors the internal SecurityContactConfidence enum verbatim.
7+
// Both the per-contact band and the aggregate band mirror the internal
8+
// SecurityContactConfidence enum verbatim (PRIMARY/SECONDARY/FALLBACK/NONE) —
9+
// no external crosswalk (confirmed with product).
810
export type AkritesExternalContactConfidenceBand = SecurityContactConfidence
9-
// Aggregate band is a DIFFERENT scale in the contract (HIGH/MEDIUM/LOW/NONE).
10-
export type AkritesExternalOverallConfidenceBand = 'HIGH' | 'MEDIUM' | 'LOW' | 'NONE'
11+
export type AkritesExternalOverallConfidenceBand = SecurityContactConfidence
1112

1213
export interface AkritesExternalSecurityContact {
1314
channel: string
@@ -42,21 +43,6 @@ export interface ContactDetailBulkEntry {
4243
contact: AkritesExternalContactDetail | null
4344
}
4445

45-
// UNCONFIRMED CROSSWALK — the internal aggregate band is PRIMARY/SECONDARY/FALLBACK/NONE
46-
// (securityContactConfidenceBand), but the Akrites contract's overallConfidenceBand uses
47-
// HIGH/MEDIUM/LOW/NONE. This is a clean 4→4 tier rename, but the contract flags it as an
48-
// open question (there is no existing function producing the HIGH/… scale). Confirm with
49-
// Akrites/product; if they'd rather reuse PRIMARY/SECONDARY/FALLBACK/NONE, drop this table.
50-
const OVERALL_CONFIDENCE_CROSSWALK: Record<
51-
SecurityContactConfidence,
52-
AkritesExternalOverallConfidenceBand
53-
> = {
54-
PRIMARY: 'HIGH',
55-
SECONDARY: 'MEDIUM',
56-
FALLBACK: 'LOW',
57-
NONE: 'NONE',
58-
}
59-
6046
export function toAkritesExternalContactDetail(
6147
row: AkritesExternalContactDetailRow,
6248
): AkritesExternalContactDetail {
@@ -69,12 +55,10 @@ export function toAkritesExternalContactDetail(
6955
}))
7056

7157
// Aggregate band derives from the highest-scoring contact (same rule as the internal
72-
// packageConfidence), then crosswalked to the contract's scale. NONE when there are none.
73-
const overallConfidenceBand =
58+
// packageConfidence), returned verbatim. NONE when there are no contacts.
59+
const overallConfidenceBand: AkritesExternalOverallConfidenceBand =
7460
contacts.length > 0
75-
? OVERALL_CONFIDENCE_CROSSWALK[
76-
securityContactConfidenceBand(Math.max(...contacts.map((c) => c.confidenceScore)))
77-
]
61+
? securityContactConfidenceBand(Math.max(...contacts.map((c) => c.confidenceScore)))
7862
: 'NONE'
7963

8064
return {

backend/src/api/public/v1/packages/akritesExternalPackageDetail.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,32 +48,32 @@ describe('toAkritesExternalPackageDetail', () => {
4848
it('maps a well-formed row to the akrites-external shape', () => {
4949
const result = toAkritesExternalPackageDetail(baseRow())
5050
expect(result.purl).toBe('pkg:npm/lodash')
51-
expect(result.health.band).toBe('high')
51+
expect(result.health.band).toBe('healthy')
5252
expect(result.riskSignals.lifecycle).toBe('active')
5353
expect(result.impact.score).toBe(50)
5454
})
5555

5656
it('falls back to computeHealthBand(scorecardScore) for an unrecognized (non-null) healthLabel', () => {
57-
// scorecardScore 7.5 -> computeHealthBand() returns 'healthy' -> crosswalk 'high'.
58-
// A naive `?? computeHealthBand(...)` (bug fixed in code review) would have
59-
// trusted 'not-a-real-label' as-is and returned 'critical' instead.
57+
// scorecardScore 7.5 -> computeHealthBand() returns 'healthy'. A naive
58+
// `?? computeHealthBand(...)` (bug fixed in code review) would have trusted
59+
// 'not-a-real-label' as-is and returned it verbatim instead.
6060
const result = toAkritesExternalPackageDetail(
6161
baseRow({ healthLabel: 'not-a-real-label', scorecardScore: 7.5 }),
6262
)
63-
expect(result.health.band).toBe('high')
63+
expect(result.health.band).toBe('healthy')
6464
})
6565

6666
it('falls back to computeHealthBand(scorecardScore) when healthLabel is null', () => {
6767
const result = toAkritesExternalPackageDetail(baseRow({ healthLabel: null, scorecardScore: 1 }))
6868
expect(result.health.band).toBe('critical')
6969
})
7070

71-
it('maps every known internal health label through the crosswalk', () => {
71+
it('returns every known internal health label verbatim', () => {
7272
expect(toAkritesExternalPackageDetail(baseRow({ healthLabel: 'excellent' })).health.band).toBe(
73-
'high',
73+
'excellent',
7474
)
7575
expect(toAkritesExternalPackageDetail(baseRow({ healthLabel: 'concerning' })).health.band).toBe(
76-
'low',
76+
'concerning',
7777
)
7878
expect(toAkritesExternalPackageDetail(baseRow({ healthLabel: 'critical' })).health.band).toBe(
7979
'critical',
@@ -85,13 +85,13 @@ describe('toAkritesExternalPackageDetail', () => {
8585
expect(result.riskSignals.lifecycle).toBeNull()
8686
})
8787

88-
it('maps lifecycle labels not present verbatim in the external enum (stable, archived)', () => {
88+
it('returns internal lifecycle labels verbatim (stable, archived)', () => {
8989
expect(
9090
toAkritesExternalPackageDetail(baseRow({ lifecycleLabel: 'stable' })).riskSignals.lifecycle,
91-
).toBe('active')
91+
).toBe('stable')
9292
expect(
9393
toAkritesExternalPackageDetail(baseRow({ lifecycleLabel: 'archived' })).riskSignals.lifecycle,
94-
).toBe('deprecated')
94+
).toBe('archived')
9595
})
9696

9797
it('normalizes raw timestamptz strings (not Date objects) to ISO 8601', () => {

backend/src/api/public/v1/packages/akritesExternalPackageDetail.ts

Lines changed: 9 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ import { HEALTH_BAND_SET, LIFECYCLE_VALUES, type Lifecycle } from './types'
99

1010
const LIFECYCLE_SET = new Set<string>(LIFECYCLE_VALUES)
1111

12-
export type AkritesExternalHealthBand = 'critical' | 'low' | 'medium' | 'high'
13-
export type AkritesExternalLifecycle = 'active' | 'inactive' | 'deprecated' | 'abandoned' | null
12+
// The API returns the internal bands verbatim — no external crosswalk (confirmed
13+
// with product): health excellent/healthy/fair/concerning/critical, lifecycle
14+
// active/stable/declining/abandoned/archived.
15+
export type AkritesExternalHealthBand = HealthBand
16+
export type AkritesExternalLifecycle = Lifecycle | null
1417

1518
export interface AkritesExternalPackageDetail {
1619
purl: string
@@ -70,48 +73,20 @@ export interface PackageDetailBulkEntry {
7073
package: AkritesExternalPackageDetail | null
7174
}
7275

73-
// UNCONFIRMED CROSSWALK — see the HealthBand schema note in the akrites-external
74-
// OpenAPI contract: the internal excellent/healthy/fair/concerning/critical scale
75-
// (good→bad) has no agreed mapping to Akrites' critical/low/medium/high scale yet.
76-
// This mirrors the "fair -> medium" guess from the original Akrites draft spec
77-
// (not encoded in this repo's openapi.yaml). Confirm the real crosswalk with
78-
// Akrites/product before this ships, then update this table only.
79-
const HEALTH_BAND_CROSSWALK: Record<HealthBand, AkritesExternalHealthBand> = {
80-
excellent: 'high',
81-
healthy: 'high',
82-
fair: 'medium',
83-
concerning: 'low',
84-
critical: 'critical',
85-
}
86-
8776
function toAkritesHealthBand(
8877
healthLabel: string | null,
8978
scorecardScore: number | null,
9079
): AkritesExternalHealthBand {
9180
// Same validity guard as getPackage.ts: an unrecognized (not just null) stored
9281
// label falls back to computeHealthBand() instead of silently miscategorizing.
93-
const label: HealthBand =
94-
healthLabel != null && HEALTH_BAND_SET.has(healthLabel)
95-
? (healthLabel as HealthBand)
96-
: computeHealthBand(scorecardScore)
97-
return HEALTH_BAND_CROSSWALK[label]
98-
}
99-
100-
// UNCONFIRMED CROSSWALK — same caveat as HEALTH_BAND_CROSSWALK above: the internal
101-
// lifecycle scale (active/stable/declining/abandoned/archived) doesn't line up
102-
// one-to-one with Akrites' (active/inactive/deprecated/abandoned/null). Confirm
103-
// with Akrites/product before this ships.
104-
const LIFECYCLE_CROSSWALK: Record<Lifecycle, AkritesExternalLifecycle> = {
105-
active: 'active',
106-
stable: 'active',
107-
declining: 'inactive',
108-
abandoned: 'abandoned',
109-
archived: 'deprecated',
82+
return healthLabel != null && HEALTH_BAND_SET.has(healthLabel)
83+
? (healthLabel as HealthBand)
84+
: computeHealthBand(scorecardScore)
11085
}
11186

11287
function toAkritesLifecycle(lifecycleLabel: string | null): AkritesExternalLifecycle {
11388
if (lifecycleLabel === null || !LIFECYCLE_SET.has(lifecycleLabel)) return null
114-
return LIFECYCLE_CROSSWALK[lifecycleLabel as Lifecycle]
89+
return lifecycleLabel as Lifecycle
11590
}
11691

11792
// Timestamptz columns arrive as the raw Postgres string (OID 1184 parser returns

backend/src/api/public/v1/packages/getAkritesExternalAdvisoryDetailBatch.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,17 @@ import {
1010
type AdvisoryDetailBulkEntry,
1111
toAkritesExternalAdvisoryDetail,
1212
} from './akritesExternalAdvisoryDetail'
13-
import { normalizePurl, purlsBodySchema } from './purl'
13+
import { paginatePurls, paginatedPurlsBodySchema } from './purl'
1414

15-
const bodySchema = purlsBodySchema()
15+
const bodySchema = paginatedPurlsBodySchema()
1616

1717
export async function getAkritesExternalAdvisoryDetailBatch(
1818
req: Request,
1919
res: Response,
2020
): Promise<void> {
21-
const { purls: rawPurls } = validateOrThrow(bodySchema, req.body)
22-
// Normalize after parsing (not in the schema) so rawPurls keeps the client's
23-
// original form — echoed back as requestedPurl so callers can self-correlate.
24-
const normalizedPurls = rawPurls.map(normalizePurl)
21+
const { page, pageSize, total, pagedPurls, normalizedPurls } = paginatePurls(
22+
validateOrThrow(bodySchema, req.body),
23+
)
2524

2625
const qx = await getPackagesQx()
2726
const rows = await getAdvisoriesByPurls(qx, normalizedPurls)
@@ -35,7 +34,7 @@ export async function getAkritesExternalAdvisoryDetailBatch(
3534
else byPurl.set(row.purl, [row])
3635
}
3736

38-
const results: AdvisoryDetailBulkEntry[] = rawPurls.map((requestedPurl, i) => {
37+
const results: AdvisoryDetailBulkEntry[] = pagedPurls.map((requestedPurl, i) => {
3938
const purlRows = byPurl.get(normalizedPurls[i])
4039
return {
4140
requestedPurl,
@@ -44,5 +43,5 @@ export async function getAkritesExternalAdvisoryDetailBatch(
4443
}
4544
})
4645

47-
ok(res, { results })
46+
ok(res, { page, pageSize, total, results })
4847
}

0 commit comments

Comments
 (0)