Rewrite [NuGet] badges including [myget chocolatey resharper powershellgallery] (#2257)

The NuGet badge examples are straggling in all-badge-examples. Rather than move them as is, I thought it made more sense to refactor the services and see if they could be generated. I didn't take that on here; this is a straight rewrite of the badges. The old implementations were fairly difficult to follow. The new implementations are complicated too, though I hope much more readable.

Though the NuGet behaviors could be consolidated into a single flag, I split `withTenant` and `withFeed` into separate flags, thinking naming the behaviors makes the implementations easier to understand. I defaulted these to true, thinking that really this is really a MyGet implementation which is generalized to NuGet. Though maybe it makes more sense to have the MyGet style as the default. Probably it doesn't matter much either way.

I added a helper class ServiceUrlBuilder to construct the Shields service URL. It's useful in this complex case where the URL must be built up conditionally. This might be useful in a couple other places.

I also wrote a new service to handle the Powershell badges. They've diverged a little bit from the Nuget v2. There's a bit of shared code which I factored out.

If the XML Nuget APIs are more reliable, we could consider switching everything else over to them, though for now I would like to get this merged and get #2078 fixed.

Fix #2078
This commit is contained in:
Paul Melnikow
2018-11-14 17:28:15 -05:00
committed by GitHub
parent 510491f376
commit 5e99aad2de
16 changed files with 751 additions and 667 deletions

View File

@@ -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 {

View File

@@ -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',
})

View File

@@ -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' })

View File

@@ -0,0 +1,9 @@
'use strict'
const { createServiceFamily } = require('../nuget/nuget-v3-service-family')
module.exports = createServiceFamily({
defaultLabel: 'myget',
serviceBaseUrl: 'myget',
apiDomain: 'myget.org',
})

View File

@@ -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' })

View File

@@ -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' }],
},
],

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
})

View File

@@ -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' })

View File

@@ -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 }

View File

@@ -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' })

View File

@@ -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',
})

View File

@@ -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' })

36
services/route-builder.js Normal file
View File

@@ -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 }
}
}