From 2cd77ccaab789a878e0401c91b0b3a080cd72194 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 17:09:12 +0000 Subject: [PATCH] Add dotnet target framework badge (fixes badges/shields#6934) Implement a platform-support badge that reads .NET target frameworks from the NuGet registration API for a given package. Supports stable (v) and prerelease (vpre) version selection, following the pipe-separated display pattern used by other platform-support badges. --- services/dotnet/dotnet-helpers.js | 32 ++++++ services/dotnet/dotnet.service.js | 153 +++++++++++++++++++++++++ services/dotnet/dotnet.service.spec.js | 106 +++++++++++++++++ services/dotnet/dotnet.tester.js | 33 ++++++ 4 files changed, 324 insertions(+) create mode 100644 services/dotnet/dotnet-helpers.js create mode 100644 services/dotnet/dotnet.service.js create mode 100644 services/dotnet/dotnet.service.spec.js create mode 100644 services/dotnet/dotnet.tester.js diff --git a/services/dotnet/dotnet-helpers.js b/services/dotnet/dotnet-helpers.js new file mode 100644 index 0000000000000..3208b31a03334 --- /dev/null +++ b/services/dotnet/dotnet-helpers.js @@ -0,0 +1,32 @@ +function normalizeTargetFramework(targetFramework) { + if (!targetFramework) { + return 'all' + } + if (targetFramework.startsWith('.NETFramework')) { + const version = targetFramework.slice('.NETFramework'.length) + return `net${version.replace(/\./g, '')}` + } + if (targetFramework.startsWith('.NETStandard')) { + return `netstandard${targetFramework.slice('.NETStandard'.length)}` + } + if (targetFramework.startsWith('.NETCoreApp')) { + return `netcoreapp${targetFramework.slice('.NETCoreApp'.length)}` + } + return targetFramework.toLowerCase() +} + +function extractTargetFrameworks(dependencyGroups) { + const frameworks = dependencyGroups.map(group => + normalizeTargetFramework(group.targetFramework), + ) + return [...new Set(frameworks)].sort() +} + +const REGISTRATION_BASE_URL = + 'https://api.nuget.org/v3/registration5-gz-semver2/' + +export { + normalizeTargetFramework, + extractTargetFrameworks, + REGISTRATION_BASE_URL, +} diff --git a/services/dotnet/dotnet.service.js b/services/dotnet/dotnet.service.js new file mode 100644 index 0000000000000..1289b9ab2befa --- /dev/null +++ b/services/dotnet/dotnet.service.js @@ -0,0 +1,153 @@ +import Joi from 'joi' +import { + BaseJsonService, + InvalidResponse, + NotFound, + pathParams, +} from '../index.js' +import { stripBuildMetadata, selectVersion } from '../nuget/nuget-helpers.js' +import { + extractTargetFrameworks, + REGISTRATION_BASE_URL, +} from './dotnet-helpers.js' + +const registrationIndexSchema = Joi.object({ + items: Joi.array() + .items( + Joi.object({ + '@id': Joi.string().required(), + }), + ) + .required(), +}).required() + +const catalogEntrySchema = Joi.object({ + version: Joi.string().required(), + dependencyGroups: Joi.array() + .items( + Joi.object({ + targetFramework: Joi.string().allow(null), + }), + ) + .default([]), +}) + +const registrationPageSchema = Joi.object({ + items: Joi.array() + .items( + Joi.object({ + '@id': Joi.string(), + catalogEntry: catalogEntrySchema, + items: Joi.array(), + }), + ) + .required(), +}).required() + +const description = ` +Shows the .NET target frameworks supported by a NuGet package, based on the +package's dependency groups in the NuGet registration API. +` + +async function collectCatalogEntries(service, url) { + const page = await service._requestJson({ + schema: registrationPageSchema, + url, + }) + const lastItem = page.items[page.items.length - 1] + if (lastItem.catalogEntry) { + return page.items + } + if (lastItem.items) { + const nestedLast = lastItem.items[lastItem.items.length - 1] + if (nestedLast.catalogEntry) { + return lastItem.items + } + } + return collectCatalogEntries(service, lastItem['@id']) +} + +export default class Dotnet extends BaseJsonService { + static category = 'platform-support' + + static route = { + base: 'dotnet', + pattern: ':variant(v|vpre)/:packageName', + } + + static openApi = { + '/dotnet/{variant}/{packageName}': { + get: { + summary: '.NET Target Framework', + description, + parameters: pathParams( + { + name: 'variant', + example: 'v', + schema: { type: 'variant', enum: ['v', 'vpre'] }, + description: + 'Latest stable package version (`v`) or latest version including prereleases (`vpre`).', + }, + { name: 'packageName', example: 'Humanizer.Core' }, + ), + }, + }, + } + + static defaultBadgeData = { + label: 'dotnet', + namedLogo: 'dotnet', + } + + static render({ frameworks }) { + return { + message: frameworks.join(' | '), + color: 'blue', + } + } + + registrationIndexUrl({ packageName }) { + return `${REGISTRATION_BASE_URL}${packageName.toLowerCase()}/index.json` + } + + transform({ catalogEntries, includePrereleases }) { + if (catalogEntries.length === 0) { + throw new NotFound({ prettyMessage: 'package not found' }) + } + const versions = catalogEntries.map(entry => + stripBuildMetadata(entry.catalogEntry.version), + ) + const version = selectVersion(versions, includePrereleases) + const catalogEntry = catalogEntries.find( + entry => stripBuildMetadata(entry.catalogEntry.version) === version, + ).catalogEntry + const frameworks = extractTargetFrameworks( + catalogEntry.dependencyGroups || [], + ) + if (frameworks.length === 0) { + throw new InvalidResponse({ + prettyMessage: 'target frameworks missing', + }) + } + return frameworks + } + + async fetchCatalogEntries({ packageName }) { + const index = await this._requestJson({ + schema: registrationIndexSchema, + url: this.registrationIndexUrl({ packageName }), + httpErrors: { + 404: 'package not found', + }, + }) + const lastPageUrl = index.items[index.items.length - 1]['@id'] + return collectCatalogEntries(this, lastPageUrl) + } + + async handle({ variant, packageName }) { + const includePrereleases = variant === 'vpre' + const catalogEntries = await this.fetchCatalogEntries({ packageName }) + const frameworks = this.transform({ catalogEntries, includePrereleases }) + return this.constructor.render({ frameworks }) + } +} diff --git a/services/dotnet/dotnet.service.spec.js b/services/dotnet/dotnet.service.spec.js new file mode 100644 index 0000000000000..40912e6f554e7 --- /dev/null +++ b/services/dotnet/dotnet.service.spec.js @@ -0,0 +1,106 @@ +import { test, given } from 'sazerac' +import { + normalizeTargetFramework, + extractTargetFrameworks, + REGISTRATION_BASE_URL, +} from './dotnet-helpers.js' +import Dotnet from './dotnet.service.js' + +describe('.NET service', function () { + test(normalizeTargetFramework, () => { + given(null).expect('all') + given(undefined).expect('all') + given('').expect('all') + given('net8.0').expect('net8.0') + given('.NETFramework4.8').expect('net48') + given('.NETFramework4.5').expect('net45') + given('.NETStandard2.0').expect('netstandard2.0') + given('.NETCoreApp3.1').expect('netcoreapp3.1') + }) + + test(extractTargetFrameworks, () => { + given([ + { targetFramework: '.NETStandard2.0' }, + { targetFramework: '.NETFramework4.8' }, + { targetFramework: 'net8.0' }, + ]).expect(['net48', 'net8.0', 'netstandard2.0']) + given([{ targetFramework: null }]).expect(['all']) + given([ + { targetFramework: 'net6.0' }, + { targetFramework: 'net6.0' }, + ]).expect(['net6.0']) + }) + + test(Dotnet.prototype.registrationIndexUrl, () => { + given({ packageName: 'Humanizer.Core' }).expect( + `${REGISTRATION_BASE_URL}humanizer.core/index.json`, + ) + }) + + test(Dotnet.prototype.transform, () => { + given({ + catalogEntries: [ + { + catalogEntry: { + version: '1.0.0', + dependencyGroups: [{ targetFramework: 'net6.0' }], + }, + }, + ], + includePrereleases: false, + }).expect(['net6.0']) + + given({ + catalogEntries: [ + { + catalogEntry: { + version: '1.0.0', + dependencyGroups: [{ targetFramework: 'net6.0' }], + }, + }, + { + catalogEntry: { + version: '2.0.0-beta1', + dependencyGroups: [{ targetFramework: 'net8.0' }], + }, + }, + ], + includePrereleases: false, + }).expect(['net6.0']) + + given({ + catalogEntries: [ + { + catalogEntry: { + version: '1.0.0', + dependencyGroups: [{ targetFramework: 'net6.0' }], + }, + }, + { + catalogEntry: { + version: '2.0.0-beta1', + dependencyGroups: [{ targetFramework: 'net8.0' }], + }, + }, + ], + includePrereleases: true, + }).expect(['net8.0']) + + given({ + catalogEntries: [], + includePrereleases: false, + }).expectError('Not Found: package not found') + + given({ + catalogEntries: [ + { + catalogEntry: { + version: '1.0.0', + dependencyGroups: [], + }, + }, + ], + includePrereleases: false, + }).expectError('Invalid Response') + }) +}) diff --git a/services/dotnet/dotnet.tester.js b/services/dotnet/dotnet.tester.js new file mode 100644 index 0000000000000..d6c5e16878070 --- /dev/null +++ b/services/dotnet/dotnet.tester.js @@ -0,0 +1,33 @@ +import { createServiceTester } from '../tester.js' +export const t = await createServiceTester() + +t.create('target frameworks (stable)') + .get('/v/Humanizer.Core.json') + .expectBadge({ + label: 'dotnet', + message: 'net48 | net8.0 | netstandard2.0', + color: 'blue', + }) + +t.create('target frameworks (prerelease)') + .get('/vpre/Humanizer.Core.json') + .expectBadge({ + label: 'dotnet', + message: 'net48 | net8.0 | netstandard2.0', + color: 'blue', + }) + +t.create('single target framework') + .get('/v/Microsoft.AspNetCore.Mvc.json') + .expectBadge({ + label: 'dotnet', + message: 'netstandard2.0', + color: 'blue', + }) + +t.create('package not found') + .get('/v/not-a-real-package-name.json') + .expectBadge({ + label: 'dotnet', + message: 'package not found', + })