diff --git a/services/feedz/feedz.service.js b/services/feedz/feedz.service.js new file mode 100644 index 0000000000..5cbaf13743 --- /dev/null +++ b/services/feedz/feedz.service.js @@ -0,0 +1,111 @@ +'use strict' + +const Joi = require('joi') +const { BaseJsonService, NotFound } = require('..') +const { + renderVersionBadge, + searchServiceUrl, + stripBuildMetadata, + selectVersion, +} = require('../nuget/nuget-helpers') + +const schema = Joi.object({ + items: Joi.array() + .items( + Joi.object({ + items: Joi.array().items( + Joi.object({ + catalogEntry: Joi.object({ + version: Joi.string().required(), + }).required(), + }) + ), + }).required() + ) + .max(1) + .default([]), +}).required() + +class FeedzVersionService extends BaseJsonService { + static category = 'version' + + static route = { + base: 'feedz', + pattern: ':which(v|vpre)/:organization/:repository/:packageName', + } + + static examples = [ + { + title: 'Feedz', + pattern: 'v/:organization/:repository/:packageName', + namedParams: { + organization: 'shieldstests', + repository: 'mongodb', + packageName: 'MongoDB.Driver.Core', + }, + staticPreview: this.render({ version: '2.10.4' }), + }, + { + title: 'Feedz (with prereleases)', + pattern: 'vpre/:organization/:repository/:packageName', + namedParams: { + organization: 'shieldstests', + repository: 'mongodb', + packageName: 'MongoDB.Driver.Core', + }, + staticPreview: this.render({ version: '2.11.0-beta2' }), + }, + ] + + static defaultBadgeData = { + label: 'feedz', + } + + static render(props) { + return renderVersionBadge(props) + } + + apiUrl({ organization, repository }) { + return `https://f.feedz.io/${organization}/${repository}/nuget` + } + + async fetch({ baseUrl, packageName }) { + const registrationsBaseUrl = await searchServiceUrl( + baseUrl, + 'RegistrationsBaseUrl' + ) + return await this._requestJson({ + schema, + url: `${registrationsBaseUrl}${packageName}/index.json`, + errorMessages: { + 404: 'repository or package not found', + }, + }) + } + + transform({ json, includePrereleases }) { + if (json.items.length === 1 && json.items[0].items.length > 0) { + const versions = json.items[0].items.map(i => + stripBuildMetadata(i.catalogEntry.version) + ) + return selectVersion(versions, includePrereleases) + } else { + throw new NotFound({ prettyMessage: 'package not found' }) + } + } + + async handle({ which, organization, repository, packageName }) { + const includePrereleases = which === 'vpre' + const baseUrl = this.apiUrl({ organization, repository }) + const json = await this.fetch({ baseUrl, packageName }) + const version = this.transform({ json, includePrereleases }) + return this.constructor.render({ + version, + feed: FeedzVersionService.defaultBadgeData.label, + }) + } +} + +module.exports = { + FeedzVersionService, +} diff --git a/services/feedz/feedz.service.spec.js b/services/feedz/feedz.service.spec.js new file mode 100644 index 0000000000..1db915e816 --- /dev/null +++ b/services/feedz/feedz.service.spec.js @@ -0,0 +1,69 @@ +'use strict' + +const { test, given } = require('sazerac') +const { FeedzVersionService } = require('./feedz.service') + +function json(versions) { + return { + items: [ + { + items: versions.map(v => ({ + catalogEntry: { + version: v, + }, + })), + }, + ], + } +} + +function noItemsJson() { + return { + items: [], + } +} + +describe('Feedz service', function () { + test(FeedzVersionService.prototype.apiUrl, () => { + given({ organization: 'shieldstests', repository: 'public' }).expect( + 'https://f.feedz.io/shieldstests/public/nuget' + ) + }) + + test(FeedzVersionService.prototype.transform, () => { + given({ json: json(['1.0.0']), includePrereleases: false }).expect('1.0.0') + given({ json: json(['1.0.0', '1.0.1']), includePrereleases: false }).expect( + '1.0.1' + ) + given({ + json: json(['1.0.0', '1.0.1-beta1']), + includePrereleases: false, + }).expect('1.0.0') + given({ + json: json(['1.0.0', '1.0.1-beta1']), + includePrereleases: true, + }).expect('1.0.1-beta1') + + given({ + json: json(['1.0.0+1', '1.0.1-beta1+1']), + includePrereleases: false, + }).expect('1.0.0') + given({ + json: json(['1.0.0+1', '1.0.1-beta1+1']), + includePrereleases: true, + }).expect('1.0.1-beta1') + + given({ json: json([]), includePrereleases: false }).expectError( + 'Not Found: package not found' + ) + given({ json: json([]), includePrereleases: true }).expectError( + 'Not Found: package not found' + ) + given({ json: noItemsJson(), includePrereleases: false }).expectError( + 'Not Found: package not found' + ) + given({ json: noItemsJson(), includePrereleases: true }).expectError( + 'Not Found: package not found' + ) + }) +}) diff --git a/services/feedz/feedz.tester.js b/services/feedz/feedz.tester.js new file mode 100644 index 0000000000..00a5193582 --- /dev/null +++ b/services/feedz/feedz.tester.js @@ -0,0 +1,89 @@ +'use strict' + +const { ServiceTester } = require('../tester') + +const t = (module.exports = new ServiceTester({ + id: 'feedz', + title: 'Feedz', + pathPrefix: '', +})) + +// The `shieldstests/public` repo is specifically made for these tests. It contains following packages: +// - Shields.NoV1: 0.1.0 +// - Shields.TestPackage: 0.0.1, 0.1.0-pre, 1.0.0 +// - Shields.TestPreOnly: 0.1.0-pre +// The source code of these packages is here: https://github.com/jakubfijalkowski/shields-test-packages + +// version +t.create('version (valid)') + .get('/feedz/v/shieldstests/public/Shields.TestPackage.json') + .expectBadge({ + label: 'feedz', + message: 'v1.0.0', + color: 'blue', + }) + +t.create('version (yellow badge)') + .get('/feedz/v/shieldstests/public/Shields.TestPreOnly.json') + .expectBadge({ + label: 'feedz', + message: 'v0.1.0-pre', + color: 'yellow', + }) + +t.create('version (orange badge)') + .get('/feedz/v/shieldstests/public/Shields.NoV1.json') + .expectBadge({ + label: 'feedz', + message: 'v0.1.0', + color: 'orange', + }) + +t.create('repository (not found)') + .get('/feedz/v/foo/bar/not-a-real-package.json') + .expectBadge({ label: 'feedz', message: 'repository or package not found' }) + +t.create('version (not found)') + .get('/feedz/v/shieldstests/public/not-a-real-package.json') + .expectBadge({ label: 'feedz', message: 'repository or package not found' }) + +t.create('non-existing repository') + .get('/feedz/v/shieldstests/does-not-exist/Shields.TestPackage.json') + .expectBadge({ label: 'feedz', message: 'repository or package not found' }) + +// version (pre) +t.create('version (pre) (valid)') + .get('/feedz/vpre/shieldstests/public/Shields.TestPackage.json') + .expectBadge({ + label: 'feedz', + message: 'v1.0.0', + color: 'blue', + }) + +t.create('version (pre) (yellow badge)') + .get('/feedz/vpre/shieldstests/public/Shields.TestPreOnly.json') + .expectBadge({ + label: 'feedz', + message: 'v0.1.0-pre', + color: 'yellow', + }) + +t.create('version (pre) (orange badge)') + .get('/feedz/vpre/shieldstests/public/Shields.NoV1.json') + .expectBadge({ + label: 'feedz', + message: 'v0.1.0', + color: 'orange', + }) + +t.create('repository (pre) (not found)') + .get('/feedz/vpre/foo/bar/not-a-real-package.json') + .expectBadge({ label: 'feedz', message: 'repository or package not found' }) + +t.create('version (pre) (not found)') + .get('/feedz/vpre/shieldstests/public/not-a-real-package.json') + .expectBadge({ label: 'feedz', message: 'repository or package not found' }) + +t.create('non-existing repository') + .get('/feedz/vpre/shieldstests/does-not-exist/Shields.TestPackage.json') + .expectBadge({ label: 'feedz', message: 'repository or package not found' }) diff --git a/services/nuget/nuget-helpers.js b/services/nuget/nuget-helpers.js index 849cac3ef6..63603c2d5c 100644 --- a/services/nuget/nuget-helpers.js +++ b/services/nuget/nuget-helpers.js @@ -1,7 +1,10 @@ 'use strict' +const { promisify } = require('util') +const semver = require('semver') const { metric, addv } = require('../text-formatters') const { downloadCount: downloadCountColor } = require('../color-formatters') +const { regularUpdate } = require('../../core/legacy/regular-update') function renderVersionBadge({ version, feed }) { let color @@ -40,8 +43,71 @@ function odataToObject(odata) { return result } +function randomElementFrom(items) { + const index = Math.floor(Math.random() * items.length) + return items[index] +} + +/* + * Hit the service index endpoint and return a {serviceType} URL, chosen + * at random. Cache the responses, but return a different random URL each time. + */ +async function searchServiceUrl(baseUrl, serviceType = 'SearchQueryService') { + // Should we really be caching all these NuGet feeds in memory? + const searchQueryServices = await promisify(regularUpdate)({ + url: `${baseUrl}/index.json`, + // The endpoint changes once per year (ie, a period of n = 1 year). + // We minimize the users' waiting time for information. + // With l = latency to fetch the endpoint and x = endpoint update period + // both in years, the yearly number of queries for the endpoint are 1/x, + // and when the endpoint changes, we wait for up to x years to get the + // right endpoint. + // So the waiting time within n years is n*l/x + x years, for which a + // derivation yields an optimum at x = sqrt(n*l), roughly 42 minutes. + intervalMillis: 42 * 60 * 1000, + json: true, + scraper: json => + json.resources.filter(resource => resource['@type'] === serviceType), + }) + return randomElementFrom(searchQueryServices)['@id'] +} + +/* + * Strip Build MetaData + * Nuget versions may include an optional "build metadata" clause, + * separated from the version by a + character. + */ +function stripBuildMetadata(version) { + return version.split('+')[0] +} + +/* + * Select latest version from NuGet feed, filtering-out prerelease versions if needed + */ +function selectVersion(versions, includePrereleases) { + if (includePrereleases) { + return versions.slice(-1).pop() + } else { + const filtered = versions.filter(i => { + if (semver.valid(i)) { + return !semver.prerelease(i) + } else { + return !i.includes('-') + } + }) + if (filtered.length > 0) { + return filtered.slice(-1).pop() + } else { + return versions.slice(-1).pop() + } + } +} + module.exports = { renderVersionBadge, renderDownloadBadge, odataToObject, + searchServiceUrl, + stripBuildMetadata, + selectVersion, } diff --git a/services/nuget/nuget-helpers.spec.js b/services/nuget/nuget-helpers.spec.js index 6cd4b752bd..a4e40a94e8 100644 --- a/services/nuget/nuget-helpers.spec.js +++ b/services/nuget/nuget-helpers.spec.js @@ -1,7 +1,12 @@ 'use strict' const { test, given } = require('sazerac') -const { renderVersionBadge, odataToObject } = require('./nuget-helpers') +const { + renderVersionBadge, + odataToObject, + stripBuildMetadata, + selectVersion, +} = require('./nuget-helpers') describe('NuGet helpers', function () { test(renderVersionBadge, () => { @@ -28,4 +33,18 @@ describe('NuGet helpers', function () { }) given(undefined).expect(undefined) }) + + test(stripBuildMetadata, () => { + given('1.0.0').expect('1.0.0') + given('1.0.0+1').expect('1.0.0') + }) + + test(selectVersion, () => { + given(['1.0.0', '1.0.1'], false).expect('1.0.1') + given(['1.0.0', '1.0.1'], true).expect('1.0.1') + given(['1.0.0', '1.0.1-pre'], false).expect('1.0.0') + given(['1.0.0', '1.0.1-pre'], true).expect('1.0.1-pre') + given(['1.0.0', '1.0.1.0.1.0-pre'], false).expect('1.0.0') + given(['1.0.0', '1.0.1.0.1.0-pre'], true).expect('1.0.1.0.1.0-pre') + }) }) diff --git a/services/nuget/nuget-v3-service-family.js b/services/nuget/nuget-v3-service-family.js index 2ac2577560..01faeab660 100644 --- a/services/nuget/nuget-v3-service-family.js +++ b/services/nuget/nuget-v3-service-family.js @@ -1,12 +1,15 @@ 'use strict' -const { promisify } = require('util') const Joi = require('joi') -const semver = require('semver') -const { regularUpdate } = require('../../core/legacy/regular-update') const RouteBuilder = require('../route-builder') const { BaseJsonService, NotFound } = require('..') -const { renderVersionBadge, renderDownloadBadge } = require('./nuget-helpers') +const { + renderVersionBadge, + renderDownloadBadge, + searchServiceUrl, + stripBuildMetadata, + selectVersion, +} = require('./nuget-helpers') /* * Build the Shields service URL object for the given service configuration. Return @@ -47,37 +50,6 @@ function apiUrl({ withTenant, apiBaseUrl, apiDomain, tenant, withFeed, feed }) { return result } -function randomElementFrom(items) { - const index = Math.floor(Math.random() * items.length) - return items[index] -} - -/* - * Hit the service index endpoint and return a SearchQueryService URL, chosen - * at random. Cache the responses, but return a different random URL each time. - */ -async function searchQueryServiceUrl(baseUrl) { - // Should we really be caching all these NuGet feeds in memory? - const searchQueryServices = await promisify(regularUpdate)({ - url: `${baseUrl}/index.json`, - // The endpoint changes once per year (ie, a period of n = 1 year). - // We minimize the users' waiting time for information. - // With l = latency to fetch the endpoint and x = endpoint update period - // both in years, the yearly number of queries for the endpoint are 1/x, - // and when the endpoint changes, we wait for up to x years to get the - // right endpoint. - // So the waiting time within n years is n*l/x + x years, for which a - // derivation yields an optimum at x = sqrt(n*l), roughly 42 minutes. - intervalMillis: 42 * 60 * 1000, - json: true, - scraper: json => - json.resources.filter( - resource => resource['@type'] === 'SearchQueryService' - ), - }) - return randomElementFrom(searchQueryServices)['@id'] -} - const schema = Joi.object({ data: Joi.array() .items( @@ -97,15 +69,6 @@ const schema = Joi.object({ .default([]), }).required() -/* - * Strip Build MetaData - * Nuget versions may include an optional "build metadata" clause, - * separated from the version by a + character. - */ -function stripBuildMetadata(version) { - return version.split('+')[0] -} - /* * Get information about a single package. */ @@ -115,7 +78,7 @@ async function fetch( ) { const json = await serviceInstance._requestJson({ schema, - url: await searchQueryServiceUrl(baseUrl), + url: await searchServiceUrl(baseUrl, 'SearchQueryService'), options: { qs: { q: `packageid:${encodeURIComponent(packageName.toLowerCase())}`, @@ -177,6 +140,7 @@ function createServiceFamily({ } async handle({ tenant, feed, which, packageName }) { + const includePrereleases = which === 'vpre' const baseUrl = apiUrl({ withTenant, apiBaseUrl, @@ -186,23 +150,8 @@ function createServiceFamily({ feed, }) let { versions } = await fetch(this, { baseUrl, packageName }) - versions = versions.map(item => ({ - version: stripBuildMetadata(item.version), - })) - let latest = versions.slice(-1).pop() - const includePrereleases = which === 'vpre' - if (!includePrereleases) { - const filtered = versions.filter(item => { - if (semver.valid(item.version)) { - return !semver.prerelease(item.version) - } - return !item.version.includes('-') - }) - if (filtered.length) { - latest = filtered.slice(-1).pop() - } - } - const { version } = latest + versions = versions.map(item => stripBuildMetadata(item.version)) + const version = selectVersion(versions, includePrereleases) return this.constructor.render({ version, feed }) } }