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:
committed by
GitHub
parent
05850d1a1b
commit
878f4fbcbc
111
services/feedz/feedz.service.js
Normal file
111
services/feedz/feedz.service.js
Normal 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,
|
||||
}
|
||||
69
services/feedz/feedz.service.spec.js
Normal file
69
services/feedz/feedz.service.spec.js
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
89
services/feedz/feedz.tester.js
Normal file
89
services/feedz/feedz.tester.js
Normal 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' })
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user