diff --git a/lib/regular-update.js b/lib/regular-update.js index 1c359d90e3..528615fb52 100644 --- a/lib/regular-update.js +++ b/lib/regular-update.js @@ -1,5 +1,7 @@ 'use strict' +const { Inaccessible, InvalidResponse } = require('../services/errors') + // Map from URL to { timestamp: last fetch time, data: data }. let regularUpdateCache = Object.create(null) @@ -42,16 +44,32 @@ function regularUpdate( } request(url, options, (err, res, buffer) => { if (err != null) { - cb(err) + cb( + new Inaccessible({ + prettyMessage: 'intermediate resource inaccessible', + underlyingError: err, + }) + ) return } + if (res.statusCode < 200 || res.statusCode >= 300) { + throw new InvalidResponse({ + prettyMessage: 'intermediate resource inaccessible', + }) + } + let reqData if (json) { try { reqData = JSON.parse(buffer) } catch (e) { - cb(e) + cb( + new InvalidResponse({ + prettyMessage: 'unparseable intermediate json response', + underlyingError: e, + }) + ) return } } else { diff --git a/services/chocolatey/chocolatey.service.js b/services/chocolatey/chocolatey.service.js new file mode 100644 index 0000000000..1a1e3bb50d --- /dev/null +++ b/services/chocolatey/chocolatey.service.js @@ -0,0 +1,9 @@ +'use strict' + +const { createServiceFamily } = require('../nuget/nuget-v2-service-family') + +module.exports = createServiceFamily({ + defaultLabel: 'chocolatey', + serviceBaseUrl: 'chocolatey', + apiBaseUrl: 'https://www.chocolatey.org/api/v2', +}) diff --git a/services/chocolatey/chocolatey.tester.js b/services/chocolatey/chocolatey.tester.js index 591c6710d6..a795dd1bb9 100644 --- a/services/chocolatey/chocolatey.tester.js +++ b/services/chocolatey/chocolatey.tester.js @@ -43,11 +43,11 @@ t.create('total downloads (unexpected response)') .intercept(nock => nock('https://www.chocolatey.org') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27scriptcs%27%20and%20IsLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27scriptcs%27%20and%20IsLatestVersion%20eq%20true' ) .reply(invalidJSON) ) - .expectJSON({ name: 'downloads', value: 'invalid' }) + .expectJSON({ name: 'downloads', value: 'unparseable json response' }) // version @@ -65,7 +65,7 @@ t.create('version (mocked, yellow badge)') .intercept(nock => nock('https://www.chocolatey.org') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27scriptcs%27%20and%20IsLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27scriptcs%27%20and%20IsLatestVersion%20eq%20true' ) .reply(200, nuGetV2VersionJsonWithDash) ) @@ -80,7 +80,7 @@ t.create('version (mocked, orange badge)') .intercept(nock => nock('https://www.chocolatey.org') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27scriptcs%27%20and%20IsLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27scriptcs%27%20and%20IsLatestVersion%20eq%20true' ) .reply(200, nuGetV2VersionJsonFirstCharZero) ) @@ -95,7 +95,7 @@ t.create('version (mocked, blue badge)') .intercept(nock => nock('https://www.chocolatey.org') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27scriptcs%27%20and%20IsLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27scriptcs%27%20and%20IsLatestVersion%20eq%20true' ) .reply(200, nuGetV2VersionJsonFirstCharNotZero) ) @@ -119,11 +119,11 @@ t.create('version (unexpected response)') .intercept(nock => nock('https://www.chocolatey.org') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27scriptcs%27%20and%20IsLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27scriptcs%27%20and%20IsLatestVersion%20eq%20true' ) .reply(invalidJSON) ) - .expectJSON({ name: 'chocolatey', value: 'invalid' }) + .expectJSON({ name: 'chocolatey', value: 'unparseable json response' }) // version (pre) @@ -141,7 +141,7 @@ t.create('version (pre) (mocked, yellow badge)') .intercept(nock => nock('https://www.chocolatey.org') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27scriptcs%27%20and%20IsAbsoluteLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27scriptcs%27%20and%20IsAbsoluteLatestVersion%20eq%20true' ) .reply(200, nuGetV2VersionJsonWithDash) ) @@ -156,7 +156,7 @@ t.create('version (pre) (mocked, orange badge)') .intercept(nock => nock('https://www.chocolatey.org') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27scriptcs%27%20and%20IsAbsoluteLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27scriptcs%27%20and%20IsAbsoluteLatestVersion%20eq%20true' ) .reply(200, nuGetV2VersionJsonFirstCharZero) ) @@ -171,7 +171,7 @@ t.create('version (pre) (mocked, blue badge)') .intercept(nock => nock('https://www.chocolatey.org') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27scriptcs%27%20and%20IsAbsoluteLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27scriptcs%27%20and%20IsAbsoluteLatestVersion%20eq%20true' ) .reply(200, nuGetV2VersionJsonFirstCharNotZero) ) @@ -195,8 +195,8 @@ t.create('version (pre) (unexpected response)') .intercept(nock => nock('https://www.chocolatey.org') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27scriptcs%27%20and%20IsAbsoluteLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27scriptcs%27%20and%20IsAbsoluteLatestVersion%20eq%20true' ) .reply(invalidJSON) ) - .expectJSON({ name: 'chocolatey', value: 'invalid' }) + .expectJSON({ name: 'chocolatey', value: 'unparseable json response' }) diff --git a/services/myget/myget.service.js b/services/myget/myget.service.js new file mode 100644 index 0000000000..3ea9fd1fee --- /dev/null +++ b/services/myget/myget.service.js @@ -0,0 +1,9 @@ +'use strict' + +const { createServiceFamily } = require('../nuget/nuget-v3-service-family') + +module.exports = createServiceFamily({ + defaultLabel: 'myget', + serviceBaseUrl: 'myget', + apiDomain: 'myget.org', +}) diff --git a/services/myget/myget.tester.js b/services/myget/myget.tester.js index c0d37e6a5f..24ac71a48f 100644 --- a/services/myget/myget.tester.js +++ b/services/myget/myget.tester.js @@ -4,7 +4,6 @@ const Joi = require('joi') const ServiceTester = require('../service-tester') const { isMetric, - isVPlusDottedVersionNClauses, isVPlusDottedVersionNClausesWithOptionalSuffix, } = require('../test-validators') const colorscheme = require('../../lib/colorscheme.json') @@ -16,13 +15,22 @@ const { } = require('../nuget-fixtures') const { invalidJSON } = require('../response-fixtures') -const t = new ServiceTester({ id: 'myget', title: 'MyGet' }) +const t = new ServiceTester({ id: 'myget', title: 'MyGet', pathPrefix: '' }) module.exports = t // downloads t.create('total downloads (valid)') - .get('/mongodb/dt/MongoDB.Driver.Core.json') + .get('/myget/mongodb/dt/MongoDB.Driver.Core.json') + .expectJSONTypes( + Joi.object().keys({ + name: 'downloads', + value: isMetric, + }) + ) + +t.create('total downloads (tenant)') + .get('/dotnet.myget/dotnet-coreclr/dt/Microsoft.DotNet.CoreCLR.json') .expectJSONTypes( Joi.object().keys({ name: 'downloads', @@ -31,25 +39,33 @@ t.create('total downloads (valid)') ) t.create('total downloads (not found)') - .get('/mongodb/dt/not-a-real-package.json') - .expectJSON({ name: 'downloads', value: 'not found' }) + .get('/myget/mongodb/dt/not-a-real-package.json') + .expectJSON({ name: 'downloads', value: 'package not found' }) +// This tests the erroring behavior in regular-update. t.create('total downloads (connection error)') - .get('/mongodb/dt/MongoDB.Driver.Core.json') + .get('/myget/mongodb/dt/MongoDB.Driver.Core.json') .networkOff() - .expectJSON({ name: 'downloads', value: 'inaccessible' }) + .expectJSON({ + name: 'downloads', + value: 'intermediate resource inaccessible', + }) +// This tests the erroring behavior in regular-update. t.create('total downloads (unexpected first response)') - .get('/mongodb/dt/MongoDB.Driver.Core.json') + .get('/myget/mongodb/dt/MongoDB.Driver.Core.json') .intercept(nock => nock('https://www.myget.org') .get('/F/mongodb/api/v3/index.json') .reply(invalidJSON) ) - .expectJSON({ name: 'downloads', value: 'invalid' }) + .expectJSON({ + name: 'downloads', + value: 'unparseable intermediate json response', + }) t.create('total downloads (unexpected second response)') - .get('/mongodb/dt/MongoDB.Driver.Core.json') + .get('/myget/mongodb/dt/MongoDB.Driver.Core.json') .intercept(nock => nock('https://www.myget.org') .get('/F/mongodb/api/v3/index.json') @@ -58,25 +74,34 @@ t.create('total downloads (unexpected second response)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:mongodb.driver.core&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2' ) .reply(invalidJSON) ) - .expectJSON({ name: 'downloads', value: 'invalid' }) + .expectJSON({ name: 'downloads', value: 'unparseable json response' }) // version t.create('version (valid)') - .get('/mongodb/v/MongoDB.Driver.Core.json') + .get('/myget/mongodb/v/MongoDB.Driver.Core.json') .expectJSONTypes( Joi.object().keys({ name: 'mongodb', - value: isVPlusDottedVersionNClauses, + value: isVPlusDottedVersionNClausesWithOptionalSuffix, + }) + ) + +t.create('total downloads (tenant)') + .get('/dotnet.myget/dotnet-coreclr/v/Microsoft.DotNet.CoreCLR.json') + .expectJSONTypes( + Joi.object().keys({ + name: 'dotnet-coreclr', + value: isVPlusDottedVersionNClausesWithOptionalSuffix, }) ) t.create('version (mocked, yellow badge)') - .get('/mongodb/v/MongoDB.Driver.Core.json?style=_shields_test') + .get('/myget/mongodb/v/MongoDB.Driver.Core.json?style=_shields_test') .intercept(nock => nock('https://www.myget.org') .get('/F/mongodb/api/v3/index.json') @@ -85,7 +110,7 @@ t.create('version (mocked, yellow badge)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:mongodb.driver.core&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2' ) .reply(200, nuGetV3VersionJsonWithDash) ) @@ -96,7 +121,7 @@ t.create('version (mocked, yellow badge)') }) t.create('version (mocked, orange badge)') - .get('/mongodb/v/MongoDB.Driver.Core.json?style=_shields_test') + .get('/myget/mongodb/v/MongoDB.Driver.Core.json?style=_shields_test') .intercept(nock => nock('https://www.myget.org') .get('/F/mongodb/api/v3/index.json') @@ -105,7 +130,7 @@ t.create('version (mocked, orange badge)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:mongodb.driver.core&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2' ) .reply(200, nuGetV3VersionJsonFirstCharZero) ) @@ -116,7 +141,7 @@ t.create('version (mocked, orange badge)') }) t.create('version (mocked, blue badge)') - .get('/mongodb/v/MongoDB.Driver.Core.json?style=_shields_test') + .get('/myget/mongodb/v/MongoDB.Driver.Core.json?style=_shields_test') .intercept(nock => nock('https://www.myget.org') .get('/F/mongodb/api/v3/index.json') @@ -125,7 +150,7 @@ t.create('version (mocked, blue badge)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:mongodb.driver.core&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2' ) .reply(200, nuGetV3VersionJsonFirstCharNotZero) ) @@ -136,25 +161,11 @@ t.create('version (mocked, blue badge)') }) t.create('version (not found)') - .get('/foo/v/not-a-real-package.json') - .expectJSON({ name: 'foo', value: 'not found' }) - -t.create('version (connection error)') - .get('/mongodb/v/MongoDB.Driver.Core.json') - .networkOff() - .expectJSON({ name: 'mongodb', value: 'inaccessible' }) - -t.create('version (unexpected first response)') - .get('/mongodb/v/MongoDB.Driver.Core.json') - .intercept(nock => - nock('https://www.myget.org') - .get('/F/mongodb/api/v3/index.json') - .reply(invalidJSON) - ) - .expectJSON({ name: 'mongodb', value: 'invalid' }) + .get('/myget/foo/v/not-a-real-package.json') + .expectJSON({ name: 'myget', value: 'package not found' }) t.create('version (unexpected second response)') - .get('/mongodb/v/MongoDB.Driver.Core.json') + .get('/myget/mongodb/v/MongoDB.Driver.Core.json') .intercept(nock => nock('https://www.myget.org') .get('/F/mongodb/api/v3/index.json') @@ -163,16 +174,16 @@ t.create('version (unexpected second response)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:mongodb.driver.core&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2' ) .reply(invalidJSON) ) - .expectJSON({ name: 'mongodb', value: 'invalid' }) + .expectJSON({ name: 'myget', value: 'unparseable json response' }) // version (pre) t.create('version (pre) (valid)') - .get('/mongodb/vpre/MongoDB.Driver.Core.json') + .get('/myget/mongodb/vpre/MongoDB.Driver.Core.json') .expectJSONTypes( Joi.object().keys({ name: 'mongodb', @@ -181,7 +192,7 @@ t.create('version (pre) (valid)') ) t.create('version (pre) (mocked, yellow badge)') - .get('/mongodb/vpre/MongoDB.Driver.Core.json?style=_shields_test') + .get('/myget/mongodb/vpre/MongoDB.Driver.Core.json?style=_shields_test') .intercept(nock => nock('https://www.myget.org') .get('/F/mongodb/api/v3/index.json') @@ -190,7 +201,7 @@ t.create('version (pre) (mocked, yellow badge)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:mongodb.driver.core&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2' ) .reply(200, nuGetV3VersionJsonWithDash) ) @@ -201,7 +212,7 @@ t.create('version (pre) (mocked, yellow badge)') }) t.create('version (pre) (mocked, orange badge)') - .get('/mongodb/vpre/MongoDB.Driver.Core.json?style=_shields_test') + .get('/myget/mongodb/vpre/MongoDB.Driver.Core.json?style=_shields_test') .intercept(nock => nock('https://www.myget.org') .get('/F/mongodb/api/v3/index.json') @@ -210,7 +221,7 @@ t.create('version (pre) (mocked, orange badge)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:mongodb.driver.core&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2' ) .reply(200, nuGetV3VersionJsonFirstCharZero) ) @@ -221,7 +232,7 @@ t.create('version (pre) (mocked, orange badge)') }) t.create('version (pre) (mocked, blue badge)') - .get('/mongodb/vpre/MongoDB.Driver.Core.json?style=_shields_test') + .get('/myget/mongodb/vpre/MongoDB.Driver.Core.json?style=_shields_test') .intercept(nock => nock('https://www.myget.org') .get('/F/mongodb/api/v3/index.json') @@ -230,7 +241,7 @@ t.create('version (pre) (mocked, blue badge)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:mongodb.driver.core&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2' ) .reply(200, nuGetV3VersionJsonFirstCharNotZero) ) @@ -241,25 +252,11 @@ t.create('version (pre) (mocked, blue badge)') }) t.create('version (pre) (not found)') - .get('/foo/vpre/not-a-real-package.json') - .expectJSON({ name: 'foo', value: 'not found' }) - -t.create('version (pre) (connection error)') - .get('/mongodb/vpre/MongoDB.Driver.Core.json') - .networkOff() - .expectJSON({ name: 'mongodb', value: 'inaccessible' }) - -t.create('version (pre) (unexpected first response)') - .get('/mongodb/vpre/MongoDB.Driver.Core.json') - .intercept(nock => - nock('https://www.myget.org') - .get('/F/mongodb/api/v3/index.json') - .reply(invalidJSON) - ) - .expectJSON({ name: 'mongodb', value: 'invalid' }) + .get('/myget/foo/vpre/not-a-real-package.json') + .expectJSON({ name: 'myget', value: 'package not found' }) t.create('version (pre) (unexpected second response)') - .get('/mongodb/vpre/MongoDB.Driver.Core.json') + .get('/myget/mongodb/vpre/MongoDB.Driver.Core.json') .intercept(nock => nock('https://www.myget.org') .get('/F/mongodb/api/v3/index.json') @@ -268,8 +265,8 @@ t.create('version (pre) (unexpected second response)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:mongodb.driver.core&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amongodb.driver.core&prerelease=true&semVerLevel=2' ) .reply(invalidJSON) ) - .expectJSON({ name: 'mongodb', value: 'invalid' }) + .expectJSON({ name: 'myget', value: 'unparseable json response' }) diff --git a/services/nuget-fixtures.js b/services/nuget-fixtures.js index a5c5e1acb5..d5842ea5c7 100644 --- a/services/nuget-fixtures.js +++ b/services/nuget-fixtures.js @@ -11,23 +11,26 @@ const queryIndex = JSON.stringify({ const nuGetV2VersionJsonWithDash = JSON.stringify({ d: { - results: [{ NormalizedVersion: '1.2-beta' }], + results: [ + { NormalizedVersion: '1.2-beta', Version: 'xxx', DownloadCount: 0 }, + ], }, }) const nuGetV2VersionJsonFirstCharZero = JSON.stringify({ d: { - results: [{ NormalizedVersion: '0.35' }], + results: [{ NormalizedVersion: '0.35', Version: 'xxx', DownloadCount: 0 }], }, }) const nuGetV2VersionJsonFirstCharNotZero = JSON.stringify({ d: { - results: [{ NormalizedVersion: '1.2.7' }], + results: [{ NormalizedVersion: '1.2.7', Version: 'xxx', DownloadCount: 0 }], }, }) const nuGetV3VersionJsonWithDash = JSON.stringify({ data: [ { + totalDownloads: 0, versions: [{ version: '1.2-beta' }], }, ], @@ -35,6 +38,7 @@ const nuGetV3VersionJsonWithDash = JSON.stringify({ const nuGetV3VersionJsonFirstCharZero = JSON.stringify({ data: [ { + totalDownloads: 0, versions: [{ version: '0.35' }], }, ], @@ -42,6 +46,7 @@ const nuGetV3VersionJsonFirstCharZero = JSON.stringify({ const nuGetV3VersionJsonFirstCharNotZero = JSON.stringify({ data: [ { + totalDownloads: 0, versions: [{ version: '1.2.7' }], }, ], diff --git a/services/nuget/nuget-helpers.js b/services/nuget/nuget-helpers.js new file mode 100644 index 0000000000..59731824bf --- /dev/null +++ b/services/nuget/nuget-helpers.js @@ -0,0 +1,35 @@ +'use strict' + +const { metric, addv } = require('../../lib/text-formatters') +const { + downloadCount: downloadCountColor, +} = require('../../lib/color-formatters') + +function renderVersionBadge({ version, feed }) { + let color + if (version.includes('-')) { + color = 'yellow' + } else if (version.startsWith('0')) { + color = 'orange' + } else { + color = 'blue' + } + + return { + message: addv(version), + color, + label: feed, + } +} + +function renderDownloadBadge({ downloads }) { + return { + message: metric(downloads), + color: downloadCountColor(downloads), + } +} + +module.exports = { + renderVersionBadge, + renderDownloadBadge, +} diff --git a/services/nuget/nuget-v2-service-family.js b/services/nuget/nuget-v2-service-family.js new file mode 100644 index 0000000000..6e3ef97f56 --- /dev/null +++ b/services/nuget/nuget-v2-service-family.js @@ -0,0 +1,142 @@ +'use strict' + +const Joi = require('joi') + +const BaseJsonService = require('../base-json') +const { NotFound } = require('../errors') +const { nonNegativeInteger } = require('../validators') +const { renderVersionBadge, renderDownloadBadge } = require('./nuget-helpers') + +function createFilter({ packageName, includePrereleases }) { + const releaseTypeFilter = includePrereleases + ? 'IsAbsoluteLatestVersion eq true' + : 'IsLatestVersion eq true' + return `Id eq '${packageName}' and ${releaseTypeFilter}` +} + +const schema = Joi.object({ + d: Joi.object({ + results: Joi.array() + .items( + Joi.object({ + Version: Joi.string(), + NormalizedVersion: Joi.string(), + DownloadCount: nonNegativeInteger, + }) + ) + .max(1) + .default([]), + }).required(), +}).required() + +async function fetch( + serviceInstance, + { baseUrl, packageName, includePrereleases = false } +) { + const data = await serviceInstance._requestJson({ + schema, + url: `${baseUrl}/Packages()`, + options: { + headers: { Accept: 'application/atom+json,application/json' }, + qs: { $filter: createFilter({ packageName, includePrereleases }) }, + }, + }) + + const packageData = data.d.results[0] + + if (packageData) { + return packageData + } else if (!includePrereleases) { + return fetch(serviceInstance, { + baseUrl, + packageName, + includePrereleases: true, + }) + } else { + throw new NotFound() + } +} + +/* + * Create a version and download service for a NuGet v2 API. Return an object + * containing both services. + * + * defaultLabel: The label for the left hand side of the badge. + * serviceBaseUrl: The base URL for the Shields service, e.g. chocolatey, resharper + * apiBaseUrl: The complete base URL of the API, e.g. https://api.example.com/api/v2 + */ +function createServiceFamily({ defaultLabel, serviceBaseUrl, apiBaseUrl }) { + class NugetVersionService extends BaseJsonService { + static get category() { + return 'version' + } + + static get route() { + return { + base: serviceBaseUrl, + pattern: ':which(v|vpre)/:packageName', + } + } + + static get examples() { + return [] + } + + static get defaultBadgeData() { + return { + label: defaultLabel, + } + } + + static render(props) { + return renderVersionBadge(props) + } + + async handle({ which, packageName }) { + const packageData = await fetch(this, { + baseUrl: apiBaseUrl, + packageName, + includePrereleases: which === 'vpre', + }) + const version = packageData.NormalizedVersion || packageData.Version + return this.constructor.render({ version }) + } + } + + class NugetDownloadService extends BaseJsonService { + static get category() { + return 'downloads' + } + + static get route() { + return { + base: serviceBaseUrl, + pattern: 'dt/:packageName', + } + } + + static get examples() { + return [] + } + + static render(props) { + return renderDownloadBadge(props) + } + + async handle({ packageName }) { + const packageData = await fetch(this, { + baseUrl: apiBaseUrl, + packageName, + }) + const { DownloadCount: downloads } = packageData + return this.constructor.render({ downloads }) + } + } + + return { NugetVersionService, NugetDownloadService } +} + +module.exports = { + createFilter, + createServiceFamily, +} diff --git a/services/nuget/nuget-v3-service-family.js b/services/nuget/nuget-v3-service-family.js new file mode 100644 index 0000000000..7b8240c195 --- /dev/null +++ b/services/nuget/nuget-v3-service-family.js @@ -0,0 +1,251 @@ +'use strict' + +const { promisify } = require('util') +const Joi = require('joi') +const { regularUpdate } = require('../../lib/regular-update') +const RouteBuilder = require('../route-builder') +const BaseJsonService = require('../base-json') +const { NotFound } = require('../errors') +const { renderVersionBadge, renderDownloadBadge } = require('./nuget-helpers') + +/* + * Build the Shields service URL object for the given service configuration. Return + * the RouteBuilder instance to which the service can add the route. + */ +function buildRoute({ serviceBaseUrl, withTenant, withFeed }) { + let result + if (withTenant) { + result = new RouteBuilder().push(`(?:(.+)\\.)?${serviceBaseUrl}`, 'tenant') + } else { + result = new RouteBuilder({ base: serviceBaseUrl }) + } + if (withFeed) { + result.push('([^/]+)', 'feed') + } + return result +} + +/* + * Construct the URL for an individual request. + * + * `apiBaseUrl`, `apiDomain`, `withTenant` and `withFeed` come from the service + * configuration. When `withTenant` and `withFeed` are false, return + * `apiBaseUrl` for every request. + * + * When `withTenant` and/or `withFeed` are true, `tenant` and `feed` come from the + * request, and this returns a different URL for each request. + * + * In practice, `withTenant` and `withFeed` are used together, for MyGet. + */ +function apiUrl({ withTenant, apiBaseUrl, apiDomain, tenant, withFeed, feed }) { + let result = withTenant + ? `https://${tenant || 'www'}.${apiDomain}` + : apiBaseUrl + if (withFeed) { + result += `/F/${feed}/api/v3` + } + 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( + Joi.object({ + versions: Joi.array() + .items( + Joi.object({ + version: Joi.string().required(), + }) + ) + .default([]), + totalDownloads: Joi.number().integer(), + totaldownloads: Joi.number().integer(), + }) + ) + .max(1) + .default([]), +}).required() + +/* + * Get information about a single package. + */ +async function fetch( + serviceInstance, + { baseUrl, packageName, includePrereleases = false } +) { + const json = await serviceInstance._requestJson({ + schema, + url: await searchQueryServiceUrl(baseUrl), + options: { + qs: { + q: `packageid:${encodeURIComponent(packageName.toLowerCase())}`, + // Include prerelease versions. + prerelease: 'true', + // Include packages with SemVer 2 version numbers. + semVerLevel: '2', + }, + }, + }) + + if (json.data.length === 1) { + return json.data[0] + } else { + throw new NotFound({ prettyMessage: 'package not found' }) + } +} + +/* + * Create a version and download service for a NuGet v2 API. Return an object + * containing both services. + * + * defaultLabel: The label for the left hand side of the badge. + * serviceBaseUrl: The base URL for the Shields service, e.g. nuget + * withTenant: When true, an optional `tenant` is extracted from the badge + * URL, which represents the subdomain of the API. When no tenant is + * provided, defaults to `www`. + * apiDomain: When `withTenant` is true, this is the rest of the domain, + * e.g. `myget.org`. + * apiBaseUrl: When `withTenant` is false, this is the base URL of the API, + * e.g. https://api.nuget.org/v3 + * withFeed: When true, the badge URL includes a required feed name, which is + * added to the request API. + */ +function createServiceFamily({ + defaultLabel, + serviceBaseUrl, + withTenant = true, + apiDomain, + apiBaseUrl, + withFeed = true, +}) { + class NugetVersionService extends BaseJsonService { + static get category() { + return 'version' + } + + static get route() { + return buildRoute({ serviceBaseUrl, withTenant, withFeed }) + .push('(v|vpre)', 'which') + .push('(.*)', 'packageName') + .toObject() + } + + static get examples() { + return [] + } + + static get defaultBadgeData() { + return { + label: defaultLabel, + } + } + + static render(props) { + return renderVersionBadge(props) + } + + async handle({ tenant, feed, which, packageName }) { + const baseUrl = apiUrl({ + withTenant, + apiBaseUrl, + apiDomain, + tenant, + withFeed, + feed, + }) + const { versions } = await fetch(this, { baseUrl, packageName }) + + let latest = versions.slice(-1).pop() + const includePrereleases = which === 'vpre' + if (!includePrereleases) { + const filtered = versions.filter(item => !item.version.includes('-')) + if (filtered.length) { + latest = filtered.slice(-1).pop() + } + } + + const { version } = latest + return this.constructor.render({ version, feed }) + } + } + + class NugetDownloadService extends BaseJsonService { + static get category() { + return 'downloads' + } + + static get route() { + return buildRoute({ serviceBaseUrl, withTenant, withFeed }) + .push('dt') + .push('(.*)', 'packageName') + .toObject() + } + + static get examples() { + return [] + } + + static render(props) { + return renderDownloadBadge(props) + } + + async handle({ tenant, feed, which, packageName }) { + const baseUrl = apiUrl({ + withTenant, + apiBaseUrl, + apiDomain, + tenant, + withFeed, + feed, + }) + const packageInfo = await fetch(this, { baseUrl, packageName }) + + // Official NuGet server uses "totalDownloads" whereas MyGet uses + // "totaldownloads" (lowercase D). Ugh. + const downloads = + packageInfo.totalDownloads || packageInfo.totaldownloads || 0 + + return this.constructor.render({ downloads }) + } + } + + return { + NugetVersionService, + NugetDownloadService, + } +} + +module.exports = { + createServiceFamily, +} diff --git a/services/nuget/nuget.service.js b/services/nuget/nuget.service.js index d6b4228a8e..2c9b332e98 100644 --- a/services/nuget/nuget.service.js +++ b/services/nuget/nuget.service.js @@ -1,364 +1,11 @@ 'use strict' -const LegacyService = require('../legacy-service') -const { - downloadCount: downloadCountColor, -} = require('../../lib/color-formatters') -const { makeBadgeData: getBadgeData } = require('../../lib/badge-data') -const { metric } = require('../../lib/text-formatters') -const { regularUpdate } = require('../../lib/regular-update') +const { createServiceFamily } = require('./nuget-v3-service-family') -function mapNugetFeedv2({ camp, cache }, pattern, offset, getInfo) { - const vRegex = new RegExp( - `^\\/${pattern}\\/v\\/(.*)\\.(svg|png|gif|jpg|json)$` - ) - const vPreRegex = new RegExp( - `^\\/${pattern}\\/vpre\\/(.*)\\.(svg|png|gif|jpg|json)$` - ) - const dtRegex = new RegExp( - `^\\/${pattern}\\/dt\\/(.*)\\.(svg|png|gif|jpg|json)$` - ) - - function getNugetPackage(apiUrl, id, includePre, request, done) { - const filter = includePre - ? `Id eq '${id}' and IsAbsoluteLatestVersion eq true` - : `Id eq '${id}' and IsLatestVersion eq true` - const reqUrl = `${apiUrl}/Packages()?$filter=${encodeURIComponent(filter)}` - request( - reqUrl, - { headers: { Accept: 'application/atom+json,application/json' } }, - (err, res, buffer) => { - if (err != null) { - done(new Error('inaccessible')) - return - } - - try { - const data = JSON.parse(buffer) - const result = data.d.results[0] - if (result == null) { - if (includePre === null) { - getNugetPackage(apiUrl, id, true, request, done) - } else { - done(new Error('not found')) - } - } else { - done(null, result) - } - } catch (e) { - done(new Error('invalid')) - } - } - ) - } - - camp.route( - vRegex, - cache((data, match, sendBadge, request) => { - const info = getInfo(match) - const site = info.site // eg, `Chocolatey`, or `YoloDev` - const repo = match[offset + 1] // eg, `Nuget.Core`. - const format = match[offset + 2] - const apiUrl = info.feed - const badgeData = getBadgeData(site, data) - getNugetPackage(apiUrl, repo, null, request, (err, data) => { - if (err != null) { - badgeData.text[1] = err.message - sendBadge(format, badgeData) - return - } - const version = data.NormalizedVersion || data.Version - badgeData.text[1] = `v${version}` - if (version.indexOf('-') !== -1) { - badgeData.colorscheme = 'yellow' - } else if (version[0] === '0') { - badgeData.colorscheme = 'orange' - } else { - badgeData.colorscheme = 'blue' - } - sendBadge(format, badgeData) - }) - }) - ) - - camp.route( - vPreRegex, - cache((data, match, sendBadge, request) => { - const info = getInfo(match) - const site = info.site // eg, `Chocolatey`, or `YoloDev` - const repo = match[offset + 1] // eg, `Nuget.Core`. - const format = match[offset + 2] - const apiUrl = info.feed - const badgeData = getBadgeData(site, data) - getNugetPackage(apiUrl, repo, true, request, (err, data) => { - if (err != null) { - badgeData.text[1] = err.message - sendBadge(format, badgeData) - return - } - const version = data.NormalizedVersion || data.Version - badgeData.text[1] = `v${version}` - if (version.indexOf('-') !== -1) { - badgeData.colorscheme = 'yellow' - } else if (version[0] === '0') { - badgeData.colorscheme = 'orange' - } else { - badgeData.colorscheme = 'blue' - } - sendBadge(format, badgeData) - }) - }) - ) - - camp.route( - dtRegex, - cache((data, match, sendBadge, request) => { - const info = getInfo(match) - const repo = match[offset + 1] // eg, `Nuget.Core`. - const format = match[offset + 2] - const apiUrl = info.feed - const badgeData = getBadgeData('downloads', data) - getNugetPackage(apiUrl, repo, null, request, (err, data) => { - if (err != null) { - badgeData.text[1] = err.message - sendBadge(format, badgeData) - return - } - const downloads = data.DownloadCount - badgeData.text[1] = metric(downloads) - badgeData.colorscheme = downloadCountColor(downloads) - sendBadge(format, badgeData) - }) - }) - ) -} - -function mapNugetFeed({ camp, cache }, pattern, offset, getInfo) { - const vRegex = new RegExp( - `^\\/${pattern}\\/v\\/(.*)\\.(svg|png|gif|jpg|json)$` - ) - const vPreRegex = new RegExp( - `^\\/${pattern}\\/vpre\\/(.*)\\.(svg|png|gif|jpg|json)$` - ) - const dtRegex = new RegExp( - `^\\/${pattern}\\/dt\\/(.*)\\.(svg|png|gif|jpg|json)$` - ) - - function getNugetData(apiUrl, id, request, done) { - // get service index document - - regularUpdate( - { - url: `${apiUrl}/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: false, - scraper: function(data) { - return data - }, - }, - (err, buf) => { - if (err != null) { - done(new Error('inaccessible')) - return - } - - try { - const searchQueryResources = JSON.parse(buf).resources.filter( - resource => resource['@type'] === 'SearchQueryService' - ) - // query autocomplete service - const randomEndpointIdx = Math.floor( - Math.random() * searchQueryResources.length - ) - const reqUrl = - `${searchQueryResources[randomEndpointIdx]['@id']}?q=packageid:${ - encodeURIComponent(id.toLowerCase()) // NuGet package id (lowercase) - }&prerelease=true` + `&semVerLevel=2` // Include prerelease versions? // Include packages with SemVer 2 version numbers - - request(reqUrl, (err, res, buffer) => { - if (err != null) { - done(new Error('inaccessible')) - return - } - - try { - const data = JSON.parse(buffer) - if (!Array.isArray(data.data) || data.data.length !== 1) { - done(new Error('not found')) - return - } - done(null, data.data[0]) - } catch (e) { - done(new Error('invalid')) - } - }) - } catch (e) { - done(new Error('invalid')) - } - } - ) - } - - function getNugetVersion(apiUrl, id, includePre, request, done) { - getNugetData(apiUrl, id, request, (err, data) => { - if (err) { - done(err) - return - } - let versions = data.versions || [] - if (!includePre) { - // Remove prerelease versions. - const filteredVersions = versions.filter( - version => !/-/.test(version.version) - ) - if (filteredVersions.length > 0) { - versions = filteredVersions - } - } - const lastVersion = versions[versions.length - 1] - done(null, lastVersion.version) - }) - } - - camp.route( - vRegex, - cache((data, match, sendBadge, request) => { - const info = getInfo(match) - const site = info.site // eg, `Chocolatey`, or `YoloDev` - const repo = match[offset + 1] // eg, `Nuget.Core`. - const format = match[offset + 2] - const apiUrl = info.feed - const badgeData = getBadgeData(site, data) - getNugetVersion(apiUrl, repo, false, request, (err, version) => { - if (err != null) { - badgeData.text[1] = err.message - sendBadge(format, badgeData) - return - } - try { - badgeData.text[1] = `v${version}` - if (version.indexOf('-') !== -1) { - badgeData.colorscheme = 'yellow' - } else if (version[0] === '0') { - badgeData.colorscheme = 'orange' - } else { - badgeData.colorscheme = 'blue' - } - sendBadge(format, badgeData) - } catch (e) { - badgeData.text[1] = 'invalid' - sendBadge(format, badgeData) - } - }) - }) - ) - - camp.route( - vPreRegex, - cache((data, match, sendBadge, request) => { - const info = getInfo(match) - const site = info.site // eg, `Chocolatey`, or `YoloDev` - const repo = match[offset + 1] // eg, `Nuget.Core`. - const format = match[offset + 2] - const apiUrl = info.feed - const badgeData = getBadgeData(site, data) - getNugetVersion(apiUrl, repo, true, request, (err, version) => { - if (err != null) { - badgeData.text[1] = err.message - sendBadge(format, badgeData) - return - } - try { - badgeData.text[1] = `v${version}` - if (version.indexOf('-') !== -1) { - badgeData.colorscheme = 'yellow' - } else if (version[0] === '0') { - badgeData.colorscheme = 'orange' - } else { - badgeData.colorscheme = 'blue' - } - sendBadge(format, badgeData) - } catch (e) { - badgeData.text[1] = 'invalid' - sendBadge(format, badgeData) - } - }) - }) - ) - - camp.route( - dtRegex, - cache((data, match, sendBadge, request) => { - const info = getInfo(match) - const repo = match[offset + 1] // eg, `Nuget.Core`. - const format = match[offset + 2] - const apiUrl = info.feed - const badgeData = getBadgeData('downloads', data) - getNugetData(apiUrl, repo, request, (err, nugetData) => { - if (err != null) { - badgeData.text[1] = err.message - sendBadge(format, badgeData) - return - } - try { - // Official NuGet server uses "totalDownloads" whereas MyGet uses - // "totaldownloads" (lowercase D). Ugh. - const downloads = - nugetData.totalDownloads || nugetData.totaldownloads || 0 - badgeData.text[1] = metric(downloads) - badgeData.colorscheme = downloadCountColor(downloads) - sendBadge(format, badgeData) - } catch (e) { - badgeData.text[1] = 'invalid' - sendBadge(format, badgeData) - } - }) - }) - ) -} - -module.exports = class Nuget extends LegacyService { - static registerLegacyRouteHandler({ camp, cache }) { - // ReSharper - mapNugetFeedv2({ camp, cache }, 'resharper', 0, match => ({ - site: 'resharper', - feed: 'https://resharper-plugins.jetbrains.com/api/v2', - })) - - // Chocolatey - mapNugetFeedv2({ camp, cache }, 'chocolatey', 0, match => ({ - site: 'chocolatey', - feed: 'https://www.chocolatey.org/api/v2', - })) - - // PowerShell Gallery - mapNugetFeedv2({ camp, cache }, 'powershellgallery', 0, match => ({ - site: 'powershellgallery', - feed: 'https://msconfiggallery.cloudapp.net/api/v2', - })) - - // NuGet - mapNugetFeed({ camp, cache }, 'nuget', 0, match => ({ - site: 'nuget', - feed: 'https://api.nuget.org/v3', - })) - - // MyGet - mapNugetFeed({ camp, cache }, '(.+\\.)?myget\\/(.*)', 2, match => { - const tenant = match[1] || 'www.' // eg. dotnet - const feed = match[2] - return { - site: feed, - feed: `https://${tenant}myget.org/F/${feed}/api/v3`, - } - }) - } -} +module.exports = createServiceFamily({ + defaultLabel: 'nuget', + serviceBaseUrl: 'nuget', + apiBaseUrl: 'https://api.nuget.org/v3', + withTenant: false, + withFeed: false, +}) diff --git a/services/nuget/nuget.tester.js b/services/nuget/nuget.tester.js index d86ad97ea8..414c9098c5 100644 --- a/services/nuget/nuget.tester.js +++ b/services/nuget/nuget.tester.js @@ -32,21 +32,7 @@ t.create('total downloads (valid)') t.create('total downloads (not found)') .get('/dt/not-a-real-package.json') - .expectJSON({ name: 'downloads', value: 'not found' }) - -t.create('total downloads (connection error)') - .get('/dt/Microsoft.AspNetCore.Mvc.json') - .networkOff() - .expectJSON({ name: 'downloads', value: 'inaccessible' }) - -t.create('total downloads (unexpected first response)') - .get('/dt/Microsoft.AspNetCore.Mvc.json') - .intercept(nock => - nock('https://api.nuget.org') - .get('/v3/index.json') - .reply(invalidJSON) - ) - .expectJSON({ name: 'downloads', value: 'invalid' }) + .expectJSON({ name: 'downloads', value: 'package not found' }) t.create('total downloads (unexpected second response)') .get('/dt/Microsoft.AspNetCore.Mvc.json') @@ -58,11 +44,11 @@ t.create('total downloads (unexpected second response)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:microsoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' ) .reply(invalidJSON) ) - .expectJSON({ name: 'downloads', value: 'invalid' }) + .expectJSON({ name: 'downloads', value: 'unparseable json response' }) // version @@ -85,7 +71,7 @@ t.create('version (mocked, yellow badge)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:microsoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' ) .reply(200, nuGetV3VersionJsonWithDash) ) @@ -105,7 +91,7 @@ t.create('version (mocked, orange badge)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:microsoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' ) .reply(200, nuGetV3VersionJsonFirstCharZero) ) @@ -125,7 +111,7 @@ t.create('version (mocked, blue badge)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:microsoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' ) .reply(200, nuGetV3VersionJsonFirstCharNotZero) ) @@ -137,21 +123,7 @@ t.create('version (mocked, blue badge)') t.create('version (not found)') .get('/v/not-a-real-package.json') - .expectJSON({ name: 'nuget', value: 'not found' }) - -t.create('version (connection error)') - .get('/v/Microsoft.AspNetCore.Mvc.json') - .networkOff() - .expectJSON({ name: 'nuget', value: 'inaccessible' }) - -t.create('version (unexpected first response)') - .get('/v/Microsoft.AspNetCore.Mvc.json') - .intercept(nock => - nock('https://api.nuget.org') - .get('/v3/index.json') - .reply(invalidJSON) - ) - .expectJSON({ name: 'nuget', value: 'invalid' }) + .expectJSON({ name: 'nuget', value: 'package not found' }) t.create('version (unexpected second response)') .get('/v/Microsoft.AspNetCore.Mvc.json') @@ -163,11 +135,11 @@ t.create('version (unexpected second response)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:microsoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' ) .reply(invalidJSON) ) - .expectJSON({ name: 'nuget', value: 'invalid' }) + .expectJSON({ name: 'nuget', value: 'unparseable json response' }) // version (pre) @@ -190,7 +162,7 @@ t.create('version (pre) (mocked, yellow badge)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:microsoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' ) .reply(200, nuGetV3VersionJsonWithDash) ) @@ -210,7 +182,7 @@ t.create('version (pre) (mocked, orange badge)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:microsoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' ) .reply(200, nuGetV3VersionJsonFirstCharZero) ) @@ -230,7 +202,7 @@ t.create('version (pre) (mocked, blue badge)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:microsoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' ) .reply(200, nuGetV3VersionJsonFirstCharNotZero) ) @@ -242,21 +214,7 @@ t.create('version (pre) (mocked, blue badge)') t.create('version (pre) (not found)') .get('/vpre/not-a-real-package.json') - .expectJSON({ name: 'nuget', value: 'not found' }) - -t.create('version (pre) (connection error)') - .get('/vpre/Microsoft.AspNetCore.Mvc.json') - .networkOff() - .expectJSON({ name: 'nuget', value: 'inaccessible' }) - -t.create('version (pre) (unexpected first response)') - .get('/vpre/Microsoft.AspNetCore.Mvc.json') - .intercept(nock => - nock('https://api.nuget.org') - .get('/v3/index.json') - .reply(invalidJSON) - ) - .expectJSON({ name: 'nuget', value: 'invalid' }) + .expectJSON({ name: 'nuget', value: 'package not found' }) t.create('version (pre) (unexpected second response)') .get('/vpre/Microsoft.AspNetCore.Mvc.json') @@ -268,8 +226,8 @@ t.create('version (pre) (unexpected second response)') .intercept(nock => nock('https://api-v2v3search-0.nuget.org') .get( - '/query?q=packageid:microsoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' + '/query?q=packageid%3Amicrosoft.aspnetcore.mvc&prerelease=true&semVerLevel=2' ) .reply(invalidJSON) ) - .expectJSON({ name: 'nuget', value: 'invalid' }) + .expectJSON({ name: 'nuget', value: 'unparseable json response' }) diff --git a/services/powershellgallery/powershellgallery.service.js b/services/powershellgallery/powershellgallery.service.js new file mode 100644 index 0000000000..a5c5907ee8 --- /dev/null +++ b/services/powershellgallery/powershellgallery.service.js @@ -0,0 +1,119 @@ +'use strict' + +const Joi = require('joi') + +const BaseXmlService = require('../base-xml') +const { NotFound } = require('../errors') +const { nonNegativeInteger } = require('../validators') +const { createFilter } = require('../nuget/nuget-v2-service-family') +const { + renderVersionBadge, + renderDownloadBadge, +} = require('../nuget/nuget-helpers') + +const schema = Joi.object({ + feed: Joi.object({ + entry: Joi.object({ + 'm:properties': Joi.object({ + 'd:Version': Joi.string(), + 'd:NormalizedVersion': Joi.string(), + 'd:DownloadCount': nonNegativeInteger, + }), + }), + }).required(), +}).required() + +async function fetch( + serviceInstance, + { packageName, includePrereleases = false } +) { + const data = await serviceInstance._requestXml({ + schema, + url: `https://www.powershellgallery.com/api/v2/Search()`, + options: { + qs: { $filter: createFilter({ packageName, includePrereleases }) }, + }, + }) + + const packageData = + 'entry' in data.feed ? data.feed.entry['m:properties'] : undefined + + if (packageData) { + return packageData + } else if (!includePrereleases) { + return fetch(serviceInstance, { + packageName, + includePrereleases: true, + }) + } else { + throw new NotFound() + } +} + +class PowershellGalleryVersion extends BaseXmlService { + static get category() { + return 'version' + } + + static get route() { + return { + base: 'powershellgallery', + pattern: ':which(v|vpre)/:packageName', + } + } + + static get examples() { + return [] + } + + static get defaultBadgeData() { + return { + label: 'powershell gallery', + } + } + + static render(props) { + return renderVersionBadge(props) + } + + async handle({ which, packageName }) { + const packageData = await fetch(this, { + packageName, + includePrereleases: which === 'vpre', + }) + const version = + packageData['d:NormalizedVersion'] || packageData['d:Version'] + return this.constructor.render({ version }) + } +} + +class PowershellGalleryDownloads extends BaseXmlService { + static get category() { + return 'downloads' + } + + static get route() { + return { + base: 'powershellgallery', + pattern: 'dt/:packageName', + } + } + + static get examples() { + return [] + } + + static render(props) { + return renderDownloadBadge(props) + } + + async handle({ packageName }) { + const packageData = await fetch(this, { + packageName, + }) + const { 'd:DownloadCount': downloads } = packageData + return this.constructor.render({ downloads }) + } +} + +module.exports = { PowershellGalleryVersion, PowershellGalleryDownloads } diff --git a/services/powershellgallery/powershellgallery.tester.js b/services/powershellgallery/powershellgallery.tester.js index 2c25ffcb7f..37c6050e05 100644 --- a/services/powershellgallery/powershellgallery.tester.js +++ b/services/powershellgallery/powershellgallery.tester.js @@ -7,13 +7,6 @@ const { isVPlusDottedVersionNClauses, isVPlusDottedVersionNClausesWithOptionalSuffix, } = require('../test-validators') -const colorscheme = require('../../lib/colorscheme.json') -const { - nuGetV2VersionJsonWithDash, - nuGetV2VersionJsonFirstCharZero, - nuGetV2VersionJsonFirstCharNotZero, -} = require('../nuget-fixtures') -const { invalidJSON } = require('../response-fixtures') const t = new ServiceTester({ id: 'powershellgallery', @@ -21,8 +14,6 @@ const t = new ServiceTester({ }) module.exports = t -// downloads - t.create('total downloads (valid)') .get('/dt/ACMESharp.json') .expectJSONTypes( @@ -36,170 +27,28 @@ t.create('total downloads (not found)') .get('/dt/not-a-real-package.json') .expectJSON({ name: 'downloads', value: 'not found' }) -t.create('total downloads (connection error)') - .get('/dt/ACMESharp.json') - .networkOff() - .expectJSON({ name: 'downloads', value: 'inaccessible' }) - -t.create('total downloads (unexpected response)') - .get('/dt/ACMESharp.json') - .intercept(nock => - nock('https://msconfiggallery.cloudapp.net') - .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ACMESharp%27%20and%20IsLatestVersion%20eq%20true' - ) - .reply(invalidJSON) - ) - .expectJSON({ name: 'downloads', value: 'invalid' }) - -// version - t.create('version (valid)') .get('/v/ACMESharp.json') .expectJSONTypes( Joi.object().keys({ - name: 'powershellgallery', + name: 'powershell gallery', value: isVPlusDottedVersionNClauses, }) ) -t.create('version (mocked, yellow badge)') - .get('/v/ACMESharp.json?style=_shields_test') - .intercept(nock => - nock('https://msconfiggallery.cloudapp.net') - .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ACMESharp%27%20and%20IsLatestVersion%20eq%20true' - ) - .reply(200, nuGetV2VersionJsonWithDash) - ) - .expectJSON({ - name: 'powershellgallery', - value: 'v1.2-beta', - colorB: colorscheme.yellow.colorB, - }) - -t.create('version (mocked, orange badge)') - .get('/v/ACMESharp.json?style=_shields_test') - .intercept(nock => - nock('https://msconfiggallery.cloudapp.net') - .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ACMESharp%27%20and%20IsLatestVersion%20eq%20true' - ) - .reply(200, nuGetV2VersionJsonFirstCharZero) - ) - .expectJSON({ - name: 'powershellgallery', - value: 'v0.35', - colorB: colorscheme.orange.colorB, - }) - -t.create('version (mocked, blue badge)') - .get('/v/ACMESharp.json?style=_shields_test') - .intercept(nock => - nock('https://msconfiggallery.cloudapp.net') - .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ACMESharp%27%20and%20IsLatestVersion%20eq%20true' - ) - .reply(200, nuGetV2VersionJsonFirstCharNotZero) - ) - .expectJSON({ - name: 'powershellgallery', - value: 'v1.2.7', - colorB: colorscheme.blue.colorB, - }) - t.create('version (not found)') .get('/v/not-a-real-package.json') - .expectJSON({ name: 'powershellgallery', value: 'not found' }) - -t.create('version (connection error)') - .get('/v/ACMESharp.json') - .networkOff() - .expectJSON({ name: 'powershellgallery', value: 'inaccessible' }) - -t.create('version (unexpected response)') - .get('/v/ACMESharp.json') - .intercept(nock => - nock('https://msconfiggallery.cloudapp.net') - .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ACMESharp%27%20and%20IsLatestVersion%20eq%20true' - ) - .reply(invalidJSON) - ) - .expectJSON({ name: 'powershellgallery', value: 'invalid' }) - -// version (pre) + .expectJSON({ name: 'powershell gallery', value: 'not found' }) t.create('version (pre) (valid)') .get('/vpre/ACMESharp.json') .expectJSONTypes( Joi.object().keys({ - name: 'powershellgallery', + name: 'powershell gallery', value: isVPlusDottedVersionNClausesWithOptionalSuffix, }) ) -t.create('version (pre) (mocked, yellow badge)') - .get('/vpre/ACMESharp.json?style=_shields_test') - .intercept(nock => - nock('https://msconfiggallery.cloudapp.net') - .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ACMESharp%27%20and%20IsAbsoluteLatestVersion%20eq%20true' - ) - .reply(200, nuGetV2VersionJsonWithDash) - ) - .expectJSON({ - name: 'powershellgallery', - value: 'v1.2-beta', - colorB: colorscheme.yellow.colorB, - }) - -t.create('version (pre) (mocked, orange badge)') - .get('/vpre/ACMESharp.json?style=_shields_test') - .intercept(nock => - nock('https://msconfiggallery.cloudapp.net') - .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ACMESharp%27%20and%20IsAbsoluteLatestVersion%20eq%20true' - ) - .reply(200, nuGetV2VersionJsonFirstCharZero) - ) - .expectJSON({ - name: 'powershellgallery', - value: 'v0.35', - colorB: colorscheme.orange.colorB, - }) - -t.create('version (pre) (mocked, blue badge)') - .get('/vpre/ACMESharp.json?style=_shields_test') - .intercept(nock => - nock('https://msconfiggallery.cloudapp.net') - .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ACMESharp%27%20and%20IsAbsoluteLatestVersion%20eq%20true' - ) - .reply(200, nuGetV2VersionJsonFirstCharNotZero) - ) - .expectJSON({ - name: 'powershellgallery', - value: 'v1.2.7', - colorB: colorscheme.blue.colorB, - }) - t.create('version (pre) (not found)') .get('/vpre/not-a-real-package.json') - .expectJSON({ name: 'powershellgallery', value: 'not found' }) - -t.create('version (pre) (connection error)') - .get('/vpre/ACMESharp.json') - .networkOff() - .expectJSON({ name: 'powershellgallery', value: 'inaccessible' }) - -t.create('version (pre) (unexpected response)') - .get('/vpre/ACMESharp.json') - .intercept(nock => - nock('https://msconfiggallery.cloudapp.net') - .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ACMESharp%27%20and%20IsAbsoluteLatestVersion%20eq%20true' - ) - .reply(invalidJSON) - ) - .expectJSON({ name: 'powershellgallery', value: 'invalid' }) + .expectJSON({ name: 'powershell gallery', value: 'not found' }) diff --git a/services/resharper/resharper.service.js b/services/resharper/resharper.service.js new file mode 100644 index 0000000000..43a870c5a7 --- /dev/null +++ b/services/resharper/resharper.service.js @@ -0,0 +1,9 @@ +'use strict' + +const { createServiceFamily } = require('../nuget/nuget-v2-service-family') + +module.exports = createServiceFamily({ + defaultLabel: 'resharper', + serviceBaseUrl: 'resharper', + apiBaseUrl: 'https://resharper-plugins.jetbrains.com/api/v2', +}) diff --git a/services/resharper/resharper.tester.js b/services/resharper/resharper.tester.js index 707e630b97..b4223a4cbe 100644 --- a/services/resharper/resharper.tester.js +++ b/services/resharper/resharper.tester.js @@ -43,11 +43,11 @@ t.create('total downloads (unexpected response)') .intercept(nock => nock('https://resharper-plugins.jetbrains.com') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsLatestVersion%20eq%20true' ) .reply(invalidJSON) ) - .expectJSON({ name: 'downloads', value: 'invalid' }) + .expectJSON({ name: 'downloads', value: 'unparseable json response' }) // version @@ -65,7 +65,7 @@ t.create('version (mocked, yellow badge)') .intercept(nock => nock('https://resharper-plugins.jetbrains.com') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsLatestVersion%20eq%20true' ) .reply(200, nuGetV2VersionJsonWithDash) ) @@ -80,7 +80,7 @@ t.create('version (mocked, orange badge)') .intercept(nock => nock('https://resharper-plugins.jetbrains.com') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsLatestVersion%20eq%20true' ) .reply(200, nuGetV2VersionJsonFirstCharZero) ) @@ -95,7 +95,7 @@ t.create('version (mocked, blue badge)') .intercept(nock => nock('https://resharper-plugins.jetbrains.com') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsLatestVersion%20eq%20true' ) .reply(200, nuGetV2VersionJsonFirstCharNotZero) ) @@ -119,11 +119,11 @@ t.create('version (unexpected response)') .intercept(nock => nock('https://resharper-plugins.jetbrains.com') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsLatestVersion%20eq%20true' ) .reply(invalidJSON) ) - .expectJSON({ name: 'resharper', value: 'invalid' }) + .expectJSON({ name: 'resharper', value: 'unparseable json response' }) // version (pre) @@ -141,7 +141,7 @@ t.create('version (pre) (mocked, yellow badge)') .intercept(nock => nock('https://resharper-plugins.jetbrains.com') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsAbsoluteLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsAbsoluteLatestVersion%20eq%20true' ) .reply(200, nuGetV2VersionJsonWithDash) ) @@ -156,7 +156,7 @@ t.create('version (pre) (mocked, orange badge)') .intercept(nock => nock('https://resharper-plugins.jetbrains.com') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsAbsoluteLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsAbsoluteLatestVersion%20eq%20true' ) .reply(200, nuGetV2VersionJsonFirstCharZero) ) @@ -171,7 +171,7 @@ t.create('version (pre) (mocked, blue badge)') .intercept(nock => nock('https://resharper-plugins.jetbrains.com') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsAbsoluteLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsAbsoluteLatestVersion%20eq%20true' ) .reply(200, nuGetV2VersionJsonFirstCharNotZero) ) @@ -195,8 +195,8 @@ t.create('version (pre) (unexpected response)') .intercept(nock => nock('https://resharper-plugins.jetbrains.com') .get( - '/api/v2/Packages()?$filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsAbsoluteLatestVersion%20eq%20true' + '/api/v2/Packages()?%24filter=Id%20eq%20%27ReSharper.Nuke%27%20and%20IsAbsoluteLatestVersion%20eq%20true' ) .reply(invalidJSON) ) - .expectJSON({ name: 'resharper', value: 'invalid' }) + .expectJSON({ name: 'resharper', value: 'unparseable json response' }) diff --git a/services/route-builder.js b/services/route-builder.js new file mode 100644 index 0000000000..a2718ec676 --- /dev/null +++ b/services/route-builder.js @@ -0,0 +1,36 @@ +'use strict' + +const { toArray } = require('../lib/badge-data') + +/* + * Factory class for building a BaseService `route` object. This class is useful + * in complex collections of service classes, when the URL is built + * conditionally. + * + * Patterns based on path-to-regex may obviate the need for this, though they + * haven't done so yet. + */ +module.exports = class RouteBuilder { + constructor({ base = '' } = {}) { + this.base = base + + this._formatComponents = [] + this.capture = [] + } + + get format() { + return this._formatComponents.join('/') + } + + push(format, capture) { + this._formatComponents = this._formatComponents.concat(toArray(format)) + this.capture = this.capture.concat(toArray(capture)) + // Return `this` for chaining. + return this + } + + toObject() { + const { base, format, capture } = this + return { base, format, capture } + } +}