diff --git a/README.md b/README.md
index ada0e89..61fe440 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,8 @@ snapshot per extension, per store, per day, and builds stats and alerts on top.
- **One inbox for the reviews of every store**, with unread and unanswered filters.
- **Alerts** by email or Discord: low ratings, install spikes, new versions, a failing
collection and a weekly summary, delivered through an outbox with retries.
-- **A public portfolio page** (opt-in) and SVG installs badges for READMEs.
+- **A public portfolio page** (opt-in), SVG installs badges for READMEs, and the exact
+ numbers as JSON (`/p/
/installs.json`) that any website can read.
## Architecture
diff --git a/src/main/java/com/mccprogrammer/extpulse/publicpage/PublicPages.java b/src/main/java/com/mccprogrammer/extpulse/publicpage/PublicPages.java
index 0cc65ab..9a2a62b 100644
--- a/src/main/java/com/mccprogrammer/extpulse/publicpage/PublicPages.java
+++ b/src/main/java/com/mccprogrammer/extpulse/publicpage/PublicPages.java
@@ -8,6 +8,7 @@
import com.mccprogrammer.extpulse.stats.StatsService.ExtensionOverview;
import org.springframework.http.CacheControl;
+import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -26,6 +27,9 @@
* ({@code https://img.shields.io/endpoint?url=...}). The VS Code Marketplace only shows
* badges from services it knows, shields.io among them, so that is the one to put in an
* extension's README.
+ *
+ * The exact numbers also come as JSON that any website may read from the browser, so a
+ * portfolio can show live installs in its own style.
*/
@Controller
class PublicPages {
@@ -36,6 +40,9 @@ class PublicPages {
private static final CacheControl CACHE = CacheControl.maxAge(CACHE_SECONDS).cachePublic();
+ // Public, read-only and without cookies, so any origin may read it.
+ private static final String ANY_ORIGIN = "*";
+
private final PublicProfiles profiles;
private final StatsService stats;
@@ -85,6 +92,20 @@ ResponseEntity extensionShieldsBadge(@PathVariable String slug, @P
return shieldsBadge(extensionInstalls(slug, id));
}
+ /** Installs of the page's extensions and their total, as exact numbers. */
+ @GetMapping("/p/{slug}/installs.json")
+ ResponseEntity installs(@PathVariable String slug) {
+ List extensions = this.stats.overview(owner(slug));
+ List each = extensions.stream()
+ .map((extension) -> new ExtensionInstalls(extension.id(), extension.displayName(), extension.installs()))
+ .toList();
+ return ResponseEntity.ok()
+ .contentType(MediaType.APPLICATION_JSON)
+ .cacheControl(CACHE)
+ .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, ANY_ORIGIN)
+ .body(new PublicInstalls(each.stream().mapToLong(ExtensionInstalls::installs).sum(), each));
+ }
+
private long extensionInstalls(String slug, long id) {
return this.stats.overview(owner(slug))
.stream()
@@ -120,4 +141,10 @@ private static ResponseEntity shieldsBadge(long installs) {
record ShieldsBadge(int schemaVersion, String label, String message, String color, int cacheSeconds) {
}
+ record PublicInstalls(long installs, List extensions) {
+ }
+
+ record ExtensionInstalls(long id, String name, long installs) {
+ }
+
}
diff --git a/src/test/java/com/mccprogrammer/extpulse/publicpage/PublicPagesTests.java b/src/test/java/com/mccprogrammer/extpulse/publicpage/PublicPagesTests.java
index 03b1970..e77a068 100644
--- a/src/test/java/com/mccprogrammer/extpulse/publicpage/PublicPagesTests.java
+++ b/src/test/java/com/mccprogrammer/extpulse/publicpage/PublicPagesTests.java
@@ -122,6 +122,22 @@ void badgesAlsoComeAsJsonForShieldsIo() throws Exception {
this.mvc.perform(get("/p/nobody/badge.json")).andExpect(status().isNotFound());
}
+ @Test
+ void exactInstallsComeAsJsonThatAnyWebsiteMayRead() throws Exception {
+ this.mvc.perform(get("/p/mccprogrammer/installs.json")).andExpect(status().isNotFound());
+ choose("mccprogrammer");
+
+ this.mvc.perform(get("/p/mccprogrammer/installs.json"))
+ .andExpect(status().isOk())
+ .andExpect(header().string("Access-Control-Allow-Origin", "*"))
+ .andExpect(header().string("Cache-Control", "max-age=3600, public"))
+ .andExpect(jsonPath("$.installs").value(12_345))
+ .andExpect(jsonPath("$.extensions[0].id").value(this.extension))
+ .andExpect(jsonPath("$.extensions[0].name").value("Diff Checker"))
+ .andExpect(jsonPath("$.extensions[0].installs").value(12_345))
+ .andExpect(content().string(not(containsString("miguel@example.com"))));
+ }
+
@Test
void anotherAccountsExtensionHasNoBadgeOnThisPage() throws Exception {
choose("mccprogrammer");