Repository: badges/shields (js)

Issue:
Open VSX Registry Badge
:clipboard: **Description**

<!--
A clear and concise description of the new badge.

- Which service is this badge for e.g: GitHub, Travis CI
- What sort of information should this badge show?
  Provide an example in plain text e.g: "version | v1.01" or as a static badge
  (static badge generator can be found at https://shields.io)
-->
The [Open VSX Registry](https://open-vsx.org/) just launched as an alternative for microsoft's proprietary [Visual Studio Marketplace](https://marketplace.visualstudio.com/)

The badges should show information similar to the already existing VS Code Marketplace badges.

:link: **Data**

<!--
Where can we get the data from?

- Is there a public API?
- Does the API requires an API key?
- Link to the API documentation.
-->
I could not find any official API documents. However, the registry is open source and can be found [here](https://github.com/eclipse/openvsx). [This file](https://github.com/eclipse/openvsx/blob/master/server/src/main/java/org/eclipse/openvsx/RegistryAPI.java) could be a good entry point for reverse engineering the API.


:microphone: **Motivation**

<!--
Please explain why this feature should be implemented and how it would be used.

- What is the specific use case?
-->
Since extension developers love to include status badges of their extensions in the extensions' README files to indicate where it is published, its rating and version, it would be great to enable badges for the new registry as well.

<!-- Love Shields? Please consider donating $10 to sustain our activities:
👉  https://opencollective.com/shields -->


Pull request description:
Add [OpenVSX] registry badges
Closes #5218 

Patch:
diff --git a/services/open-vsx/open-vsx-base.js b/services/open-vsx/open-vsx-base.js
new file mode 100644
index 0000000000..7d03eea052
--- /dev/null
+++ b/services/open-vsx/open-vsx-base.js
@@ -0,0 +1,62 @@
+'use strict'
+
+const Joi = require('joi')
+const { optionalNonNegativeInteger } = require('../validators')
+const { BaseJsonService, NotFound } = require('..')
+
+const extensionQuerySchema = Joi.object({
+  error: Joi.string(),
+  version: Joi.string().when('error', {
+    is: Joi.exist(),
+    then: Joi.optional(),
+    otherwise: Joi.required(),
+  }),
+  timestamp: Joi.string().isoDate().when('error', {
+    is: Joi.exist(),
+    then: Joi.optional(),
+    otherwise: Joi.required(),
+  }),
+  downloadCount: optionalNonNegativeInteger,
+  reviewCount: optionalNonNegativeInteger,
+  averageRating: Joi.number().when('reviewCount', {
+    is: Joi.exist(),
+    then: Joi.number().max(5),
+    otherwise: Joi.optional(),
+  }),
+}).required()
+
+module.exports = class OpenVSXBase extends BaseJsonService {
+  static keywords = [
+    'ovsx',
+    'open-vsx',
+    'ovsx-marketplace',
+    'open-vsx-marketplace',
+  ]
+
+  static defaultBadgeData = {
+    label: 'open vsx',
+    color: 'blue',
+  }
+
+  async fetch({ namespace, extension, version }) {
+    return this._requestJson({
+      schema: extensionQuerySchema,
+      url: `https://open-vsx.org/api/${namespace}/${extension}/${
+        version || ''
+      }`,
+      errorMessages: {
+        400: 'invalid extension id',
+      },
+    })
+  }
+
+  transform({ json }) {
+    const { error, version } = json
+    if (error || !version) {
+      throw new NotFound({
+        prettyMessage: 'extension not found',
+      })
+    }
+    return json
+  }
+}
diff --git a/services/open-vsx/open-vsx-downloads.service.js b/services/open-vsx/open-vsx-downloads.service.js
new file mode 100644
index 0000000000..638e125a8b
--- /dev/null
+++ b/services/open-vsx/open-vsx-downloads.service.js
@@ -0,0 +1,58 @@
+'use strict'
+
+const { metric } = require('../text-formatters')
+const { downloadCount } = require('../color-formatters')
+const OpenVSXBase = require('./open-vsx-base')
+
+module.exports = class OpenVSXDownloads extends OpenVSXBase {
+  static category = 'downloads'
+
+  static route = {
+    base: 'open-vsx',
+    pattern: ':interval(dt)/:namespace/:extension/:version*',
+  }
+
+  static examples = [
+    {
+      title: 'Open VSX Downloads',
+      pattern: 'dt/:namespace/:extension',
+      namedParams: {
+        namespace: 'redhat',
+        extension: 'java',
+      },
+      staticPreview: this.render({ downloads: 29000 }),
+      keywords: this.keywords,
+    },
+    {
+      title: 'Open VSX Downloads (version)',
+      pattern: 'dt/:namespace/:extension/:version',
+      namedParams: {
+        namespace: 'redhat',
+        extension: 'java',
+        version: '0.69.0',
+      },
+      staticPreview: this.render({ version: '0.69.0', downloads: 29000 }),
+      keywords: this.keywords,
+    },
+  ]
+
+  static defaultBadgeData = { label: 'downloads' }
+
+  static render({ version, downloads }) {
+    return {
+      label: version ? `downloads@${version}` : 'downloads',
+      message: metric(downloads),
+      color: downloadCount(downloads),
+    }
+  }
+
+  async handle({ namespace, extension, version }) {
+    const json = await this.fetch({ namespace, extension, version })
+    const { version: tag, downloadCount } = this.transform({ json })
+
+    return this.constructor.render({
+      version: version ? tag : undefined,
+      downloads: downloadCount,
+    })
+  }
+}
diff --git a/services/open-vsx/open-vsx-rating.service.js b/services/open-vsx/open-vsx-rating.service.js
new file mode 100644
index 0000000000..6970af4ff6
--- /dev/null
+++ b/services/open-vsx/open-vsx-rating.service.js
@@ -0,0 +1,79 @@
+'use strict'
+
+const { starRating } = require('../text-formatters')
+const { floorCount } = require('../color-formatters')
+const OpenVSXBase = require('./open-vsx-base')
+
+module.exports = class OpenVSXRating extends OpenVSXBase {
+  static category = 'rating'
+
+  static route = {
+    base: 'open-vsx',
+    pattern: ':format(rating|stars)/:namespace/:extension',
+  }
+
+  static examples = [
+    {
+      title: 'Open VSX Rating',
+      pattern: 'rating/:namespace/:extension',
+      namedParams: {
+        namespace: 'redhat',
+        extension: 'java',
+      },
+      staticPreview: this.render({
+        format: 'rating',
+        averageRating: 5,
+        ratingCount: 2,
+      }),
+      keywords: this.keywords,
+    },
+    {
+      title: 'Open VSX Rating (Stars)',
+      pattern: 'stars/:namespace/:extension',
+      namedParams: {
+        namespace: 'redhat',
+        extension: 'java',
+      },
+      staticPreview: this.render({
+        format: 'stars',
+        averageRating: 5,
+        ratingCount: 2,
+      }),
+      keywords: this.keywords,
+    },
+  ]
+
+  static defaultBadgeData = {
+    label: 'rating',
+  }
+
+  static render({ format, averageRating, ratingCount }) {
+    if (ratingCount === 0) {
+      return {
+        message: 'unrated',
+        color: 'lightgrey',
+      }
+    }
+
+    const message =
+      format === 'rating'
+        ? `${averageRating.toFixed(1)}/5 (${ratingCount})`
+        : starRating(averageRating)
+
+    return {
+      message,
+      color: floorCount(averageRating, 2, 3, 4),
+    }
+  }
+
+  async handle({ format, namespace, extension }) {
+    const json = await this.fetch({ namespace, extension })
+    const { averageRating, reviewCount } = this.transform({ json })
+
+    return this.constructor.render({
+      format,
+      averageRating,
+      ratingCount: reviewCount,
+    })
+  }
+}
diff --git a/services/open-vsx/open-vsx-rating.spec.js b/services/open-vsx/open-vsx-rating.spec.js
new file mode 100644
index 0000000000..9d0bf7f0ca
--- /dev/null
+++ b/services/open-vsx/open-vsx-rating.spec.js
@@ -0,0 +1,13 @@
+'use strict'
+
+const { test, given } = require('sazerac')
+const OpenVSXRating = require('./open-vsx-rating.service')
+
+describe('OpenVSXRating', function () {
+  test(OpenVSXRating.render, () => {
+    given({ ratingCount: 0 }).expect({
+      message: 'unrated',
+      color: 'lightgrey',
+    })
+  })
+})
diff --git a/services/open-vsx/open-vsx-release-date.service.js b/services/open-vsx/open-vsx-release-date.service.js
new file mode 100644
index 0000000000..11cd175d0b
--- /dev/null
+++ b/services/open-vsx/open-vsx-release-date.service.js
@@ -0,0 +1,46 @@
+'use strict'
+
+const { age } = require('../color-formatters')
+const { formatDate } = require('../text-formatters')
+const OpenVSXBase = require('./open-vsx-base')
+
+module.exports = class OpenVSXReleaseDate extends OpenVSXBase {
+  static category = 'activity'
+
+  static route = {
+    base: 'open-vsx',
+    pattern: 'release-date/:namespace/:extension',
+  }
+
+  static examples = [
+    {
+      title: 'Open VSX Release Date',
+      namedParams: {
+        namespace: 'redhat',
+        extension: 'java',
+      },
+      staticPreview: this.render({
+        releaseDate: '2020-10-15T13:40:16.986723Z',
+      }),
+      keywords: this.keywords,
+    },
+  ]
+
+  static defaultBadgeData = { label: 'release date' }
+
+  static render({ releaseDate }) {
+    return {
+      message: formatDate(releaseDate),
+      color: age(releaseDate),
+    }
+  }
+
+  async handle({ namespace, extension }) {
+    const json = await this.fetch({ namespace, extension })
+    const { timestamp } = this.transform({ json })
+
+    return this.constructor.render({
+      releaseDate: timestamp,
+    })
+  }
+}
diff --git a/services/open-vsx/open-vsx-version.service.js b/services/open-vsx/open-vsx-version.service.js
new file mode 100644
index 0000000000..36fc52b169
--- /dev/null
+++ b/services/open-vsx/open-vsx-version.service.js
@@ -0,0 +1,33 @@
+'use strict'
+
+const { renderVersionBadge } = require('../version')
+const OpenVSXBase = require('./open-vsx-base')
+
+module.exports = class OpenVSXVersion extends OpenVSXBase {
+  static category = 'version'
+
+  static route = {
+    base: 'open-vsx',
+    pattern: 'v/:namespace/:extension',
+  }
+
+  static examples = [
+    {
+      title: 'Open VSX Version',
+      namedParams: { namespace: 'redhat', extension: 'java' },
+      staticPreview: this.render({ version: '0.69.0' }),
+      keywords: this.keywords,
+    },
+  ]
+
+  static render({ version }) {
+    return renderVersionBadge({ version })
+  }
+
+  async handle({ namespace, extension }) {
+    const json = await this.fetch({ namespace, extension })
+    const { version } = this.transform({ json })
+
+    return this.constructor.render({ version })
+  }
+}


Test patch:
diff --git a/services/open-vsx/open-vsx-downloads.tester.js b/services/open-vsx/open-vsx-downloads.tester.js
new file mode 100644
index 0000000000..7e6797386a
--- /dev/null
+++ b/services/open-vsx/open-vsx-downloads.tester.js
@@ -0,0 +1,23 @@
+'use strict'
+
+const t = (module.exports = require('../tester').createServiceTester())
+const { withRegex, isMetric } = require('../test-validators')
+
+const isVersionLabel = withRegex(/^downloads@(\d+\.\d+\.\d+)(\.\d+)?$/)
+
+t.create('downloads invalid extension')
+  .get('/dt/badges/shields.json')
+  .expectBadge({
+    label: 'downloads',
+    message: 'extension not found',
+  })
+
+t.create('downloads').get('/dt/redhat/java.json').expectBadge({
+  label: 'downloads',
+  message: isMetric,
+})
+
+t.create('downloads version').get('/dt/redhat/java/latest.json').expectBadge({
+  label: isVersionLabel,
+  message: isMetric,
+})
diff --git a/services/open-vsx/open-vsx-rating.tester.js b/services/open-vsx/open-vsx-rating.tester.js
new file mode 100644
index 0000000000..71b269ebb7
--- /dev/null
+++ b/services/open-vsx/open-vsx-rating.tester.js
@@ -0,0 +1,30 @@
+'use strict'
+
+const t = (module.exports = require('../tester').createServiceTester())
+const { withRegex, isStarRating } = require('../test-validators')
+
+const isVscodeRating = withRegex(/[0-5]\.[0-9]{1}\/5?\s*\([0-9]*\)$/)
+
+t.create('rating invalid extension')
+  .get('/rating/badges/shields.json')
+  .expectBadge({
+    label: 'rating',
+    message: 'extension not found',
+  })
+
+t.create('rating').get('/rating/redhat/java.json').expectBadge({
+  label: 'rating',
+  message: isVscodeRating,
+})
+
+t.create('stars invalid extension')
+  .get('/stars/badges/shields.json')
+  .expectBadge({
+    label: 'rating',
+    message: 'extension not found',
+  })
+
+t.create('stars').get('/stars/redhat/java.json').expectBadge({
+  label: 'rating',
+  message: isStarRating,
+})
diff --git a/services/open-vsx/open-vsx-release-date.tester.js b/services/open-vsx/open-vsx-release-date.tester.js
new file mode 100644
index 0000000000..5c5e2d111c
--- /dev/null
+++ b/services/open-vsx/open-vsx-release-date.tester.js
@@ -0,0 +1,16 @@
+'use strict'
+
+const t = (module.exports = require('../tester').createServiceTester())
+const { isFormattedDate } = require('../test-validators')
+
+t.create('release date invalid extension')
+  .get('/release-date/badges/shields.json')
+  .expectBadge({
+    label: 'release date',
+    message: 'extension not found',
+  })
+
+t.create('release date').get('/release-date/redhat/java.json').expectBadge({
+  label: 'release date',
+  message: isFormattedDate,
+})
diff --git a/services/open-vsx/open-vsx-version.tester.js b/services/open-vsx/open-vsx-version.tester.js
new file mode 100644
index 0000000000..3a0a5e3e73
--- /dev/null
+++ b/services/open-vsx/open-vsx-version.tester.js
@@ -0,0 +1,18 @@
+'use strict'
+
+const t = (module.exports = require('../tester').createServiceTester())
+const { withRegex } = require('../test-validators')
+
+const isVersion = withRegex(/^v(\d+\.\d+\.\d+)(\.\d+)?$/)
+
+t.create('version invalid extension')
+  .get('/v/badges/shields.json')
+  .expectBadge({
+    label: 'open vsx',
+    message: 'extension not found',
+  })
+
+t.create('version').get('/v/redhat/java.json').expectBadge({
+  label: 'open vsx',
+  message: isVersion,
+})