From 015818736417e3f84d0490ad3e4a7ab94aa27be3 Mon Sep 17 00:00:00 2001 From: MCCarmo Date: Thu, 29 Oct 2026 21:20:42 +0000 Subject: [PATCH] Public installs as JSON that any website can read --- README.md | 3 ++- .../extpulse/publicpage/PublicPages.java | 27 +++++++++++++++++++ .../extpulse/publicpage/PublicPagesTests.java | 16 +++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) 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");