Add support for NuGet badges from [nuget feedz] hosting (#5753)

* Add support for Feedz NuGet feeds

* Fix tests & error messages that are used

* Cleanup service and change route according to the conventions

* Change route nomenclature

* Extract `searchServiceUrl`, `stripBuildMetadata` and `selectVersion` to NuGet helpers

* Fix Feedz examples

* Fixup the pattern in Feedz examples

Missed save...

* Use MongoDB.Driver.Core in Feedz examples

* Use standard route pattern instead of `RouteBuilder`

* Extract `transform` function in feedz service

* Add simple test for the `apiUrl` function

* Distinguish repository/package errors

Co-authored-by: repo-ranger[bot] <39074581+repo-ranger[bot]@users.noreply.github.com>
This commit is contained in:
Jakub Fijałkowski
2020-11-03 01:52:13 +01:00
committed by GitHub
parent 05850d1a1b
commit 878f4fbcbc
6 changed files with 366 additions and 63 deletions

View File

@@ -0,0 +1,111 @@
'use strict'
const Joi = require('joi')
const { BaseJsonService, NotFound } = require('..')
const {
renderVersionBadge,
searchServiceUrl,
stripBuildMetadata,
selectVersion,
} = require('../nuget/nuget-helpers')
const schema = Joi.object({
items: Joi.array()
.items(
Joi.object({
items: Joi.array().items(
Joi.object({
catalogEntry: Joi.object({
version: Joi.string().required(),
}).required(),
})
),
}).required()
)
.max(1)
.default([]),
}).required()
class FeedzVersionService extends BaseJsonService {
static category = 'version'
static route = {
base: 'feedz',
pattern: ':which(v|vpre)/:organization/:repository/:packageName',
}
static examples = [
{
title: 'Feedz',
pattern: 'v/:organization/:repository/:packageName',
namedParams: {
organization: 'shieldstests',
repository: 'mongodb',
packageName: 'MongoDB.Driver.Core',
},
staticPreview: this.render({ version: '2.10.4' }),
},
{
title: 'Feedz (with prereleases)',
pattern: 'vpre/:organization/:repository/:packageName',
namedParams: {
organization: 'shieldstests',
repository: 'mongodb',
packageName: 'MongoDB.Driver.Core',
},
staticPreview: this.render({ version: '2.11.0-beta2' }),
},
]
static defaultBadgeData = {
label: 'feedz',
}
static render(props) {
return renderVersionBadge(props)
}
apiUrl({ organization, repository }) {
return `https://f.feedz.io/${organization}/${repository}/nuget`
}
async fetch({ baseUrl, packageName }) {
const registrationsBaseUrl = await searchServiceUrl(
baseUrl,
'RegistrationsBaseUrl'
)
return await this._requestJson({
schema,
url: `${registrationsBaseUrl}${packageName}/index.json`,
errorMessages: {
404: 'repository or package not found',
},
})
}
transform({ json, includePrereleases }) {
if (json.items.length === 1 && json.items[0].items.length > 0) {
const versions = json.items[0].items.map(i =>
stripBuildMetadata(i.catalogEntry.version)
)
return selectVersion(versions, includePrereleases)
} else {
throw new NotFound({ prettyMessage: 'package not found' })
}
}
async handle({ which, organization, repository, packageName }) {
const includePrereleases = which === 'vpre'
const baseUrl = this.apiUrl({ organization, repository })
const json = await this.fetch({ baseUrl, packageName })
const version = this.transform({ json, includePrereleases })
return this.constructor.render({
version,
feed: FeedzVersionService.defaultBadgeData.label,
})
}
}
module.exports = {
FeedzVersionService,
}

View File

@@ -0,0 +1,69 @@
'use strict'
const { test, given } = require('sazerac')
const { FeedzVersionService } = require('./feedz.service')
function json(versions) {
return {
items: [
{
items: versions.map(v => ({
catalogEntry: {
version: v,
},
})),
},
],
}
}
function noItemsJson() {
return {
items: [],
}
}
describe('Feedz service', function () {
test(FeedzVersionService.prototype.apiUrl, () => {
given({ organization: 'shieldstests', repository: 'public' }).expect(
'https://f.feedz.io/shieldstests/public/nuget'
)
})
test(FeedzVersionService.prototype.transform, () => {
given({ json: json(['1.0.0']), includePrereleases: false }).expect('1.0.0')
given({ json: json(['1.0.0', '1.0.1']), includePrereleases: false }).expect(
'1.0.1'
)
given({
json: json(['1.0.0', '1.0.1-beta1']),
includePrereleases: false,
}).expect('1.0.0')
given({
json: json(['1.0.0', '1.0.1-beta1']),
includePrereleases: true,
}).expect('1.0.1-beta1')
given({
json: json(['1.0.0+1', '1.0.1-beta1+1']),
includePrereleases: false,
}).expect('1.0.0')
given({
json: json(['1.0.0+1', '1.0.1-beta1+1']),
includePrereleases: true,
}).expect('1.0.1-beta1')
given({ json: json([]), includePrereleases: false }).expectError(
'Not Found: package not found'
)
given({ json: json([]), includePrereleases: true }).expectError(
'Not Found: package not found'
)
given({ json: noItemsJson(), includePrereleases: false }).expectError(
'Not Found: package not found'
)
given({ json: noItemsJson(), includePrereleases: true }).expectError(
'Not Found: package not found'
)
})
})

View File

@@ -0,0 +1,89 @@
'use strict'
const { ServiceTester } = require('../tester')
const t = (module.exports = new ServiceTester({
id: 'feedz',
title: 'Feedz',
pathPrefix: '',
}))
// The `shieldstests/public` repo is specifically made for these tests. It contains following packages:
// - Shields.NoV1: 0.1.0
// - Shields.TestPackage: 0.0.1, 0.1.0-pre, 1.0.0
// - Shields.TestPreOnly: 0.1.0-pre
// The source code of these packages is here: https://github.com/jakubfijalkowski/shields-test-packages
// version
t.create('version (valid)')
.get('/feedz/v/shieldstests/public/Shields.TestPackage.json')
.expectBadge({
label: 'feedz',
message: 'v1.0.0',
color: 'blue',
})
t.create('version (yellow badge)')
.get('/feedz/v/shieldstests/public/Shields.TestPreOnly.json')
.expectBadge({
label: 'feedz',
message: 'v0.1.0-pre',
color: 'yellow',
})
t.create('version (orange badge)')
.get('/feedz/v/shieldstests/public/Shields.NoV1.json')
.expectBadge({
label: 'feedz',
message: 'v0.1.0',
color: 'orange',
})
t.create('repository (not found)')
.get('/feedz/v/foo/bar/not-a-real-package.json')
.expectBadge({ label: 'feedz', message: 'repository or package not found' })
t.create('version (not found)')
.get('/feedz/v/shieldstests/public/not-a-real-package.json')
.expectBadge({ label: 'feedz', message: 'repository or package not found' })
t.create('non-existing repository')
.get('/feedz/v/shieldstests/does-not-exist/Shields.TestPackage.json')
.expectBadge({ label: 'feedz', message: 'repository or package not found' })
// version (pre)
t.create('version (pre) (valid)')
.get('/feedz/vpre/shieldstests/public/Shields.TestPackage.json')
.expectBadge({
label: 'feedz',
message: 'v1.0.0',
color: 'blue',
})
t.create('version (pre) (yellow badge)')
.get('/feedz/vpre/shieldstests/public/Shields.TestPreOnly.json')
.expectBadge({
label: 'feedz',
message: 'v0.1.0-pre',
color: 'yellow',
})
t.create('version (pre) (orange badge)')
.get('/feedz/vpre/shieldstests/public/Shields.NoV1.json')
.expectBadge({
label: 'feedz',
message: 'v0.1.0',
color: 'orange',
})
t.create('repository (pre) (not found)')
.get('/feedz/vpre/foo/bar/not-a-real-package.json')
.expectBadge({ label: 'feedz', message: 'repository or package not found' })
t.create('version (pre) (not found)')
.get('/feedz/vpre/shieldstests/public/not-a-real-package.json')
.expectBadge({ label: 'feedz', message: 'repository or package not found' })
t.create('non-existing repository')
.get('/feedz/vpre/shieldstests/does-not-exist/Shields.TestPackage.json')
.expectBadge({ label: 'feedz', message: 'repository or package not found' })

View File

@@ -1,7 +1,10 @@
'use strict'
const { promisify } = require('util')
const semver = require('semver')
const { metric, addv } = require('../text-formatters')
const { downloadCount: downloadCountColor } = require('../color-formatters')
const { regularUpdate } = require('../../core/legacy/regular-update')
function renderVersionBadge({ version, feed }) {
let color
@@ -40,8 +43,71 @@ function odataToObject(odata) {
return result
}
function randomElementFrom(items) {
const index = Math.floor(Math.random() * items.length)
return items[index]
}
/*
* Hit the service index endpoint and return a {serviceType} URL, chosen
* at random. Cache the responses, but return a different random URL each time.
*/
async function searchServiceUrl(baseUrl, serviceType = 'SearchQueryService') {
// Should we really be caching all these NuGet feeds in memory?
const searchQueryServices = await promisify(regularUpdate)({
url: `${baseUrl}/index.json`,
// The endpoint changes once per year (ie, a period of n = 1 year).
// We minimize the users' waiting time for information.
// With l = latency to fetch the endpoint and x = endpoint update period
// both in years, the yearly number of queries for the endpoint are 1/x,
// and when the endpoint changes, we wait for up to x years to get the
// right endpoint.
// So the waiting time within n years is n*l/x + x years, for which a
// derivation yields an optimum at x = sqrt(n*l), roughly 42 minutes.
intervalMillis: 42 * 60 * 1000,
json: true,
scraper: json =>
json.resources.filter(resource => resource['@type'] === serviceType),
})
return randomElementFrom(searchQueryServices)['@id']
}
/*
* Strip Build MetaData
* Nuget versions may include an optional "build metadata" clause,
* separated from the version by a + character.
*/
function stripBuildMetadata(version) {
return version.split('+')[0]
}
/*
* Select latest version from NuGet feed, filtering-out prerelease versions if needed
*/
function selectVersion(versions, includePrereleases) {
if (includePrereleases) {
return versions.slice(-1).pop()
} else {
const filtered = versions.filter(i => {
if (semver.valid(i)) {
return !semver.prerelease(i)
} else {
return !i.includes('-')
}
})
if (filtered.length > 0) {
return filtered.slice(-1).pop()
} else {
return versions.slice(-1).pop()
}
}
}
module.exports = {
renderVersionBadge,
renderDownloadBadge,
odataToObject,
searchServiceUrl,
stripBuildMetadata,
selectVersion,
}

View File

@@ -1,7 +1,12 @@
'use strict'
const { test, given } = require('sazerac')
const { renderVersionBadge, odataToObject } = require('./nuget-helpers')
const {
renderVersionBadge,
odataToObject,
stripBuildMetadata,
selectVersion,
} = require('./nuget-helpers')
describe('NuGet helpers', function () {
test(renderVersionBadge, () => {
@@ -28,4 +33,18 @@ describe('NuGet helpers', function () {
})
given(undefined).expect(undefined)
})
test(stripBuildMetadata, () => {
given('1.0.0').expect('1.0.0')
given('1.0.0+1').expect('1.0.0')
})
test(selectVersion, () => {
given(['1.0.0', '1.0.1'], false).expect('1.0.1')
given(['1.0.0', '1.0.1'], true).expect('1.0.1')
given(['1.0.0', '1.0.1-pre'], false).expect('1.0.0')
given(['1.0.0', '1.0.1-pre'], true).expect('1.0.1-pre')
given(['1.0.0', '1.0.1.0.1.0-pre'], false).expect('1.0.0')
given(['1.0.0', '1.0.1.0.1.0-pre'], true).expect('1.0.1.0.1.0-pre')
})
})

View File

@@ -1,12 +1,15 @@
'use strict'
const { promisify } = require('util')
const Joi = require('joi')
const semver = require('semver')
const { regularUpdate } = require('../../core/legacy/regular-update')
const RouteBuilder = require('../route-builder')
const { BaseJsonService, NotFound } = require('..')
const { renderVersionBadge, renderDownloadBadge } = require('./nuget-helpers')
const {
renderVersionBadge,
renderDownloadBadge,
searchServiceUrl,
stripBuildMetadata,
selectVersion,
} = require('./nuget-helpers')
/*
* Build the Shields service URL object for the given service configuration. Return
@@ -47,37 +50,6 @@ function apiUrl({ withTenant, apiBaseUrl, apiDomain, tenant, withFeed, feed }) {
return result
}
function randomElementFrom(items) {
const index = Math.floor(Math.random() * items.length)
return items[index]
}
/*
* Hit the service index endpoint and return a SearchQueryService URL, chosen
* at random. Cache the responses, but return a different random URL each time.
*/
async function searchQueryServiceUrl(baseUrl) {
// Should we really be caching all these NuGet feeds in memory?
const searchQueryServices = await promisify(regularUpdate)({
url: `${baseUrl}/index.json`,
// The endpoint changes once per year (ie, a period of n = 1 year).
// We minimize the users' waiting time for information.
// With l = latency to fetch the endpoint and x = endpoint update period
// both in years, the yearly number of queries for the endpoint are 1/x,
// and when the endpoint changes, we wait for up to x years to get the
// right endpoint.
// So the waiting time within n years is n*l/x + x years, for which a
// derivation yields an optimum at x = sqrt(n*l), roughly 42 minutes.
intervalMillis: 42 * 60 * 1000,
json: true,
scraper: json =>
json.resources.filter(
resource => resource['@type'] === 'SearchQueryService'
),
})
return randomElementFrom(searchQueryServices)['@id']
}
const schema = Joi.object({
data: Joi.array()
.items(
@@ -97,15 +69,6 @@ const schema = Joi.object({
.default([]),
}).required()
/*
* Strip Build MetaData
* Nuget versions may include an optional "build metadata" clause,
* separated from the version by a + character.
*/
function stripBuildMetadata(version) {
return version.split('+')[0]
}
/*
* Get information about a single package.
*/
@@ -115,7 +78,7 @@ async function fetch(
) {
const json = await serviceInstance._requestJson({
schema,
url: await searchQueryServiceUrl(baseUrl),
url: await searchServiceUrl(baseUrl, 'SearchQueryService'),
options: {
qs: {
q: `packageid:${encodeURIComponent(packageName.toLowerCase())}`,
@@ -177,6 +140,7 @@ function createServiceFamily({
}
async handle({ tenant, feed, which, packageName }) {
const includePrereleases = which === 'vpre'
const baseUrl = apiUrl({
withTenant,
apiBaseUrl,
@@ -186,23 +150,8 @@ function createServiceFamily({
feed,
})
let { versions } = await fetch(this, { baseUrl, packageName })
versions = versions.map(item => ({
version: stripBuildMetadata(item.version),
}))
let latest = versions.slice(-1).pop()
const includePrereleases = which === 'vpre'
if (!includePrereleases) {
const filtered = versions.filter(item => {
if (semver.valid(item.version)) {
return !semver.prerelease(item.version)
}
return !item.version.includes('-')
})
if (filtered.length) {
latest = filtered.slice(-1).pop()
}
}
const { version } = latest
versions = versions.map(item => stripBuildMetadata(item.version))
const version = selectVersion(versions, includePrereleases)
return this.constructor.render({ version, feed })
}
}