diff --git a/api/src/lib/oss-index.ts b/api/src/lib/oss-index.ts index 42ff316a2..52c269ee9 100644 --- a/api/src/lib/oss-index.ts +++ b/api/src/lib/oss-index.ts @@ -16,7 +16,7 @@ export type TopCollectionTuple = [ avgScore: number, ]; -export type TopProjectTuple = [ +type ProjectHead = [ id: string, count: number, name: string, @@ -25,13 +25,20 @@ export type TopProjectTuple = [ avgScore: number, v1HealthScore: number, description: string, - status: string, +]; + +type HealthTail = [ healthScore: number | null, healthLabel: string | null, scaledHealthScore: number | null, healthMaxScore: number | null, ]; +export type TopProjectTuple = [...head: ProjectHead, status: string, ...health: HealthTail]; + +// The categories pipe returns the project tuple without its status slot. +export type CategoryTopProjectTuple = [...head: ProjectHead, ...health: HealthTail]; + // Counts and the UInt64 software value must be non-negative safe integers. The 0-1 averages use // inRange; the dropped v1 score only needs a finite number. const isNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value); @@ -46,20 +53,29 @@ export const isTopCollectionTuple = (value: unknown): value is TopCollectionTupl isCount(value[3]) && inRange(value[4], 1); -export const isTopProjectTuple = (value: unknown): value is TopProjectTuple => - Array.isArray(value) && - value.length >= 13 && +const isProjectHead = (value: unknown[]) => isString(value[0]) && isCount(value[1]) && isString(value[2]) && isString(value[3]) && isCount(value[4]) && - inRange(value[5], 1) && + inRange(value[5] as number, 1) && isNumber(value[6]) && - isString(value[7]) && + isString(value[7]); + +const isHealthTail = (value: unknown[], start: number) => + isHealthScore(value[start], value[start + 1], value[start + 3]) && + isNullableNumber(value[start + 2]); + +export const isTopProjectTuple = (value: unknown): value is TopProjectTuple => + Array.isArray(value) && + value.length >= 13 && + isProjectHead(value) && isString(value[8]) && - isHealthScore(value[9], value[10], value[12]) && - isNullableNumber(value[11]); + isHealthTail(value, 9); + +export const isCategoryTopProjectTuple = (value: unknown): value is CategoryTopProjectTuple => + Array.isArray(value) && value.length >= 12 && isProjectHead(value) && isHealthTail(value, 8); export const TopCollection = Type.Object( { @@ -103,6 +119,11 @@ export const TopProject = Type.Object( ); export type TopProject = Static; +export const CategoryTopProject = Type.Omit(TopProject, ['status'], { + title: 'CategoryTopProject', +}); +export type CategoryTopProject = Static; + export const toTopCollection = ([ id, count, @@ -139,6 +160,32 @@ export const toTopProject = ([ status, }); +export const toCategoryTopProject = ([ + id, + count, + name, + logo, + softwareValue, + avgScore, + , + description, + healthScore, + healthLabel, + , + healthMaxScore, +]: CategoryTopProjectTuple): CategoryTopProject => ({ + id, + count, + name, + logoUrl: logo, + description, + softwareValue, + avgScore, + healthScore, + healthLabel: healthLabel as CategoryTopProject['healthLabel'], + healthMaxScore, +}); + export const sortValues = ['totalContributors', 'softwareValue'] as const; export const sortQuery = Type.Object({ diff --git a/api/src/versions/v1-alpha/oss-index/categories.ts b/api/src/versions/v1-alpha/oss-index/categories.ts new file mode 100644 index 000000000..4f190270e --- /dev/null +++ b/api/src/versions/v1-alpha/oss-index/categories.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT +import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'; +import { Type } from '@sinclair/typebox'; + +import { fetchPipe } from '../../../clients/tinybird.js'; +import { NotFoundError } from '../../../lib/errors.js'; +import { + CategoryTopProject, + isCategoryTopProjectTuple, + isTopCollectionTuple, + sortQuery, + TopCollection, + toCategoryTopProject, + toTopCollection, + type CategoryTopProjectTuple, + type TopCollectionTuple, +} from '../../../lib/oss-index.js'; +import { inRange, isCount, isString } from '../../../lib/security.js'; + +const groupPipePath = '/v0/pipes/category_groups_list.json'; +const categoriesPipePath = '/v0/pipes/categories_oss_index.json'; + +interface GroupRow { + name: string; + slug: string; + type: string; +} + +interface Row { + id: string; + name: string; + slug: string; + totalContributors: number; + softwareValue: number; + avgScore: number; + topCollections: TopCollectionTuple[]; + topProjects: CategoryTopProjectTuple[]; +} + +const isGroupRow = (row: GroupRow) => + isString(row.name) && isString(row.slug) && isString(row.type); + +const isRow = (row: Row) => + isString(row.id) && + isString(row.name) && + isString(row.slug) && + isCount(row.totalContributors) && + isCount(row.softwareValue) && + inRange(row.avgScore, 1) && + Array.isArray(row.topCollections) && + row.topCollections.every(isTopCollectionTuple) && + Array.isArray(row.topProjects) && + row.topProjects.every(isCategoryTopProjectTuple); + +const Query = Type.Object({ + categoryGroupSlug: Type.String({ + minLength: 1, + description: 'Slug of the category group, for example `runtime-horizontal`.', + }), + ...sortQuery.properties, +}); + +const OssIndexCategory = Type.Object( + { + id: Type.String({ description: 'Identifier of the category.' }), + name: Type.String({ description: 'Display name of the category.' }), + slug: Type.String({ description: 'URL slug of the category.' }), + totalContributors: Type.Integer({ + minimum: 0, + description: 'Contributors across the category.', + }), + softwareValue: Type.Integer({ minimum: 0, description: 'Software value of the category.' }), + avgScore: Type.Number({ + minimum: 0, + maximum: 1, + description: 'Average health score of the category, from 0 to 1.', + }), + topCollections: Type.Array(TopCollection, { + description: 'Leading collections of the category, ordered by contributors.', + }), + topProjects: Type.Array(CategoryTopProject, { + description: 'Leading projects of the category, ordered by contributors.', + }), + }, + { title: 'OssIndexCategory' }, +); + +const OssIndexCategories = Type.Object( + { + name: Type.String({ description: 'Display name of the category group.' }), + slug: Type.String({ description: 'URL slug of the category group.' }), + type: Type.String({ + description: 'Type of the group, for example `vertical` or `horizontal`.', + }), + categories: Type.Array(OssIndexCategory, { + description: 'Categories of the group in the requested order. Empty when it has none.', + }), + }, + { title: 'OssIndexCategories' }, +); + +const categoryRoutes: FastifyPluginAsyncTypebox = async (scope) => { + scope.get( + '/oss-index/categories', + { + schema: { + tags: ['OSS Index'], + summary: 'List the categories of an OSS Index category group', + description: + 'Returns a category group with each of its categories, their totals and their leading collections and projects. ' + + '`sort` orders the categories by contributors or by software value. ' + + 'Answers 404 when no group has the given slug.', + querystring: Query, + response: { 200: OssIndexCategories }, + }, + }, + async (request) => { + const { categoryGroupSlug, sort } = request.query; + const [group] = await fetchPipe( + request, + groupPipePath, + { slug: categoryGroupSlug }, + isGroupRow, + ); + if (!group) { + throw new NotFoundError('Category group not found'); + } + + const rows = await fetchPipe( + request, + categoriesPipePath, + { categoryGroupSlug, orderBy: sort ?? 'totalContributors' }, + isRow, + ); + return { + name: group.name, + slug: group.slug, + type: group.type, + categories: rows.map((row) => ({ + ...row, + topCollections: row.topCollections.map(toTopCollection), + topProjects: row.topProjects.map(toCategoryTopProject), + })), + }; + }, + ); +}; + +export default categoryRoutes; diff --git a/api/tests/oss-index-categories.test.ts b/api/tests/oss-index-categories.test.ts new file mode 100644 index 000000000..199ae2382 --- /dev/null +++ b/api/tests/oss-index-categories.test.ts @@ -0,0 +1,197 @@ +// Copyright (c) 2025 The Linux Foundation and each contributor. +// SPDX-License-Identifier: MIT +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + callsTo, + mockFetch, + pipeCalls, + resolveSchema, + tinybirdStub, + useApp, + type OpenApiDoc, +} from './helpers/tinybird.js'; + +const route = '/v1-alpha/oss-index/categories'; +const slug = 'runtime-horizontal'; +const groupsPath = '/v0/pipes/category_groups_list.json'; +const categoriesPath = '/v0/pipes/categories_oss_index.json'; + +const group = (overrides: Record = {}) => ({ + name: 'Runtime', + slug, + type: 'horizontal', + ...overrides, +}); + +const row = (overrides: Record = {}) => ({ + id: 'k1', + name: 'Container Runtime', + slug: 'container-runtime', + totalContributors: 325024, + softwareValue: 22334455569, + avgScore: 0.255, + topCollections: [['c1', 292599, 'Container Engines', 14653059859, 0.29]], + topProjects: [ + [ + 'p1', + 119387, + 'Kubernetes', + 'https://logo.test/k.png', + 6002018897, + 0, + 86, + 'K8s', + 74, + 'healthy', + 74, + 100, + ], + ], + ...overrides, +}); + +let groups: unknown[] = []; +let rows: unknown[] = []; +const { get } = useApp(); + +beforeEach(() => { + groups = [group()]; + rows = [row()]; + mockFetch.mockImplementation( + tinybirdStub((url) => (url.pathname === groupsPath ? groups : rows)), + ); +}); + +describe('GET /v1-alpha/oss-index/categories', () => { + it('answers the group details with its mapped categories', async () => { + const res = await get(`${route}?categoryGroupSlug=${slug}`); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + name: 'Runtime', + slug, + type: 'horizontal', + categories: [ + { + id: 'k1', + name: 'Container Runtime', + slug: 'container-runtime', + totalContributors: 325024, + softwareValue: 22334455569, + avgScore: 0.255, + topCollections: [ + { + id: 'c1', + count: 292599, + name: 'Container Engines', + softwareValue: 14653059859, + avgScore: 0.29, + }, + ], + topProjects: [ + { + id: 'p1', + count: 119387, + name: 'Kubernetes', + logoUrl: 'https://logo.test/k.png', + description: 'K8s', + softwareValue: 6002018897, + avgScore: 0, + healthScore: 74, + healthLabel: 'healthy', + healthMaxScore: 100, + }, + ], + }, + ], + }); + }); + + it('looks the group up by slug, then lists its categories', async () => { + await get(`${route}?categoryGroupSlug=${slug}`); + expect(pipeCalls().map((url) => url.pathname)).toEqual([groupsPath, categoriesPath]); + expect(callsTo(groupsPath)[0]?.searchParams.get('slug')).toBe(slug); + const [call] = callsTo(categoriesPath); + expect(call?.searchParams.get('categoryGroupSlug')).toBe(slug); + expect(call?.searchParams.get('orderBy')).toBe('totalContributors'); + }); + + it('forwards an explicit sort as orderBy', async () => { + await get(`${route}?categoryGroupSlug=${slug}&sort=softwareValue`); + expect(callsTo(categoriesPath)[0]?.searchParams.get('orderBy')).toBe('softwareValue'); + }); + + it('answers an empty category list', async () => { + rows = []; + const res = await get(`${route}?categoryGroupSlug=${slug}`); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ ...group(), categories: [] }); + }); + + it('answers 404 for an unknown group without listing categories', async () => { + groups = []; + const res = await get(`${route}?categoryGroupSlug=nope`); + expect(res.statusCode).toBe(404); + expect(res.json()).toMatchObject({ code: 'not_found' }); + expect(callsTo(categoriesPath)).toHaveLength(0); + }); + + it.each([ + ['a missing slug', ''], + ['an empty slug', '?categoryGroupSlug='], + ['an unknown sort', `?categoryGroupSlug=${slug}&sort=bogus`], + ])('rejects %s with 400 before calling a pipe', async (_label, qs) => { + const res = await get(`${route}${qs}`); + expect(res.statusCode).toBe(400); + expect(pipeCalls()).toHaveLength(0); + }); + + it.each([ + ['a missing category id', row({ id: undefined })], + ['a non-array topProjects', row({ topProjects: 'x' })], + ['a malformed collection tuple', row({ topCollections: [['c1', 1, null, 1, 1]] })], + ['a short project tuple', row({ topProjects: [['p1', 1, 'n']] })], + [ + 'a project tuple without health fields', + row({ topProjects: [['p1', 1, 'n', 'l', 1, 1, 1, 'd']] }), + ], + [ + 'a null project logo', + row({ topProjects: [['p1', 1, 'n', null, 1, 1, 1, 'd', null, null, null, null]] }), + ], + ['a negative totalContributors', row({ totalContributors: -1 })], + ['a negative avgScore', row({ avgScore: -0.1 })], + ['an avgScore above 1', row({ avgScore: 1.1 })], + ['a fractional softwareValue', row({ softwareValue: 1.5 })], + [ + 'a fractional project count', + row({ topProjects: [['p1', 1.5, 'n', 'l', 1, 1, 1, 'd', null, null, null, null]] }), + ], + ])('answers 503 upstream_unavailable for %s', async (_label, bad) => { + rows = [bad]; + const res = await get(`${route}?categoryGroupSlug=${slug}`); + expect(res.statusCode).toBe(503); + expect(res.body).toContain('upstream_unavailable'); + }); + + it('answers 503 upstream_unavailable for a malformed group row', async () => { + groups = [group({ name: null })]; + const res = await get(`${route}?categoryGroupSlug=${slug}`); + expect(res.statusCode).toBe(503); + expect(res.body).toContain('upstream_unavailable'); + expect(callsTo(categoriesPath)).toHaveLength(0); + }); + + it('is listed in the OpenAPI spec under the OSS Index tag', async () => { + const spec = (await get('/v1-alpha/openapi.json')).json(); + const op = spec.paths[route]?.get as { tags?: string[] } | undefined; + expect(op?.tags).toEqual(['OSS Index']); + }); + + it('publishes the 0 to 1 bounds the row guard enforces on avgScore', async () => { + const spec = (await get('/v1-alpha/openapi.json')).json(); + const ok = spec.paths[route]?.get?.responses['200']?.content['application/json']?.schema; + const category = resolveSchema(spec, resolveSchema(spec, ok)?.properties?.categories?.items); + expect(category?.properties?.avgScore).toMatchObject({ minimum: 0, maximum: 1 }); + }); +}); diff --git a/api/tests/oss-index.test.ts b/api/tests/oss-index.test.ts index 9e0b3f8b0..90e5557f3 100644 --- a/api/tests/oss-index.test.ts +++ b/api/tests/oss-index.test.ts @@ -4,7 +4,9 @@ import { describe, expect, it } from 'vitest'; import { isTopCollectionTuple, + isCategoryTopProjectTuple, isTopProjectTuple, + toCategoryTopProject, toTopCollection, toTopProject, } from '../src/lib/oss-index.js'; @@ -67,6 +69,51 @@ describe('toTopProject', () => { }); }); +// The categories pipe sends the project tuple without its status slot. +const categoryProjectTuple = [...projectTuple.slice(0, 8), ...projectTuple.slice(9)]; +const withCategorySlot = (index: number, value: unknown) => + categoryProjectTuple.map((slot, i) => (i === index ? value : slot)); + +describe('toCategoryTopProject', () => { + it('names each tuple slot, renames logo to logoUrl and takes the v2 health fields', () => { + expect(toCategoryTopProject(categoryProjectTuple as never)).toEqual({ + id: 'p1', + count: 30, + name: 'Node.js', + logoUrl: 'https://logo.test/n.png', + description: 'Runtime', + softwareValue: 2400, + avgScore: 0.8, + healthScore: 74, + healthLabel: 'healthy', + healthMaxScore: 100, + }); + }); +}); + +describe('isCategoryTopProjectTuple', () => { + it.each([ + ['a scored', categoryProjectTuple], + ['an unscored', [...categoryProjectTuple.slice(0, 8), null, null, null, null]], + ])('accepts a twelve slot tuple with %s health tail', (_label, value) => { + expect(isCategoryTopProjectTuple(value)).toBe(true); + }); + + it.each([ + ['too short', categoryProjectTuple.slice(0, 11)], + ['without the health fields', categoryProjectTuple.slice(0, 8)], + ['a null logo', withCategorySlot(3, null)], + ['a string v1 health score', withCategorySlot(6, '86')], + ['an unknown health label', withCategorySlot(9, 'great')], + [ + 'a health score above its maximum', + [...categoryProjectTuple.slice(0, 8), 80, 'healthy', 100, 75], + ], + ])('rejects a tuple that is %s', (_label, value) => { + expect(isCategoryTopProjectTuple(value)).toBe(false); + }); +}); + describe('tuple guards', () => { it('accept well-formed tuples', () => { expect(isTopCollectionTuple(collectionTuple)).toBe(true);