diff --git a/services/github/github-package-registry-downloads.service.js b/services/github/github-package-registry-downloads.service.js new file mode 100644 index 0000000000000000000000000000000000000000..c247ae23a5729b2fbd414a1d5bc6cfcc7c9ff38f --- /dev/null +++ b/services/github/github-package-registry-downloads.service.js @@ -0,0 +1,295 @@ +'use strict' + +const gql = require('graphql-tag') +const Joi = require('@hapi/joi') +const { downloadCount } = require('../color-formatters') +const { metric } = require('../text-formatters') +const { nonNegativeInteger } = require('../validators') +const { GithubAuthV4Service } = require('./github-auth-service') +const { documentation, transformErrors } = require('./github-helpers') +const { NotFound } = require('..') + +// https://developer.github.com/v4/object/registrypackagestatistics/ +// https://developer.github.com/v4/object/registrypackageversionstatistics/ +const packageStatistics = Joi.object({ + statistics: Joi.object({ + downloadsToday: nonNegativeInteger, + downloadsThisWeek: nonNegativeInteger, + downloadsThisMonth: nonNegativeInteger, + downloadsThisYear: nonNegativeInteger, + downloadsTotalCount: nonNegativeInteger, + }).required(), +}) + +const totalDownloadsSchema = Joi.object({ + data: Joi.object({ + repository: Joi.object({ + registryPackages: Joi.object({ + nodes: Joi.array() + .items(packageStatistics) + .min(0) + .required(), + }).required(), + }).required(), + }).required(), +}).required() + +const versionDownloadsSchema = Joi.object({ + data: Joi.object({ + repository: Joi.object({ + registryPackages: Joi.object({ + nodes: Joi.array() + .items( + Joi.object({ + version: packageStatistics.allow(null), + }) + ) + .min(0) + .required(), + }).required(), + }).required(), + }).required(), +}).required() + +const latestVersionDownloadsSchema = Joi.object({ + data: Joi.object({ + repository: Joi.object({ + registryPackages: Joi.object({ + nodes: Joi.array() + .items( + Joi.object({ + latestVersion: packageStatistics, + }) + ) + .min(0) + .required(), + }).required(), + }).required(), + }).required(), +}).required() + +const intervalMap = { + today: { + transform: statistics => statistics.downloadsToday, + messageSuffix: '/today', + }, + week: { + transform: statistics => statistics.downloadsThisWeek, + messageSuffix: '/week', + }, + month: { + transform: statistics => statistics.downloadsThisMonth, + messageSuffix: '/month', + }, + year: { + transform: statistics => statistics.downloadsThisYear, + messageSuffix: '/year', + }, + total: { + transform: statistics => statistics.downloadsTotalCount, + messageSuffix: '', + }, +} + +module.exports = class GithubPackageRegistryDownloads extends GithubAuthV4Service { + static get category() { + return 'downloads' + } + + static get route() { + return { + base: 'github/packages/downloads', + pattern: + ':user/:repo/:interval(today|week|month|year|total)/:packageName/:version?', + } + } + + static get examples() { + return [ + { + title: 'GitHub Package Registry Downloads', + pattern: + ':user/:repo/:interval(today|week|month|year|total)/:packageName', + namedParams: { + user: 'github', + repo: 'auto-complete-element', + interval: 'total', + packageName: 'auto-complete-element', + }, + staticPreview: this.render({ + interval: 'total', + count: 50, + }), + documentation, + }, + { + title: 'GitHub Package Registry Downloads (Version)', + pattern: + ':user/:repo/:interval(today|week|month|year|total)/:packageName/:version', + namedParams: { + user: 'github', + repo: 'auto-complete-element', + interval: 'total', + packageName: 'auto-complete-element', + version: '1.0.6', + }, + staticPreview: this.render({ + interval: 'total', + count: 5, + version: '1.0.6', + }), + documentation, + }, + { + title: 'GitHub Package Registry Downloads (Latest Version)', + pattern: + ':user/:repo/:interval(today|week|month|year|total)/:packageName/:version', + namedParams: { + user: 'github', + repo: 'auto-complete-element', + interval: 'total', + packageName: 'auto-complete-element', + version: 'latest', + }, + staticPreview: this.render({ + interval: 'total', + count: 9, + version: 'latest', + }), + documentation, + }, + ] + } + + static _getLabel(version) { + if (version) { + return `downloads@${version}` + } else { + return 'downloads' + } + } + + static get defaultBadgeData() { + return { label: 'downloads' } + } + + static render({ interval, count, version }) { + const { messageSuffix } = intervalMap[interval] + return { + label: this._getLabel(version), + message: `${metric(count)}${messageSuffix}`, + color: downloadCount(count), + } + } + + async fetch({ user, repo, packageName, version }) { + if (version) { + if (version === 'latest') { + return await this._requestGraphql({ + query: gql` + query($user: String!, $repo: String!, $packageName: String!) { + repository(owner: $user, name: $repo) { + registryPackages(name: $packageName, first: 1) { + nodes { + latestVersion { + statistics { + downloadsThisMonth + downloadsThisWeek + downloadsThisYear + downloadsToday + downloadsTotalCount + } + } + } + } + } + } + `, + variables: { user, repo, packageName }, + schema: latestVersionDownloadsSchema, + transformErrors, + }) + } else { + return await this._requestGraphql({ + query: gql` + query( + $user: String! + $repo: String! + $packageName: String! + $version: String! + ) { + repository(owner: $user, name: $repo) { + registryPackages(name: $packageName, first: 1) { + nodes { + version(version: $version) { + statistics { + downloadsThisMonth + downloadsThisWeek + downloadsThisYear + downloadsToday + downloadsTotalCount + } + } + } + } + } + } + `, + variables: { user, repo, packageName, version }, + schema: versionDownloadsSchema, + transformErrors, + }) + } + } else { + return await this._requestGraphql({ + query: gql` + query($user: String!, $repo: String!, $packageName: String!) { + repository(owner: $user, name: $repo) { + registryPackages(name: $packageName, first: 1) { + nodes { + statistics { + downloadsThisMonth + downloadsThisWeek + downloadsThisYear + downloadsToday + downloadsTotalCount + } + } + } + } + } + `, + variables: { user, repo, packageName }, + schema: totalDownloadsSchema, + transformErrors, + }) + } + } + + transform({ json, interval, version }) { + const ghPackage = json.data.repository.registryPackages.nodes[0] + if (!ghPackage) { + throw new NotFound({ prettyMessage: 'package not found' }) + } + + let statistics + if (version === 'latest') { + statistics = ghPackage.latestVersion.statistics + } else if (version) { + if (!ghPackage.version) { + throw new NotFound({ prettyMessage: 'version not found' }) + } + statistics = ghPackage.version.statistics + } else { + statistics = ghPackage.statistics + } + const { transform } = intervalMap[interval] + return { count: transform(statistics) } + } + + async handle({ user, repo, interval, packageName, version }) { + const json = await this.fetch({ user, repo, packageName, version }) + const { count } = this.transform({ json, interval, version }) + return this.constructor.render({ count, interval, version }) + } +} diff --git a/services/github/github-package-registry-downloads.tester.js b/services/github/github-package-registry-downloads.tester.js new file mode 100644 index 0000000000000000000000000000000000000000..5fbcd12c4b73ab2e617b1411466ad0c4cdb46f2d --- /dev/null +++ b/services/github/github-package-registry-downloads.tester.js @@ -0,0 +1,148 @@ +'use strict' + +const Joi = require('@hapi/joi') +const { isMetric, isMetricOverTimePeriod } = require('../test-validators') +const t = (module.exports = require('../tester').createServiceTester()) + +const downloadsToday = Joi.alternatives( + isMetricOverTimePeriod, + Joi.allow('0/today') +) +const downloadsThisWeek = Joi.alternatives( + isMetricOverTimePeriod, + Joi.allow('0/today') +) +const downloadsThisMonth = Joi.alternatives( + isMetricOverTimePeriod, + Joi.allow('0/today') +) +const downloadsThisYear = Joi.alternatives( + isMetricOverTimePeriod, + Joi.allow('0/today') +) + +t.create('Package Registry Downloads (non-existent repository') + .get('/badges/not-a-real-repo/total/super-fake-package.json') + .expectBadge({ + label: 'downloads', + message: 'repo not found', + }) + +t.create('Package Registry Downloads (non-existent package') + .get('/badges/shields/total/super-fake-package.json') + .expectBadge({ + label: 'downloads', + message: 'package not found', + }) + +t.create('Package Registry Downloads (non-existent version') + .get('/github/semantic/total/semantic/9.9.9.json') + .expectBadge({ + label: 'downloads', + message: 'version not found', + }) + +t.create('Package Registry Downloads (total)') + .get('/github/semantic/total/semantic.json') + .expectBadge({ + label: 'downloads', + message: isMetric, + }) + +t.create('Package Registry Downloads (today)') + .get('/github/semantic/today/semantic.json') + .expectBadge({ + label: 'downloads', + message: downloadsToday, + }) + +t.create('Package Registry Downloads (this week)') + .get('/github/semantic/week/semantic.json') + .expectBadge({ + label: 'downloads', + message: downloadsThisWeek, + }) + +t.create('Package Registry Downloads (this month)') + .get('/github/semantic/month/semantic.json') + .expectBadge({ + label: 'downloads', + message: downloadsThisMonth, + }) + +t.create('Package Registry Downloads (this year)') + .get('/github/semantic/year/semantic.json') + .expectBadge({ + label: 'downloads', + message: downloadsThisYear, + }) + +t.create('Package Registry Downloads (specific version total)') + .get('/github/auto-complete-element/total/auto-complete-element/1.0.6.json') + .expectBadge({ + label: 'downloads@1.0.6', + message: isMetric, + }) + +t.create('Package Registry Downloads (specific version today)') + .get('/github/auto-complete-element/today/auto-complete-element/1.0.6.json') + .expectBadge({ + label: 'downloads@1.0.6', + message: downloadsToday, + }) + +t.create('Package Registry Downloads (specific version this week)') + .get('/github/auto-complete-element/week/auto-complete-element/1.0.6.json') + .expectBadge({ + label: 'downloads@1.0.6', + message: downloadsThisWeek, + }) + +t.create('Package Registry Downloads (specific version this month)') + .get('/github/auto-complete-element/month/auto-complete-element/1.0.6.json') + .expectBadge({ + label: 'downloads@1.0.6', + message: downloadsThisMonth, + }) + +t.create('Package Registry Downloads (specific version this year)') + .get('/github/auto-complete-element/year/auto-complete-element/1.0.6.json') + .expectBadge({ + label: 'downloads@1.0.6', + message: downloadsThisYear, + }) + +t.create('Package Registry Downloads (latest version total)') + .get('/github/auto-complete-element/total/auto-complete-element/latest.json') + .expectBadge({ + label: 'downloads@latest', + message: isMetric, + }) + +t.create('Package Registry Downloads (latest version today)') + .get('/github/auto-complete-element/today/auto-complete-element/latest.json') + .expectBadge({ + label: 'downloads@latest', + message: downloadsToday, + }) + +t.create('Package Registry Downloads (latest version this week)') + .get('/github/auto-complete-element/week/auto-complete-element/latest.json') + .expectBadge({ + label: 'downloads@latest', + message: downloadsThisWeek, + }) + +t.create('Package Registry Downloads (latest version this month)') + .get('/github/auto-complete-element/month/auto-complete-element/latest.json') + .expectBadge({ + label: 'downloads@latest', + message: downloadsThisMonth, + }) + +t.create('Package Registry Downloads (latest version this year)') + .get('/github/auto-complete-element/year/auto-complete-element/latest.json') + .expectBadge({ + label: 'downloads@latest', + message: downloadsThisYear, + })