refactor(datasource/nuget): move v2/v3 API logic to classes (#28117)

This commit is contained in:
Florian Greinacher 2024-03-27 10:49:05 +01:00 committed by GitHub
parent fde2dff36d
commit 87bba9d31a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 288 additions and 262 deletions

View file

@ -1,6 +1,6 @@
import { sortNugetVersions } from './v3'; import { sortNugetVersions } from './common';
describe('modules/datasource/nuget/v3', () => { describe('modules/datasource/nuget/common', () => {
it.each<{ version: string; other: string; result: number }>` it.each<{ version: string; other: string; result: number }>`
version | other | result version | other | result
${'invalid1'} | ${'invalid2'} | ${0} ${'invalid1'} | ${'invalid2'} | ${0}

View file

@ -1,6 +1,7 @@
import { logger } from '../../../logger'; import { logger } from '../../../logger';
import { regEx } from '../../../util/regex'; import { regEx } from '../../../util/regex';
import { parseUrl } from '../../../util/url'; import { parseUrl } from '../../../util/url';
import { api as versioning } from '../../versioning/nuget';
import type { ParsedRegistryUrl } from './types'; import type { ParsedRegistryUrl } from './types';
const buildMetaRe = regEx(/\+.+$/g); const buildMetaRe = regEx(/\+.+$/g);
@ -47,3 +48,23 @@ export function parseRegistryUrl(registryUrl: string): ParsedRegistryUrl {
const feedUrl = parsedUrl.href; const feedUrl = parsedUrl.href;
return { feedUrl, protocolVersion }; return { feedUrl, protocolVersion };
} }
/**
* Compare two versions. Return:
* - `1` if `a > b` or `b` is invalid
* - `-1` if `a < b` or `a` is invalid
* - `0` if `a == b` or both `a` and `b` are invalid
*/
export function sortNugetVersions(a: string, b: string): number {
if (versioning.isValid(a)) {
if (versioning.isValid(b)) {
return versioning.sortVersions(a, b);
} else {
return 1;
}
} else if (versioning.isValid(b)) {
return -1;
} else {
return 0;
}
}

View file

@ -3,8 +3,8 @@ import * as nugetVersioning from '../../versioning/nuget';
import { Datasource } from '../datasource'; import { Datasource } from '../datasource';
import type { GetReleasesConfig, ReleaseResult } from '../types'; import type { GetReleasesConfig, ReleaseResult } from '../types';
import { parseRegistryUrl } from './common'; import { parseRegistryUrl } from './common';
import * as v2 from './v2'; import { NugetV2Api } from './v2';
import * as v3 from './v3'; import { NugetV3Api } from './v3';
// https://api.nuget.org/v3/index.json is a default official nuget feed // https://api.nuget.org/v3/index.json is a default official nuget feed
export const nugetOrg = 'https://api.nuget.org/v3/index.json'; export const nugetOrg = 'https://api.nuget.org/v3/index.json';
@ -18,6 +18,10 @@ export class NugetDatasource extends Datasource {
override readonly registryStrategy = 'merge'; override readonly registryStrategy = 'merge';
readonly v2Api = new NugetV2Api();
readonly v3Api = new NugetV3Api();
constructor() { constructor() {
super(NugetDatasource.id); super(NugetDatasource.id);
} }
@ -33,12 +37,17 @@ export class NugetDatasource extends Datasource {
} }
const { feedUrl, protocolVersion } = parseRegistryUrl(registryUrl); const { feedUrl, protocolVersion } = parseRegistryUrl(registryUrl);
if (protocolVersion === 2) { if (protocolVersion === 2) {
return v2.getReleases(this.http, feedUrl, packageName); return this.v2Api.getReleases(this.http, feedUrl, packageName);
} }
if (protocolVersion === 3) { if (protocolVersion === 3) {
const queryUrl = await v3.getResourceUrl(this.http, feedUrl); const queryUrl = await this.v3Api.getResourceUrl(this.http, feedUrl);
if (queryUrl) { if (queryUrl) {
return v3.getReleases(this.http, feedUrl, queryUrl, packageName); return this.v3Api.getReleases(
this.http,
feedUrl,
queryUrl,
packageName,
);
} }
} }
return null; return null;

View file

@ -1,70 +1,74 @@
import { XmlDocument, XmlElement } from 'xmldoc'; import { XmlDocument, XmlElement } from 'xmldoc';
import { logger } from '../../../logger'; import { logger } from '../../../logger';
import type { Http } from '../../../util/http'; import type { Http } from '../../../util/http';
import type { HttpResponse } from '../../../util/http/types';
import { regEx } from '../../../util/regex'; import { regEx } from '../../../util/regex';
import type { ReleaseResult } from '../types'; import type { ReleaseResult } from '../types';
import { massageUrl, removeBuildMeta } from './common'; import { massageUrl, removeBuildMeta } from './common';
function getPkgProp(pkgInfo: XmlElement, propName: string): string | undefined { export class NugetV2Api {
return pkgInfo.childNamed('m:properties')?.childNamed(`d:${propName}`)?.val; getPkgProp(pkgInfo: XmlElement, propName: string): string | undefined {
} return pkgInfo.childNamed('m:properties')?.childNamed(`d:${propName}`)?.val;
}
export async function getReleases( async getReleases(
http: Http, http: Http,
feedUrl: string, feedUrl: string,
pkgName: string, pkgName: string,
): Promise<ReleaseResult | null> { ): Promise<ReleaseResult | null> {
const dep: ReleaseResult = { const dep: ReleaseResult = {
releases: [], releases: [],
}; };
let pkgUrlList: string | null = `${feedUrl.replace( let pkgUrlList: string | null = `${feedUrl.replace(
regEx(/\/+$/), regEx(/\/+$/),
'', '',
)}/FindPackagesById()?id=%27${pkgName}%27&$select=Version,IsLatestVersion,ProjectUrl,Published`; )}/FindPackagesById()?id=%27${pkgName}%27&$select=Version,IsLatestVersion,ProjectUrl,Published`;
while (pkgUrlList !== null) { while (pkgUrlList !== null) {
// typescript issue // typescript issue
const pkgVersionsListRaw: HttpResponse<string> = await http.get(pkgUrlList); const pkgVersionsListRaw = await http.get(pkgUrlList);
const pkgVersionsListDoc = new XmlDocument(pkgVersionsListRaw.body); const pkgVersionsListDoc = new XmlDocument(pkgVersionsListRaw.body);
const pkgInfoList = pkgVersionsListDoc.childrenNamed('entry'); const pkgInfoList = pkgVersionsListDoc.childrenNamed('entry');
for (const pkgInfo of pkgInfoList) { for (const pkgInfo of pkgInfoList) {
const version = getPkgProp(pkgInfo, 'Version'); const version = this.getPkgProp(pkgInfo, 'Version');
const releaseTimestamp = getPkgProp(pkgInfo, 'Published'); const releaseTimestamp = this.getPkgProp(pkgInfo, 'Published');
dep.releases.push({ dep.releases.push({
// TODO: types (#22198) // TODO: types (#22198)
version: removeBuildMeta(`${version}`), version: removeBuildMeta(`${version}`),
releaseTimestamp, releaseTimestamp,
}); });
try { try {
const pkgIsLatestVersion = getPkgProp(pkgInfo, 'IsLatestVersion'); const pkgIsLatestVersion = this.getPkgProp(
if (pkgIsLatestVersion === 'true') { pkgInfo,
dep['tags'] = { latest: removeBuildMeta(`${version}`) }; 'IsLatestVersion',
const projectUrl = getPkgProp(pkgInfo, 'ProjectUrl'); );
if (projectUrl) { if (pkgIsLatestVersion === 'true') {
dep.sourceUrl = massageUrl(projectUrl); dep['tags'] = { latest: removeBuildMeta(`${version}`) };
const projectUrl = this.getPkgProp(pkgInfo, 'ProjectUrl');
if (projectUrl) {
dep.sourceUrl = massageUrl(projectUrl);
}
} }
} catch (err) /* istanbul ignore next */ {
logger.debug(
{ err, pkgName, feedUrl },
`nuget registry failure: can't parse pkg info for project url`,
);
} }
} catch (err) /* istanbul ignore next */ {
logger.debug(
{ err, pkgName, feedUrl },
`nuget registry failure: can't parse pkg info for project url`,
);
} }
const nextPkgUrlListLink = pkgVersionsListDoc
.childrenNamed('link')
.find((node) => node.attr.rel === 'next');
pkgUrlList = nextPkgUrlListLink ? nextPkgUrlListLink.attr.href : null;
} }
const nextPkgUrlListLink = pkgVersionsListDoc // dep not found if no release, so we can try next registry
.childrenNamed('link') if (dep.releases.length === 0) {
.find((node) => node.attr.rel === 'next'); return null;
}
pkgUrlList = nextPkgUrlListLink ? nextPkgUrlListLink.attr.href : null; return dep;
} }
// dep not found if no release, so we can try next registry
if (dep.releases.length === 0) {
return null;
}
return dep;
} }

View file

@ -10,7 +10,7 @@ import { regEx } from '../../../util/regex';
import { ensureTrailingSlash } from '../../../util/url'; import { ensureTrailingSlash } from '../../../util/url';
import { api as versioning } from '../../versioning/nuget'; import { api as versioning } from '../../versioning/nuget';
import type { Release, ReleaseResult } from '../types'; import type { Release, ReleaseResult } from '../types';
import { massageUrl, removeBuildMeta } from './common'; import { massageUrl, removeBuildMeta, sortNugetVersions } from './common';
import type { import type {
CatalogEntry, CatalogEntry,
CatalogPage, CatalogPage,
@ -18,227 +18,219 @@ import type {
ServicesIndexRaw, ServicesIndexRaw,
} from './types'; } from './types';
const cacheNamespace = 'datasource-nuget'; export class NugetV3Api {
static readonly cacheNamespace = 'datasource-nuget';
export async function getResourceUrl( async getResourceUrl(
http: Http, http: Http,
url: string, url: string,
resourceType = 'RegistrationsBaseUrl', resourceType = 'RegistrationsBaseUrl',
): Promise<string | null> { ): Promise<string | null> {
// https://docs.microsoft.com/en-us/nuget/api/service-index // https://docs.microsoft.com/en-us/nuget/api/service-index
const resultCacheKey = `${url}:${resourceType}`; const resultCacheKey = `${url}:${resourceType}`;
const cachedResult = await packageCache.get<string>( const cachedResult = await packageCache.get<string>(
cacheNamespace, NugetV3Api.cacheNamespace,
resultCacheKey, resultCacheKey,
);
// istanbul ignore if
if (cachedResult) {
return cachedResult;
}
let servicesIndexRaw: ServicesIndexRaw | undefined;
try {
const responseCacheKey = url;
servicesIndexRaw = await packageCache.get<ServicesIndexRaw>(
cacheNamespace,
responseCacheKey,
); );
// istanbul ignore else: currently not testable
if (!servicesIndexRaw) { // istanbul ignore if
servicesIndexRaw = (await http.getJson<ServicesIndexRaw>(url)).body; if (cachedResult) {
await packageCache.set( return cachedResult;
cacheNamespace,
responseCacheKey,
servicesIndexRaw,
3 * 24 * 60,
);
} }
let servicesIndexRaw: ServicesIndexRaw | undefined;
const services = servicesIndexRaw.resources try {
.map(({ '@id': serviceId, '@type': t }) => ({ const responseCacheKey = url;
serviceId, servicesIndexRaw = await packageCache.get<ServicesIndexRaw>(
type: t?.split('/')?.shift(), NugetV3Api.cacheNamespace,
version: t?.split('/')?.pop(), responseCacheKey,
}))
.filter(
({ type, version }) => type === resourceType && semver.valid(version),
)
.sort((x, y) =>
x.version && y.version
? semver.compare(x.version, y.version)
: /* istanbul ignore next: hard to test */ 0,
); );
// istanbul ignore else: currently not testable
if (!servicesIndexRaw) {
servicesIndexRaw = (await http.getJson<ServicesIndexRaw>(url)).body;
await packageCache.set(
NugetV3Api.cacheNamespace,
responseCacheKey,
servicesIndexRaw,
3 * 24 * 60,
);
}
if (services.length === 0) { const services = servicesIndexRaw.resources
await packageCache.set(cacheNamespace, resultCacheKey, null, 60); .map(({ '@id': serviceId, '@type': t }) => ({
serviceId,
type: t?.split('/')?.shift(),
version: t?.split('/')?.pop(),
}))
.filter(
({ type, version }) => type === resourceType && semver.valid(version),
)
.sort((x, y) =>
x.version && y.version
? semver.compare(x.version, y.version)
: /* istanbul ignore next: hard to test */ 0,
);
if (services.length === 0) {
await packageCache.set(
NugetV3Api.cacheNamespace,
resultCacheKey,
null,
60,
);
logger.debug(
{ url, servicesIndexRaw },
`no ${resourceType} services found`,
);
return null;
}
const { serviceId, version } = services.pop()!;
// istanbul ignore if
if (
resourceType === 'RegistrationsBaseUrl' &&
version &&
!version.startsWith('3.0.0-') &&
!semver.satisfies(version, '^3.0.0')
) {
logger.warn(
{ url, version },
`Nuget: Unknown version returned. Only v3 is supported`,
);
}
await packageCache.set(
NugetV3Api.cacheNamespace,
resultCacheKey,
serviceId,
60,
);
return serviceId;
} catch (err) {
// istanbul ignore if: not easy testable with nock
if (err instanceof ExternalHostError) {
throw err;
}
logger.debug( logger.debug(
{ url, servicesIndexRaw }, { err, url, servicesIndexRaw },
`no ${resourceType} services found`, `nuget registry failure: can't get ${resourceType}`,
); );
return null; return null;
} }
}
const { serviceId, version } = services.pop()!; async getCatalogEntry(
http: Http,
catalogPage: CatalogPage,
): Promise<CatalogEntry[]> {
let items = catalogPage.items;
if (!items) {
const url = catalogPage['@id'];
const catalogPageFull = await http.getJson<CatalogPage>(url);
items = catalogPageFull.body.items;
}
return items.map(({ catalogEntry }) => catalogEntry);
}
// istanbul ignore if async getReleases(
if ( http: Http,
resourceType === 'RegistrationsBaseUrl' && registryUrl: string,
version && feedUrl: string,
!version.startsWith('3.0.0-') && pkgName: string,
!semver.satisfies(version, '^3.0.0') ): Promise<ReleaseResult | null> {
) { const baseUrl = feedUrl.replace(regEx(/\/*$/), '');
logger.warn( const url = `${baseUrl}/${pkgName.toLowerCase()}/index.json`;
{ url, version }, const packageRegistration = await http.getJson<PackageRegistration>(url);
`Nuget: Unknown version returned. Only v3 is supported`, const catalogPages = packageRegistration.body.items || [];
const catalogPagesQueue = catalogPages.map(
(page) => (): Promise<CatalogEntry[]> => this.getCatalogEntry(http, page),
);
const catalogEntries = (await p.all(catalogPagesQueue))
.flat()
.sort((a, b) => sortNugetVersions(a.version, b.version));
let homepage: string | null = null;
let latestStable: string | null = null;
const releases = catalogEntries.map(
({ version, published: releaseTimestamp, projectUrl, listed }) => {
const release: Release = { version: removeBuildMeta(version) };
if (releaseTimestamp) {
release.releaseTimestamp = releaseTimestamp;
}
if (versioning.isValid(version) && versioning.isStable(version)) {
latestStable = removeBuildMeta(version);
homepage = projectUrl ? massageUrl(projectUrl) : homepage;
}
if (listed === false) {
release.isDeprecated = true;
}
return release;
},
);
if (!releases.length) {
return null;
}
// istanbul ignore next: only happens when no stable version exists
if (latestStable === null && catalogPages.length) {
const last = catalogEntries.pop()!;
latestStable = removeBuildMeta(last.version);
homepage ??= last.projectUrl ?? null;
}
const dep: ReleaseResult = {
releases,
};
try {
const packageBaseAddress = await this.getResourceUrl(
http,
registryUrl,
'PackageBaseAddress',
); );
} // istanbul ignore else: this is a required v3 api
if (is.nonEmptyString(packageBaseAddress)) {
await packageCache.set(cacheNamespace, resultCacheKey, serviceId, 60); const nuspecUrl = `${ensureTrailingSlash(
return serviceId; packageBaseAddress,
} catch (err) { )}${pkgName.toLowerCase()}/${
// istanbul ignore if: not easy testable with nock // TODO: types (#22198)
if (err instanceof ExternalHostError) { latestStable
throw err; }/${pkgName.toLowerCase()}.nuspec`;
} const metaresult = await http.get(nuspecUrl);
logger.debug( const nuspec = new XmlDocument(metaresult.body);
{ err, url, servicesIndexRaw }, const sourceUrl = nuspec.valueWithPath('metadata.repository@url');
`nuget registry failure: can't get ${resourceType}`, if (sourceUrl) {
); dep.sourceUrl = massageUrl(sourceUrl);
return null; }
}
}
async function getCatalogEntry(
http: Http,
catalogPage: CatalogPage,
): Promise<CatalogEntry[]> {
let items = catalogPage.items;
if (!items) {
const url = catalogPage['@id'];
const catalogPageFull = await http.getJson<CatalogPage>(url);
items = catalogPageFull.body.items;
}
return items.map(({ catalogEntry }) => catalogEntry);
}
/**
* Compare two versions. Return:
* - `1` if `a > b` or `b` is invalid
* - `-1` if `a < b` or `a` is invalid
* - `0` if `a == b` or both `a` and `b` are invalid
*/
export function sortNugetVersions(a: string, b: string): number {
if (versioning.isValid(a)) {
if (versioning.isValid(b)) {
return versioning.sortVersions(a, b);
} else {
return 1;
}
} else if (versioning.isValid(b)) {
return -1;
} else {
return 0;
}
}
export async function getReleases(
http: Http,
registryUrl: string,
feedUrl: string,
pkgName: string,
): Promise<ReleaseResult | null> {
const baseUrl = feedUrl.replace(regEx(/\/*$/), '');
const url = `${baseUrl}/${pkgName.toLowerCase()}/index.json`;
const packageRegistration = await http.getJson<PackageRegistration>(url);
const catalogPages = packageRegistration.body.items || [];
const catalogPagesQueue = catalogPages.map(
(page) => (): Promise<CatalogEntry[]> => getCatalogEntry(http, page),
);
const catalogEntries = (await p.all(catalogPagesQueue))
.flat()
.sort((a, b) => sortNugetVersions(a.version, b.version));
let homepage: string | null = null;
let latestStable: string | null = null;
const releases = catalogEntries.map(
({ version, published: releaseTimestamp, projectUrl, listed }) => {
const release: Release = { version: removeBuildMeta(version) };
if (releaseTimestamp) {
release.releaseTimestamp = releaseTimestamp;
} }
if (versioning.isValid(version) && versioning.isStable(version)) { } catch (err) {
latestStable = removeBuildMeta(version); // istanbul ignore if: not easy testable with nock
homepage = projectUrl ? massageUrl(projectUrl) : homepage; if (err instanceof ExternalHostError) {
throw err;
} }
if (listed === false) { // ignore / silence 404. Seen on proget, if remote connector is used and package is not yet cached
release.isDeprecated = true; if (err instanceof HttpError && err.response?.statusCode === 404) {
logger.debug(
{ registryUrl, pkgName, pkgVersion: latestStable },
`package manifest (.nuspec) not found`,
);
return dep;
} }
return release;
},
);
if (!releases.length) {
return null;
}
// istanbul ignore next: only happens when no stable version exists
if (latestStable === null && catalogPages.length) {
const last = catalogEntries.pop()!;
latestStable = removeBuildMeta(last.version);
homepage ??= last.projectUrl ?? null;
}
const dep: ReleaseResult = {
releases,
};
try {
const packageBaseAddress = await getResourceUrl(
http,
registryUrl,
'PackageBaseAddress',
);
// istanbul ignore else: this is a required v3 api
if (is.nonEmptyString(packageBaseAddress)) {
const nuspecUrl = `${ensureTrailingSlash(
packageBaseAddress,
)}${pkgName.toLowerCase()}/${
// TODO: types (#22198)
latestStable
}/${pkgName.toLowerCase()}.nuspec`;
const metaresult = await http.get(nuspecUrl);
const nuspec = new XmlDocument(metaresult.body);
const sourceUrl = nuspec.valueWithPath('metadata.repository@url');
if (sourceUrl) {
dep.sourceUrl = massageUrl(sourceUrl);
}
}
} catch (err) {
// istanbul ignore if: not easy testable with nock
if (err instanceof ExternalHostError) {
throw err;
}
// ignore / silence 404. Seen on proget, if remote connector is used and package is not yet cached
if (err instanceof HttpError && err.response?.statusCode === 404) {
logger.debug( logger.debug(
{ registryUrl, pkgName, pkgVersion: latestStable }, { err, registryUrl, pkgName, pkgVersion: latestStable },
`package manifest (.nuspec) not found`, `Cannot obtain sourceUrl`,
); );
return dep; return dep;
} }
logger.debug(
{ err, registryUrl, pkgName, pkgVersion: latestStable }, // istanbul ignore else: not easy testable
`Cannot obtain sourceUrl`, if (homepage) {
); // only assign if not assigned
dep.sourceUrl ??= homepage;
dep.homepage ??= homepage;
}
return dep; return dep;
} }
// istanbul ignore else: not easy testable
if (homepage) {
// only assign if not assigned
dep.sourceUrl ??= homepage;
dep.homepage ??= homepage;
}
return dep;
} }