From d0bdb8244cf8820d6913effac1d41e5daf1cd2e0 Mon Sep 17 00:00:00 2001 From: Joe Clack <28568841+Lordfirespeed@users.noreply.github.com> Date: Fri, 22 Dec 2023 14:29:03 +0000 Subject: [PATCH] [Thunderstore] Add Thunderstore Badges (#9782) * add base for thunderstore services * badge service and corresponding tester for thunderstore download count * badge service and corresponding tester for thunderstore latest package version * fix HTML * use stable package-metrics endpoint * remove erroneous statement from docs * remove `namedLogo` from default badge data on both services * follow route naming conventions * use `[x].json` for test assertions * use existing version pattern * document service `handle` return-type more narrowly * use consistent test formatting * add base for thunderstore services * badge service and corresponding tester for thunderstore download count * badge service and corresponding tester for thunderstore latest package version * fix HTML * use stable package-metrics endpoint * remove erroneous statement from docs * remove `namedLogo` from default badge data on both services * follow route naming conventions * use `[x].json` for test assertions * use existing version pattern * document service `handle` return-type more narrowly * use consistent test formatting * plural-ise base thunderstore docs * don't require unused attributes * declare BaseThunderstoreService abstract, add docstring * add thunderstoreGreen static variable * add thunderstore likes service --------- Co-authored-by: chris48s <chris48s@users.noreply.github.com> --- services/thunderstore/thunderstore-base.js | 93 +++++++++++++++++++ .../thunderstore-downloads.service.js | 41 ++++++++ .../thunderstore-downloads.tester.js | 12 +++ .../thunderstore-likes.service.js | 53 +++++++++++ .../thunderstore/thunderstore-likes.tester.js | 12 +++ .../thunderstore-version.service.js | 40 ++++++++ .../thunderstore-version.tester.js | 12 +++ 7 files changed, 263 insertions(+) create mode 100644 services/thunderstore/thunderstore-base.js create mode 100644 services/thunderstore/thunderstore-downloads.service.js create mode 100644 services/thunderstore/thunderstore-downloads.tester.js create mode 100644 services/thunderstore/thunderstore-likes.service.js create mode 100644 services/thunderstore/thunderstore-likes.tester.js create mode 100644 services/thunderstore/thunderstore-version.service.js create mode 100644 services/thunderstore/thunderstore-version.tester.js diff --git a/services/thunderstore/thunderstore-base.js b/services/thunderstore/thunderstore-base.js new file mode 100644 index 0000000000..d4b2d7ad13 --- /dev/null +++ b/services/thunderstore/thunderstore-base.js @@ -0,0 +1,93 @@ +import Joi from 'joi' +import { BaseJsonService } from '../index.js' +import { nonNegativeInteger } from '../validators.js' + +const packageSchema = Joi.object({ + latest: Joi.object({ + version_number: Joi.string().required(), + }).required(), +}).required() + +const packageMetricsSchema = Joi.object({ + downloads: nonNegativeInteger, + rating_score: nonNegativeInteger, +}) + +const documentation = ` +<p> + The Thunderstore badges require a package's <code>namespace</code> and <code>name</code>. +</p> +<p> + Everything can be discerned from your package's URL. Thunderstore package URLs have a mostly consistent + format: +</p> +<p> + <code>https://thunderstore.io/c/[community]/p/[namespace]/[packageName]</code> +</p> +<p> + For example: <code>https://thunderstore.io/c/lethal-company/p/notnotnotswipez/MoreCompany/</code>. + <ul> + <li><code>namespace = "notnotnotswipez"</code></li> + <li><code>packageName = "MoreCompany"</code></li> + </ul> +</p> +<details> + <summary>Risk Of Rain 2</summary> + <p> + The 'default community', Risk of Rain 2, has an alternative URL: + </p> + <p> + <code>https://thunderstore.io/package/[namespace]/[packageName]</code> + </p> +</details> +<details> + <summary>Subdomain Communities</summary> + <p> + Some communities use a 'subdomain' alternative URL, for example, Valheim: + </p> + <p> + <code>https://valheim.thunderstore.io/package/[namespace]/[packageName]</code> + </p> +</details> +` + +/** + * Services which query Thunderstore endpoints should extend BaseThunderstoreService + * + * @abstract + */ +class BaseThunderstoreService extends BaseJsonService { + static thunderstoreGreen = '23FFB0' + + /** + * Fetches package metadata from the Thunderstore API. + * + * @param {object} pkg - Package specifier + * @param {string} pkg.namespace - the package namespace + * @param {string} pkg.packageName - the package name + * @returns {Promise<object>} - Promise containing validated package information + */ + async fetchPackage({ namespace, packageName }) { + return this._requestJson({ + schema: packageSchema, + url: `https://thunderstore.io/api/experimental/package/${namespace}/${packageName}`, + }) + } + + /** + * Fetches package metrics from the Thunderstore API. + * + * @param {object} pkg - Package specifier + * @param {string} pkg.namespace - the package namespace + * @param {string} pkg.packageName - the package name + * @returns {Promise<object>} - Promise containing validated package metrics + */ + async fetchPackageMetrics({ namespace, packageName }) { + return this._requestJson({ + schema: packageMetricsSchema, + url: `https://thunderstore.io/api/v1/package-metrics/${namespace}/${packageName}`, + }) + } +} + +export { BaseThunderstoreService, documentation } diff --git a/services/thunderstore/thunderstore-downloads.service.js b/services/thunderstore/thunderstore-downloads.service.js new file mode 100644 index 0000000000..b642f925ec --- /dev/null +++ b/services/thunderstore/thunderstore-downloads.service.js @@ -0,0 +1,41 @@ +import { renderDownloadsBadge } from '../downloads.js' +import { BaseThunderstoreService, documentation } from './thunderstore-base.js' + +export default class ThunderstoreDownloads extends BaseThunderstoreService { + static category = 'downloads' + + static route = { + base: 'thunderstore/dt', + pattern: ':namespace/:packageName', + } + + static examples = [ + { + title: 'Thunderstore Downloads', + namedParams: { + namespace: 'notnotnotswipez', + packageName: 'MoreCompany', + }, + staticPreview: renderDownloadsBadge({ downloads: 120000 }), + documentation, + }, + ] + + static defaultBadgeData = { + label: 'downloads', + } + + /** + * @param {object} pkg - Package specifier + * @param {string} pkg.namespace - the package namespace + * @param {string} pkg.packageName - the package name + * @returns {Promise<object>} - Promise containing the rendered badge payload + */ + async handle({ namespace, packageName }) { + const { downloads } = await this.fetchPackageMetrics({ + namespace, + packageName, + }) + return renderDownloadsBadge({ downloads }) + } +} diff --git a/services/thunderstore/thunderstore-downloads.tester.js b/services/thunderstore/thunderstore-downloads.tester.js new file mode 100644 index 0000000000..7649798a4a --- /dev/null +++ b/services/thunderstore/thunderstore-downloads.tester.js @@ -0,0 +1,12 @@ +import { createServiceTester } from '../tester.js' +import { isMetric } from '../test-validators.js' + +export const t = await createServiceTester() + +t.create('Downloads') + .get('/ebkr/r2modman.json') + .expectBadge({ label: 'downloads', message: isMetric }) + +t.create('Downloads (not found)') + .get('/not-a-namespace/not-a-package-name.json') + .expectBadge({ label: 'downloads', message: 'not found', color: 'red' }) diff --git a/services/thunderstore/thunderstore-likes.service.js b/services/thunderstore/thunderstore-likes.service.js new file mode 100644 index 0000000000..4304c308b9 --- /dev/null +++ b/services/thunderstore/thunderstore-likes.service.js @@ -0,0 +1,53 @@ +import { metric } from '../text-formatters.js' +import { BaseThunderstoreService, documentation } from './thunderstore-base.js' + +export default class ThunderstoreLikes extends BaseThunderstoreService { + static category = 'social' + + static route = { + base: 'thunderstore/likes', + pattern: ':namespace/:packageName', + } + + static examples = [ + { + title: 'Thunderstore Likes', + namedParams: { + namespace: 'notnotnotswipez', + packageName: 'MoreCompany', + }, + staticPreview: { + label: 'likes', + message: '150', + style: 'social', + }, + documentation, + }, + ] + + static defaultBadgeData = { + label: 'likes', + namedLogo: 'thunderstore', + } + + static render({ likes }) { + return { + message: metric(likes), + color: `#${this.thunderstoreGreen}`, + } + } + + /** + * @param {object} pkg - Package specifier + * @param {string} pkg.namespace - the package namespace + * @param {string} pkg.packageName - the package name + * @returns {Promise<object>} - Promise containing the rendered badge payload + */ + async handle({ namespace, packageName }) { + const { rating_score: likes } = await this.fetchPackageMetrics({ + namespace, + packageName, + }) + return this.constructor.render({ likes }) + } +} diff --git a/services/thunderstore/thunderstore-likes.tester.js b/services/thunderstore/thunderstore-likes.tester.js new file mode 100644 index 0000000000..65af0a5014 --- /dev/null +++ b/services/thunderstore/thunderstore-likes.tester.js @@ -0,0 +1,12 @@ +import { createServiceTester } from '../tester.js' +import { isMetric } from '../test-validators.js' + +export const t = await createServiceTester() + +t.create('Likes') + .get('/ebkr/r2modman.json') + .expectBadge({ label: 'likes', message: isMetric }) + +t.create('Likes (not found)') + .get('/not-a-namespace/not-a-package-name.json') + .expectBadge({ label: 'likes', message: 'not found', color: 'red' }) diff --git a/services/thunderstore/thunderstore-version.service.js b/services/thunderstore/thunderstore-version.service.js new file mode 100644 index 0000000000..6618cf26b3 --- /dev/null +++ b/services/thunderstore/thunderstore-version.service.js @@ -0,0 +1,40 @@ +import { renderVersionBadge } from '../version.js' +import { BaseThunderstoreService, documentation } from './thunderstore-base.js' + +export default class ThunderstoreVersion extends BaseThunderstoreService { + static category = 'version' + + static route = { + base: 'thunderstore/v', + pattern: ':namespace/:packageName', + } + + static examples = [ + { + title: 'Thunderstore Version', + namedParams: { + namespace: 'notnotnotswipez', + packageName: 'MoreCompany', + }, + staticPreview: renderVersionBadge({ version: '1.4.5' }), + documentation, + }, + ] + + static defaultBadgeData = { + label: 'thunderstore', + } + + /** + * @param {object} pkg - Package specifier + * @param {string} pkg.namespace - the package namespace + * @param {string} pkg.packageName - the package name + * @returns {Promise<object>} - Promise containing the rendered badge payload + */ + async handle({ namespace, packageName }) { + const { + latest: { version_number: version }, + } = await this.fetchPackage({ namespace, packageName }) + return renderVersionBadge({ version }) + } +} diff --git a/services/thunderstore/thunderstore-version.tester.js b/services/thunderstore/thunderstore-version.tester.js new file mode 100644 index 0000000000..d3afc05fbc --- /dev/null +++ b/services/thunderstore/thunderstore-version.tester.js @@ -0,0 +1,12 @@ +import { createServiceTester } from '../tester.js' +import { isSemver } from '../test-validators.js' + +export const t = await createServiceTester() + +t.create('Version') + .get('/ebkr/r2modman.json') + .expectBadge({ label: 'thunderstore', message: isSemver }) + +t.create('Version (not found)') + .get('/not-a-namespace/not-a-package-name.json') + .expectBadge({ label: 'thunderstore', message: 'not found', color: 'red' }) -- GitLab