Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 56 additions & 9 deletions api/src/lib/oss-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type TopCollectionTuple = [
avgScore: number,
];

export type TopProjectTuple = [
type ProjectHead = [
id: string,
count: number,
name: string,
Expand All @@ -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);
Expand All @@ -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]);
Comment thread
epipav marked this conversation as resolved.

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(
{
Expand Down Expand Up @@ -103,6 +119,11 @@ export const TopProject = Type.Object(
);
export type TopProject = Static<typeof TopProject>;

export const CategoryTopProject = Type.Omit(TopProject, ['status'], {
title: 'CategoryTopProject',
});
export type CategoryTopProject = Static<typeof CategoryTopProject>;

export const toTopCollection = ([
id,
count,
Expand Down Expand Up @@ -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({
Expand Down
150 changes: 150 additions & 0 deletions api/src/versions/v1-alpha/oss-index/categories.ts
Original file line number Diff line number Diff line change
@@ -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<GroupRow>(
request,
groupPipePath,
{ slug: categoryGroupSlug },
isGroupRow,
);
if (!group) {
throw new NotFoundError('Category group not found');
}

const rows = await fetchPipe<Row>(
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;
Loading
Loading