diff --git a/services/nexus/nexus.service.js b/services/nexus/nexus.service.js index 1d2dcd76250d5..ea8c6e5c49268 100644 --- a/services/nexus/nexus.service.js +++ b/services/nexus/nexus.service.js @@ -1,18 +1,44 @@ 'use strict' -const LegacyService = require('../legacy-service') -const { makeBadgeData: getBadgeData } = require('../../lib/badge-data') -const { isSnapshotVersion: isNexusSnapshotVersion } = require('./nexus-version') -const { addv: versionText } = require('../../lib/text-formatters') +const Joi = require('joi') + +const BaseJsonService = require('../base-json') +const { InvalidResponse, NotFound } = require('../errors') +const { isSnapshotVersion } = require('./nexus-version') const { version: versionColor } = require('../../lib/color-formatters') +const { addv } = require('../../lib/text-formatters') +const serverSecrets = require('../../lib/server-secrets') +const { + optionalDottedVersionNClausesWithOptionalSuffix, +} = require('../validators') + +const searchApiSchema = Joi.object({ + data: Joi.array() + .items( + Joi.object({ + latestRelease: optionalDottedVersionNClausesWithOptionalSuffix, + latestSnapshot: optionalDottedVersionNClausesWithOptionalSuffix, + version: optionalDottedVersionNClausesWithOptionalSuffix, + }) + ) + .required(), +}).required() + +const resolveApiSchema = Joi.object({ + data: Joi.object({ + baseVersion: optionalDottedVersionNClausesWithOptionalSuffix, + version: optionalDottedVersionNClausesWithOptionalSuffix, + }).required(), +}).required() + +module.exports = class Nexus extends BaseJsonService { + static render({ version }) { + return { + message: addv(version), + color: versionColor(version), + } + } -// This legacy service should be rewritten to use e.g. BaseJsonService. -// -// Tips for rewriting: -// https://github.com/badges/shields/blob/master/doc/rewriting-services.md -// -// Do not base new services on this code. -module.exports = class Nexus extends LegacyService { static get category() { return 'version' } @@ -20,111 +46,181 @@ module.exports = class Nexus extends LegacyService { static get route() { return { base: 'nexus', + // API pattern: + // /nexus/(r|s|)/(http|https)/[:port][/]//[:k1=v1[:k2=v2[...]]] + format: + '(r|s|[^/]+)/(https?)/((?:[^/]+)(?:/[^/]+)?)/([^/]+)/([^/:]+)(:.+)?', + capture: ['repo', 'scheme', 'host', 'groupId', 'artifactId', 'queryOpt'], } } + static get defaultBadgeData() { + return { color: 'blue', label: 'nexus' } + } + static get examples() { return [ { title: 'Sonatype Nexus (Releases)', - previewUrl: 'r/https/oss.sonatype.org/com.google.guava/guava', + pattern: 'r/:scheme/:host/:groupId/:artifactId', + namedParams: { + scheme: 'https', + host: 'oss.sonatype.org', + groupId: 'com.google.guava', + artifactId: 'guava', + }, + staticExample: this.render({ + version: 'v27.0.1-jre', + }), }, { title: 'Sonatype Nexus (Snapshots)', - previewUrl: 's/https/oss.sonatype.org/com.google.guava/guava', + pattern: 's/:scheme/:host/:groupId/:artifactId', + namedParams: { + scheme: 'https', + host: 'oss.sonatype.org', + groupId: 'com.google.guava', + artifactId: 'guava', + }, + staticExample: this.render({ + version: 'v24.0-SNAPSHOT', + }), + }, + { + title: 'Sonatype Nexus (Repository)', + pattern: ':repo/:scheme/:host/:groupId/:artifactId', + namedParams: { + repo: 'developer', + scheme: 'https', + host: 'repository.jboss.org/nexus', + groupId: 'ai.h2o', + artifactId: 'h2o-automl', + }, + staticExample: this.render({ + version: '3.22.0.2', + }), + }, + { + title: 'Sonatype Nexus (Query Options)', + pattern: ':repo/:scheme/:host/:groupId/:artifactId/:queryOpt', + namedParams: { + repo: 'fs-public-snapshots', + scheme: 'https', + host: 'repository.jboss.org/nexus', + groupId: 'com.progress.fuse', + artifactId: 'fusehq', + queryOpt: ':c=agent-apple-osx:p=tar.gz', + }, + staticExample: this.render({ + version: '7.0.1-SNAPSHOT', + }), + documentation: ` +

+ Note that you can use query options with any Nexus badge type (Releases, Snapshots, or Repository) +

+

+ Query options should be provided as key=value pairs separated by a semicolon +

+ `, }, ] } - static registerLegacyRouteHandler({ camp, cache }) { - // standalone sonatype nexus installation + transform({ repo, json }) { + if (repo === 'r') { + return { version: json.data[0].latestRelease } + } else if (repo === 's') { + // only want to match 1.2.3-SNAPSHOT style versions, which may not always be in + // 'latestSnapshot' so check 'version' as well before continuing to next entry + for (const artifact of json.data) { + if (isSnapshotVersion(artifact.latestSnapshot)) { + return { version: artifact.latestSnapshot } + } + if (isSnapshotVersion(artifact.version)) { + return { version: artifact.version } + } + } + throw new InvalidResponse({ prettyMessage: 'no snapshot versions found' }) + } else { + return { version: json.data.baseVersion || json.data.version } + } + } + + async handle({ repo, scheme, host, groupId, artifactId, queryOpt }) { + const { json } = await this.fetch({ + repo, + scheme, + host, + groupId, + artifactId, + queryOpt, + }) + if (json.data.length === 0) { + throw new NotFound({ prettyMessage: 'artifact or version not found' }) + } + const { version } = this.transform({ repo, json }) + if (!version) { + throw new InvalidResponse({ prettyMessage: 'invalid artifact version' }) + } + return this.constructor.render({ version }) + } + + addQueryParamsToQueryString({ qs, queryOpt }) { + // Users specify query options with 'key=value' pairs, using a + // semicolon delimiter between pairs ([:k1=v1[:k2=v2[...]]]). + // queryOpt will be a string containing those key/value pairs, + // For example: :c=agent-apple-osx:p=tar.gz + const keyValuePairs = queryOpt.split(':') + keyValuePairs.forEach(keyValuePair => { + const paramParts = keyValuePair.split('=') + const paramKey = paramParts[0] + const paramValue = paramParts[1] + qs[paramKey] = paramValue + }) + } + + async fetch({ repo, scheme, host, groupId, artifactId, queryOpt }) { + const qs = { + g: groupId, + a: artifactId, + } + let schema + let url = `${scheme}://${host}/` // API pattern: - // /nexus/(r|s|)/(http|https)/[:port][/]//[:k1=v1[:k2=v2[...]]]. // for /nexus/[rs]/... pattern, use the search api of the nexus server, and // for /nexus//... pattern, use the resolve api of the nexus server. - camp.route( - /^\/nexus\/(r|s|[^/]+)\/(https?)\/((?:[^/]+)(?:\/[^/]+)?)\/([^/]+)\/([^/:]+)(:.+)?\.(svg|png|gif|jpg|json)$/, - cache((data, match, sendBadge, request) => { - const repo = match[1] // r | s | repo-name - const scheme = match[2] // http | https - const host = match[3] // eg, `nexus.example.com` - const groupId = encodeURIComponent(match[4]) // eg, `com.google.inject` - const artifactId = encodeURIComponent(match[5]) // eg, `guice` - const queryOpt = (match[6] || '').replace(/:/g, '&') // eg, `&p=pom&c=doc` - const format = match[7] - - const badgeData = getBadgeData('nexus', data) - - const apiUrl = `${scheme}://${host}${ - repo === 'r' || repo === 's' - ? `/service/local/lucene/search?g=${groupId}&a=${artifactId}${queryOpt}` - : `/service/local/artifact/maven/resolve?r=${repo}&g=${groupId}&a=${artifactId}&v=LATEST${queryOpt}` - }` - - request( - apiUrl, - { headers: { Accept: 'application/json' } }, - (err, res, buffer) => { - if (err != null) { - badgeData.text[1] = 'inaccessible' - sendBadge(format, badgeData) - return - } else if (res && res.statusCode === 404) { - badgeData.text[1] = 'no-artifact' - sendBadge(format, badgeData) - return - } - try { - const parsed = JSON.parse(buffer) - let version = '0' - switch (repo) { - case 'r': - if (parsed.data.length === 0) { - badgeData.text[1] = 'no-artifact' - sendBadge(format, badgeData) - return - } - version = parsed.data[0].latestRelease - break - case 's': - if (parsed.data.length === 0) { - badgeData.text[1] = 'no-artifact' - sendBadge(format, badgeData) - return - } - // only want to match 1.2.3-SNAPSHOT style versions, which may not always be in - // 'latestSnapshot' so check 'version' as well before continuing to next entry - parsed.data.every(artifact => { - if (isNexusSnapshotVersion(artifact.latestSnapshot)) { - version = artifact.latestSnapshot - return - } - if (isNexusSnapshotVersion(artifact.version)) { - version = artifact.version - return - } - return true - }) - break - default: - version = parsed.data.baseVersion || parsed.data.version - break - } - if (version !== '0') { - badgeData.text[1] = versionText(version) - badgeData.colorscheme = versionColor(version) - } else { - badgeData.text[1] = 'undefined' - badgeData.colorscheme = 'orange' - } - sendBadge(format, badgeData) - } catch (e) { - badgeData.text[1] = 'invalid' - sendBadge(format, badgeData) - } - } - ) - }) - ) + if (repo === 'r' || repo === 's') { + schema = searchApiSchema + url += 'service/local/lucene/search' + } else { + schema = resolveApiSchema + url += 'service/local/artifact/maven/resolve' + qs.r = repo + qs.v = 'LATEST' + } + + if (queryOpt) { + this.addQueryParamsToQueryString({ qs, queryOpt }) + } + + const options = { qs } + + if (serverSecrets && serverSecrets.nexus_user) { + options.auth = { + user: serverSecrets.nexus_user, + pass: serverSecrets.nexus_pass, + } + } + + const json = await this._requestJson({ + schema, + url, + options, + errorMessages: { + 404: 'artifact not found', + }, + }) + + return { json } } } diff --git a/services/nexus/nexus.tester.js b/services/nexus/nexus.tester.js index 7988e2f47e16f..22611bd11e1a1 100644 --- a/services/nexus/nexus.tester.js +++ b/services/nexus/nexus.tester.js @@ -1,87 +1,244 @@ 'use strict' const Joi = require('joi') -const { invalidJSON } = require('../response-fixtures') +const sinon = require('sinon') +const { colorScheme } = require('../test-helpers') +const { + isVPlusDottedVersionNClausesWithOptionalSuffix: isVersion, +} = require('../test-validators') const t = (module.exports = require('../create-service-tester')()) +const serverSecrets = require('../../lib/server-secrets') -t.create('search release version') +const user = 'admin' +const pass = 'password' + +function mockNexusCreds() { + serverSecrets['nexus_user'] = undefined + serverSecrets['nexus_pass'] = undefined + sinon.stub(serverSecrets, 'nexus_user').value(user) + sinon.stub(serverSecrets, 'nexus_pass').value(pass) +} + +t.create('live: search release version valid artifact') .get('/r/https/repository.jboss.org/nexus/jboss/jboss-client.json') .expectJSONTypes( Joi.object().keys({ name: 'nexus', - value: Joi.string().regex(/^v4(\.\d+)+$/), + value: isVersion, }) ) -t.create('search release version of an inexistent artifact') +t.create('live: search release version of an inexistent artifact') .get('/r/https/repository.jboss.org/nexus/jboss/inexistent-artifact-id.json') - .expectJSON({ name: 'nexus', value: 'no-artifact' }) + .expectJSON({ + name: 'nexus', + value: 'artifact or version not found', + }) -t.create('search snapshot version') +t.create('live: search snapshot version valid snapshot artifact') .get('/s/https/repository.jboss.org/nexus/com.progress.fuse/fusehq.json') .expectJSONTypes( Joi.object().keys({ name: 'nexus', - value: Joi.string().regex(/-SNAPSHOT$/), + value: isVersion, }) ) -t.create('search snapshot version not in latestSnapshot') - .get('/s/https/repository.jboss.org/nexus/com.progress.fuse/fusehq.json') - .intercept(nock => - nock('https://repository.jboss.org') - .get('/nexus/service/local/lucene/search') - .query({ g: 'com.progress.fuse', a: 'fusehq' }) - .reply(200, '{ "data": [ { "version": "7.0.1-SNAPSHOT" } ] }') - ) - .expectJSON({ name: 'nexus', value: 'v7.0.1-SNAPSHOT' }) - -t.create('search snapshot version of a release artifact') +t.create('live: search snapshot version of a release artifact') .get('/s/https/repository.jboss.org/nexus/jboss/jboss-client.json') - .expectJSON({ name: 'nexus', value: 'undefined' }) + .expectJSON({ name: 'nexus', value: 'no snapshot versions found' }) -t.create('search snapshot version of an inexistent artifact') - .get('/s/https/repository.jboss.org/nexus/jboss/inexistent-artifact-id.json') - .expectJSON({ name: 'nexus', value: 'no-artifact' }) +t.create('live: search snapshot version of an inexistent artifact') + .get( + '/s/https/repository.jboss.org/nexus/jboss/inexistent-artifact-id.json?style=_shields_test' + ) + .expectJSON({ + name: 'nexus', + value: 'artifact or version not found', + colorB: colorScheme.red, + }) -t.create('resolve version') +t.create('live: repository version') .get('/developer/https/repository.jboss.org/nexus/ai.h2o/h2o-automl.json') .expectJSONTypes( Joi.object().keys({ name: 'nexus', - value: Joi.string().regex(/^v3(\.\d+)+$/), + value: isVersion, }) ) -t.create('resolve version with query') +t.create('live: repository version with query') .get( '/fs-public-snapshots/https/repository.jboss.org/nexus/com.progress.fuse/fusehq:c=agent-apple-osx:p=tar.gz.json' ) .expectJSONTypes( Joi.object().keys({ name: 'nexus', - value: Joi.string().regex(/^v7(\.\d+)+-SNAPSHOT$/), + value: isVersion, }) ) -t.create('resolve version of an inexistent artifact') +t.create('live: repository version of an inexistent artifact') .get( '/developer/https/repository.jboss.org/nexus/jboss/inexistent-artifact-id.json' ) - .expectJSON({ name: 'nexus', value: 'no-artifact' }) + .expectJSON({ + name: 'nexus', + value: 'artifact not found', + }) t.create('connection error') .get('/r/https/repository.jboss.org/nexus/jboss/jboss-client.json') .networkOff() .expectJSON({ name: 'nexus', value: 'inaccessible' }) -t.create('json parsing error') - .get('/r/https/repository.jboss.org/nexus/jboss/jboss-client.json') +t.create('search snapshot version not in latestSnapshot') + .get( + '/s/https/repository.jboss.org/nexus/com.progress.fuse/fusehq.json?style=_shields_test' + ) + .intercept(nock => + nock('https://repository.jboss.org/nexus') + .get('/service/local/lucene/search') + .query({ g: 'com.progress.fuse', a: 'fusehq' }) + .reply(200, { data: [{ version: '7.0.1-SNAPSHOT' }] }) + ) + .expectJSON({ + name: 'nexus', + value: 'v7.0.1-SNAPSHOT', + colorB: colorScheme.orange, + }) + +t.create('search snapshot no snapshot versions') + .get( + '/s/https/repository.jboss.org/nexus/com.progress.fuse/fusehq.json?style=_shields_test' + ) + .intercept(nock => + nock('https://repository.jboss.org/nexus') + .get('/service/local/lucene/search') + .query({ g: 'com.progress.fuse', a: 'fusehq' }) + .reply(200, { data: [{ version: '1.2.3' }] }) + ) + .expectJSON({ + name: 'nexus', + value: 'no snapshot versions found', + colorB: colorScheme.lightgrey, + }) + +t.create('search release version') + .get( + '/r/https/repository.jboss.org/nexus/jboss/jboss-client.json?style=_shields_test' + ) + .intercept(nock => + nock('https://repository.jboss.org/nexus') + .get('/service/local/lucene/search') + .query({ g: 'jboss', a: 'jboss-client' }) + .reply(200, { data: [{ latestRelease: '1.0.0' }] }) + ) + .expectJSON({ + name: 'nexus', + value: 'v1.0.0', + colorB: colorScheme.blue, + }) + +t.create('repository release version') + .get( + '/developer/https/repository.jboss.org/nexus/ai.h2o/h2o-automl.json?style=_shields_test' + ) + .intercept(nock => + nock('https://repository.jboss.org/nexus') + .get('/service/local/artifact/maven/resolve') + .query({ + g: 'ai.h2o', + a: 'h2o-automl', + r: 'developer', + v: 'LATEST', + }) + .reply(200, { + data: { + baseVersion: '1.2.3', + version: '1.0.0', + }, + }) + ) + .expectJSON({ + name: 'nexus', + value: 'v1.2.3', + colorB: colorScheme.blue, + }) + +t.create('repository release version') + .get( + '/developer/https/repository.jboss.org/nexus/ai.h2o/h2o-automl.json?style=_shields_test' + ) + .intercept(nock => + nock('https://repository.jboss.org/nexus') + .get('/service/local/artifact/maven/resolve') + .query({ + g: 'ai.h2o', + a: 'h2o-automl', + r: 'developer', + v: 'LATEST', + }) + .reply(200, { + data: { + version: '1.0.0', + }, + }) + ) + .expectJSON({ + name: 'nexus', + value: 'v1.0.0', + colorB: colorScheme.blue, + }) + +t.create('user query params') + .get( + '/fs-public-snapshots/https/repository.jboss.org/nexus/com.progress.fuse/fusehq:c=agent-apple-osx:p=tar.gz.json?style=_shields_test' + ) + .intercept(nock => + nock('https://repository.jboss.org/nexus') + .get('/service/local/artifact/maven/resolve') + .query({ + g: 'com.progress.fuse', + a: 'fusehq', + r: 'fs-public-snapshots', + v: 'LATEST', + c: 'agent-apple-osx', + p: 'tar.gz', + }) + .reply(200, { + data: { + version: '3.2.1', + }, + }) + ) + .expectJSON({ + name: 'nexus', + value: 'v3.2.1', + colorB: colorScheme.blue, + }) + +t.create('auth') + .before(mockNexusCreds) + .get( + '/r/https/repository.jboss.org/nexus/jboss/jboss-client.json?style=_shields_test' + ) .intercept(nock => - nock('https://repository.jboss.org') - .get('/nexus/service/local/lucene/search') + nock('https://repository.jboss.org/nexus') + .get('/service/local/lucene/search') .query({ g: 'jboss', a: 'jboss-client' }) - .reply(invalidJSON) + // This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request. + // Without this the request wouldn't match and the test would fail. + .basicAuth({ + user, + pass, + }) + .reply(200, { data: [{ latestRelease: '2.3.4' }] }) ) - .expectJSON({ name: 'nexus', value: 'invalid' }) + .finally(sinon.restore) + .expectJSON({ + name: 'nexus', + value: 'v2.3.4', + colorB: colorScheme.blue, + }) diff --git a/services/validators.js b/services/validators.js index 63a4f6cb978eb..d5b6355073a96 100644 --- a/services/validators.js +++ b/services/validators.js @@ -20,6 +20,10 @@ module.exports = { .validRange() .required(), + optionalDottedVersionNClausesWithOptionalSuffix: Joi.string().regex( + /^\d+(\.\d+)*(-.*)?$/ + ), + // TODO This accepts URLs with query strings and fragments, which for some // purposes should be rejected. optionalUrl: Joi.string().uri({ scheme: ['http', 'https'] }),