diff --git a/services/thunderstore/thunderstore-base.js b/services/thunderstore/thunderstore-base.js new file mode 100644 index 0000000000000000000000000000000000000000..d4b2d7ad136eedf6f1bdd42915449cb85b1f7ee8 --- /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 0000000000000000000000000000000000000000..b642f925eccc3ed7560fd114bc1da6739df26c9b --- /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 0000000000000000000000000000000000000000..7649798a4aef28b48276bdede6898b4364526656 --- /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 0000000000000000000000000000000000000000..4304c308b992062b48710ffe9685820d57c4e034 --- /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 0000000000000000000000000000000000000000..65af0a50144fbaf316a94a586a9a1c49907b7c31 --- /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 0000000000000000000000000000000000000000..6618cf26b35718d843017d05dd06ee98723bf837 --- /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 0000000000000000000000000000000000000000..d3afc05fbc32fdb06451876145f70c409c4e0f77 --- /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' })