diff --git a/services/apm/apm.service.js b/services/apm/apm.service.js index 3f4ccbea25..0725124e9c 100644 --- a/services/apm/apm.service.js +++ b/services/apm/apm.service.js @@ -5,11 +5,22 @@ const { BaseJsonService } = require('../base') const { InvalidResponse } = require('../errors') const { version: versionColor } = require('../../lib/color-formatters') const { metric, addv } = require('../../lib/text-formatters') +const { nonNegativeInteger } = require('../validators.js') + +const apmSchema = Joi.object({ + downloads: nonNegativeInteger, + releases: Joi.object({ + latest: Joi.string().required(), + }), + metadata: Joi.object({ + license: Joi.string().required(), + }), +}) class BaseAPMService extends BaseJsonService { async fetch(repo) { return this._requestJson({ - schema: Joi.object(), + schema: apmSchema, url: `https://atom.io/api/packages/${repo}`, notFoundMessage: 'package not found', }) @@ -18,6 +29,15 @@ class BaseAPMService extends BaseJsonService { static get defaultBadgeData() { return { label: 'apm' } } + + static get examples() { + return [ + { + previewUrl: 'vim-mode', + keywords: ['atom'], + }, + ] + } } class APMDownloads extends BaseAPMService { @@ -43,15 +63,6 @@ class APMDownloads extends BaseAPMService { capture: ['repo'], } } - - static get examples() { - return [ - { - previewUrl: 'dm/vim-mode', - keywords: ['atom'], - }, - ] - } } class APMVersion extends BaseAPMService { @@ -77,15 +88,6 @@ class APMVersion extends BaseAPMService { capture: ['repo'], } } - - static get examples() { - return [ - { - previewUrl: 'v/vim-mode', - keywords: ['atom'], - }, - ] - } } class APMLicense extends BaseAPMService { @@ -115,15 +117,6 @@ class APMLicense extends BaseAPMService { capture: ['repo'], } } - - static get examples() { - return [ - { - previewUrl: 'l/vim-mode', - keywords: ['atom'], - }, - ] - } } module.exports = { diff --git a/services/appveyor/appveyor.service.js b/services/appveyor/appveyor.service.js index 220cc3d2b2..ec4c9f4b98 100644 --- a/services/appveyor/appveyor.service.js +++ b/services/appveyor/appveyor.service.js @@ -3,6 +3,12 @@ const Joi = require('joi') const { BaseJsonService } = require('../base') +const appVeyorSchema = Joi.object({ + build: Joi.object({ + status: Joi.string().required(), + }), +}).required() + module.exports = class AppVeyor extends BaseJsonService { async handle({ repo, branch }) { let url = `https://ci.appveyor.com/api/projects/${repo}` @@ -12,7 +18,7 @@ module.exports = class AppVeyor extends BaseJsonService { const { build: { status }, } = await this._requestJson({ - schema: Joi.object(), + schema: appVeyorSchema, url, notFoundMessage: 'project not found or access denied', }) diff --git a/services/cdnjs/cdnjs.service.js b/services/cdnjs/cdnjs.service.js index 52861c8275..6b9cf7596f 100644 --- a/services/cdnjs/cdnjs.service.js +++ b/services/cdnjs/cdnjs.service.js @@ -6,12 +6,17 @@ const { NotFound } = require('../errors') const { addv: versionText } = require('../../lib/text-formatters') const { version: versionColor } = require('../../lib/color-formatters') +const cdnjsSchema = Joi.object({ + // optional due to non-standard 'not found' condition + version: Joi.string(), +}).required() + module.exports = class Cdnjs extends BaseJsonService { async handle({ library }) { const url = `https://api.cdnjs.com/libraries/${library}?fields=version` const json = await this._requestJson({ url, - schema: Joi.any(), + schema: cdnjsSchema, }) if (Object.keys(json).length === 0) { @@ -19,11 +24,10 @@ module.exports = class Cdnjs extends BaseJsonService { status code = 200, body = {} */ throw new NotFound() } - const version = json.version || 0 return { - message: versionText(version), - color: versionColor(version), + message: versionText(json.version), + color: versionColor(json.version), } } diff --git a/services/clojars/clojars.service.js b/services/clojars/clojars.service.js index a02509b727..43f3f36ebb 100644 --- a/services/clojars/clojars.service.js +++ b/services/clojars/clojars.service.js @@ -5,12 +5,17 @@ const { BaseJsonService } = require('../base') const { NotFound } = require('../errors') const { version: versionColor } = require('../../lib/color-formatters') +const clojarsSchema = Joi.object({ + // optional due to non-standard 'not found' condition + version: Joi.string(), +}).required() + module.exports = class Clojars extends BaseJsonService { async handle({ clojar }) { const url = `https://clojars.org/${clojar}/latest-version.json` const json = await this._requestJson({ url, - schema: Joi.any(), + schema: clojarsSchema, }) if (Object.keys(json).length === 0) { diff --git a/services/gem/gem-downloads.service.js b/services/gem/gem-downloads.service.js new file mode 100644 index 0000000000..6d0fa3bf15 --- /dev/null +++ b/services/gem/gem-downloads.service.js @@ -0,0 +1,149 @@ +'use strict' + +const semver = require('semver') +const Joi = require('joi') + +const { BaseJsonService } = require('../base') +const { InvalidResponse } = require('../errors') +const { + downloadCount: downloadCountColor, +} = require('../../lib/color-formatters') +const { metric } = require('../../lib/text-formatters') +const { latest: latestVersion } = require('../../lib/version') +const { nonNegativeInteger } = require('../validators.js') + +const gemsSchema = Joi.object({ + downloads: nonNegativeInteger, + version_downloads: nonNegativeInteger, +}).required() + +const versionsSchema = Joi.array() + .items( + Joi.object({ + prerelease: Joi.boolean().required(), + number: Joi.string().required(), + downloads_count: nonNegativeInteger, + }) + ) + .min(1) + .required() + +module.exports = class GemDownloads extends BaseJsonService { + async fetch(repo, info) { + const endpoint = info === 'dv' ? 'versions/' : 'gems/' + const schema = info === 'dv' ? versionsSchema : gemsSchema + const url = `https://rubygems.org/api/v1/${endpoint}${repo}.json` + return this._requestJson({ + url, + schema, + }) + } + + _getLabel(version, info) { + if (version) { + return 'downloads@' + version + } else { + if (info === 'dtv') { + return 'downloads@latest' + } else { + return 'downloads' + } + } + } + + async handle({ info, rubygem }) { + const splitRubygem = rubygem.split('/') + const repo = splitRubygem[0] + let version = + splitRubygem.length > 1 ? splitRubygem[splitRubygem.length - 1] : null + version = version === 'stable' ? version : semver.valid(version) + const label = this._getLabel(version, info) + const json = await this.fetch(repo, info) + + let downloads + if (info === 'dt') { + downloads = metric(json.downloads) + } else if (info === 'dtv') { + downloads = metric(json.version_downloads) + } else if (info === 'dv') { + let versionData + if (version !== null && version === 'stable') { + const versions = json + .filter(function(ver) { + return ver.prerelease === false + }) + .map(function(ver) { + return ver.number + }) + // Found latest stable version. + const stableVersion = latestVersion(versions) + versionData = json.filter(function(ver) { + return ver.number === stableVersion + })[0] + downloads = metric(versionData.downloads_count) + } else if (version !== null) { + versionData = json.filter(function(ver) { + return ver.number === version + })[0] + + downloads = metric(versionData.downloads_count) + } else { + throw new InvalidResponse({ + underlyingError: new Error('version is null'), + }) + } + } else { + throw new InvalidResponse({ + underlyingError: new Error('info is invalid'), + }) + } + + return { + label: label, + message: downloads, + color: downloadCountColor(downloads), + } + } + + // Metadata + static get defaultBadgeData() { + return { label: 'downloads' } + } + + static get category() { + return 'downloads' + } + + static get url() { + return { + base: 'gem', + format: '(dt|dtv|dv)/(.+)', + capture: ['info', 'rubygem'], + } + } + + static get examples() { + return [ + { + title: 'Gem', + previewUrl: 'dv/rails/stable', + keywords: ['ruby'], + }, + { + title: 'Gem', + previewUrl: 'dv/rails/4.1.0', + keywords: ['ruby'], + }, + { + title: 'Gem', + previewUrl: 'dtv/rails', + keywords: ['ruby'], + }, + { + title: 'Gem', + previewUrl: 'dt/rails', + keywords: ['ruby'], + }, + ] + } +} diff --git a/services/gem/gem-owner.service.js b/services/gem/gem-owner.service.js new file mode 100644 index 0000000000..2ac085499f --- /dev/null +++ b/services/gem/gem-owner.service.js @@ -0,0 +1,51 @@ +'use strict' + +const Joi = require('joi') + +const { BaseJsonService } = require('../base') +const { floorCount: floorCountColor } = require('../../lib/color-formatters') + +const ownerSchema = Joi.array().required() + +module.exports = class GemOwner extends BaseJsonService { + async handle({ user }) { + const url = `https://rubygems.org/api/v1/owners/${user}/gems.json` + const json = await this._requestJson({ + url, + schema: ownerSchema, + }) + const count = json.length + + return { + message: count, + color: floorCountColor(count, 10, 50, 100), + } + } + + // Metadata + static get defaultBadgeData() { + return { label: 'gems' } + } + + static get category() { + return 'other' + } + + static get url() { + return { + base: 'gem/u', + format: '(.+)', + capture: ['user'], + } + } + + static get examples() { + return [ + { + title: 'Gems', + previewUrl: 'raphink', + keywords: ['ruby'], + }, + ] + } +} diff --git a/services/gem/gem-rank.service.js b/services/gem/gem-rank.service.js new file mode 100644 index 0000000000..8e50f1b570 --- /dev/null +++ b/services/gem/gem-rank.service.js @@ -0,0 +1,95 @@ +'use strict' + +const Joi = require('joi') + +const { BaseJsonService } = require('../base') +const { floorCount: floorCountColor } = require('../../lib/color-formatters') +const { ordinalNumber } = require('../../lib/text-formatters') +const { nonNegativeInteger } = require('../validators.js') + +const totalSchema = Joi.array() + .items( + Joi.object({ + total_ranking: nonNegativeInteger, + }) + ) + .min(1) + .required() +const dailySchema = Joi.array() + .items( + Joi.object({ + daily_ranking: nonNegativeInteger, + }) + ) + .min(1) + .required() + +module.exports = class GemRank extends BaseJsonService { + _getApiUrl(repo, totalRank, dailyRank) { + let endpoint + if (totalRank) { + endpoint = '/total_ranking.json' + } else if (dailyRank) { + endpoint = '/daily_ranking.json' + } + return `http://bestgems.org/api/v1/gems/${repo}${endpoint}` + } + + async handle({ info, repo }) { + const totalRank = info === 'rt' + const dailyRank = info === 'rd' + const schema = totalRank ? totalSchema : dailySchema + const url = this._getApiUrl(repo, totalRank, dailyRank) + const json = await this._requestJson({ + url, + schema, + }) + + let rank + if (totalRank) { + rank = json[0].total_ranking + } else if (dailyRank) { + rank = json[0].daily_ranking + } + const count = Math.floor(100000 / rank) + let message = ordinalNumber(rank) + message += totalRank ? '' : ' daily' + + return { + message: message, + color: floorCountColor(count, 10, 50, 100), + } + } + + // Metadata + static get defaultBadgeData() { + return { label: 'rank' } + } + + static get category() { + return 'rating' + } + + static get url() { + return { + base: 'gem', + format: '(rt|rd)/(.+)', + capture: ['info', 'repo'], + } + } + + static get examples() { + return [ + { + title: 'Gems', + previewUrl: 'rt/puppet', + keywords: ['ruby'], + }, + { + title: 'Gems', + previewUrl: 'rd/facter', + keywords: ['ruby'], + }, + ] + } +} diff --git a/services/gem/gem-version.service.js b/services/gem/gem-version.service.js new file mode 100644 index 0000000000..615f0d3cca --- /dev/null +++ b/services/gem/gem-version.service.js @@ -0,0 +1,55 @@ +'use strict' + +const Joi = require('joi') + +const { BaseJsonService } = require('../base') +const { addv: versionText } = require('../../lib/text-formatters') +const { version: versionColor } = require('../../lib/color-formatters') + +// Response should contain a string key 'version' +// In most cases this will be a SemVer +// but the registry doesn't actually enforce this +const versionSchema = Joi.object({ + version: Joi.string().required(), +}).required() + +module.exports = class GemVersion extends BaseJsonService { + async handle({ repo }) { + const url = `https://rubygems.org/api/v1/gems/${repo}.json` + const { version } = await this._requestJson({ + url, + schema: versionSchema, + }) + return { + message: versionText(version), + color: versionColor(version), + } + } + + // Metadata + static get defaultBadgeData() { + return { label: 'gem' } + } + + static get category() { + return 'version' + } + + static get url() { + return { + base: 'gem/v', + format: '(.+)', + capture: ['repo'], + } + } + + static get examples() { + return [ + { + title: 'Gem', + previewUrl: 'formatador', + keywords: ['ruby'], + }, + ] + } +} diff --git a/services/gem/gem.service.js b/services/gem/gem.service.js deleted file mode 100644 index 03a9033b68..0000000000 --- a/services/gem/gem.service.js +++ /dev/null @@ -1,294 +0,0 @@ -'use strict' - -const semver = require('semver') -const Joi = require('joi') - -const { BaseJsonService } = require('../base') -const { InvalidResponse } = require('../errors') -const { addv: versionText } = require('../../lib/text-formatters') -const { version: versionColor } = require('../../lib/color-formatters') -const { - floorCount: floorCountColor, - downloadCount: downloadCountColor, -} = require('../../lib/color-formatters') -const { metric, ordinalNumber } = require('../../lib/text-formatters') -const { latest: latestVersion } = require('../../lib/version') - -class GemVersion extends BaseJsonService { - async handle({ repo }) { - const url = `https://rubygems.org/api/v1/gems/${repo}.json` - const { version } = await this._requestJson({ - url, - schema: Joi.object(), - }) - return { - message: versionText(version), - color: versionColor(version), - } - } - - // Metadata - static get defaultBadgeData() { - return { label: 'gem' } - } - - static get category() { - return 'version' - } - - static get url() { - return { - base: 'gem/v', - format: '(.+)', - capture: ['repo'], - } - } - - static get examples() { - return [ - { - title: 'Gem', - previewUrl: 'formatador', - keywords: ['ruby'], - }, - ] - } -} - -class GemDownloads extends BaseJsonService { - fetch(repo, info) { - const endpoint = info === 'dv' ? 'versions/' : 'gems/' - const url = `https://rubygems.org/api/v1/${endpoint}${repo}.json` - return this._requestJson({ - url, - schema: Joi.any(), - }) - } - - _getLabel(version, info) { - if (version) { - return 'downloads@' + version - } else { - if (info === 'dtv') { - return 'downloads@latest' - } else { - return 'downloads' - } - } - } - - async handle({ info, rubygem }) { - const splitRubygem = rubygem.split('/') - const repo = splitRubygem[0] - let version = - splitRubygem.length > 1 ? splitRubygem[splitRubygem.length - 1] : null - version = version === 'stable' ? version : semver.valid(version) - const label = this._getLabel(version, info) - const json = await this.fetch(repo, info) - - let downloads - if (info === 'dt') { - downloads = metric(json.downloads) - } else if (info === 'dtv') { - downloads = metric(json.version_downloads) - } else if (info === 'dv') { - let versionData - if (version !== null && version === 'stable') { - const versions = json - .filter(function(ver) { - return ver.prerelease === false - }) - .map(function(ver) { - return ver.number - }) - // Found latest stable version. - const stableVersion = latestVersion(versions) - versionData = json.filter(function(ver) { - return ver.number === stableVersion - })[0] - downloads = metric(versionData.downloads_count) - } else if (version !== null) { - versionData = json.filter(function(ver) { - return ver.number === version - })[0] - - downloads = metric(versionData.downloads_count) - } else { - throw new InvalidResponse({ - underlyingError: new Error('version is null'), - }) - } - } else { - throw new InvalidResponse({ - underlyingError: new Error('info is invalid'), - }) - } - - return { - label: label, - message: downloads, - color: downloadCountColor(downloads), - } - } - - // Metadata - static get defaultBadgeData() { - return { label: 'downloads' } - } - - static get category() { - return 'downloads' - } - - static get url() { - return { - base: 'gem', - format: '(dt|dtv|dv)/(.+)', - capture: ['info', 'rubygem'], - } - } - - static get examples() { - return [ - { - title: 'Gem', - previewUrl: 'dv/rails/stable', - keywords: ['ruby'], - }, - { - title: 'Gem', - previewUrl: 'dv/rails/4.1.0', - keywords: ['ruby'], - }, - { - title: 'Gem', - previewUrl: 'dtv/rails', - keywords: ['ruby'], - }, - { - title: 'Gem', - previewUrl: 'dt/rails', - keywords: ['ruby'], - }, - ] - } -} - -class GemOwner extends BaseJsonService { - async handle({ user }) { - const url = `https://rubygems.org/api/v1/owners/${user}/gems.json` - const json = await this._requestJson({ - url, - schema: Joi.array(), - }) - const count = json.length - - return { - message: count, - color: floorCountColor(count, 10, 50, 100), - } - } - - // Metadata - static get defaultBadgeData() { - return { label: 'gems' } - } - - static get category() { - return 'other' - } - - static get url() { - return { - base: 'gem/u', - format: '(.+)', - capture: ['user'], - } - } - - static get examples() { - return [ - { - title: 'Gems', - previewUrl: 'raphink', - keywords: ['ruby'], - }, - ] - } -} - -class GemRank extends BaseJsonService { - _getApiUrl(repo, totalRank, dailyRank) { - let endpoint - if (totalRank) { - endpoint = '/total_ranking.json' - } else if (dailyRank) { - endpoint = '/daily_ranking.json' - } - return `http://bestgems.org/api/v1/gems/${repo}${endpoint}` - } - - async handle({ info, repo }) { - const totalRank = info === 'rt' - const dailyRank = info === 'rd' - const url = this._getApiUrl(repo, totalRank, dailyRank) - const json = await this._requestJson({ - url, - schema: Joi.array(), - }) - - let rank - if (totalRank) { - rank = json[0].total_ranking - } else if (dailyRank) { - rank = json[0].daily_ranking - } - const count = Math.floor(100000 / rank) - let message = ordinalNumber(rank) - message += totalRank ? '' : ' daily' - - return { - message: message, - color: floorCountColor(count, 10, 50, 100), - } - } - - // Metadata - static get defaultBadgeData() { - return { label: 'rank' } - } - - static get category() { - return 'rating' - } - - static get url() { - return { - base: 'gem', - format: '(rt|rd)/(.+)', - capture: ['info', 'repo'], - } - } - - static get examples() { - return [ - { - title: 'Gems', - previewUrl: 'rt/puppet', - keywords: ['ruby'], - }, - { - title: 'Gems', - previewUrl: 'rd/facter', - keywords: ['ruby'], - }, - ] - } -} - -module.exports = { - GemVersion, - GemDownloads, - GemOwner, - GemRank, -} diff --git a/services/npm/npm-downloads.service.js b/services/npm/npm-downloads.service.js index a8e0be3576..aae45cbcbc 100644 --- a/services/npm/npm-downloads.service.js +++ b/services/npm/npm-downloads.service.js @@ -3,13 +3,11 @@ const Joi = require('joi') const { BaseJsonService } = require('../base') const { metric } = require('../../lib/text-formatters') +const { nonNegativeInteger } = require('../validators.js') // https://github.com/npm/registry/blob/master/docs/download-counts.md#output const pointResponseSchema = Joi.object({ - downloads: Joi.number() - .integer() - .min(0) - .required(), + downloads: nonNegativeInteger, }).required() // https://github.com/npm/registry/blob/master/docs/download-counts.md#output-1 diff --git a/services/validators.js b/services/validators.js new file mode 100644 index 0000000000..50723b609e --- /dev/null +++ b/services/validators.js @@ -0,0 +1,12 @@ +'use strict' + +const Joi = require('joi') + +const nonNegativeInteger = Joi.number() + .integer() + .min(0) + .required() + +module.exports = { + nonNegativeInteger, +}