diff --git a/src/main/java/com/mccprogrammer/extpulse/publicpage/Badge.java b/src/main/java/com/mccprogrammer/extpulse/publicpage/Badge.java index b88efcd..5b5f341 100644 --- a/src/main/java/com/mccprogrammer/extpulse/publicpage/Badge.java +++ b/src/main/java/com/mccprogrammer/extpulse/publicpage/Badge.java @@ -10,6 +10,9 @@ final class Badge { private static final int PADDING = 6; + /** The value's color, also given to shields.io for its badges. */ + static final String COLOR = "256abf"; + private Badge() { } @@ -24,10 +27,11 @@ static String svg(String label, String value) { %2$s\ \ \ - \ + \ \ %6$s%8$s""", width, text, - labelWidth, valueWidth, labelWidth / 2.0, escape(label), labelWidth + valueWidth / 2.0, escape(value)); + labelWidth, valueWidth, labelWidth / 2.0, escape(label), labelWidth + valueWidth / 2.0, escape(value), + COLOR); } /** 950, 12.3k, 4.5M: the size of a number at a glance. */ diff --git a/src/main/java/com/mccprogrammer/extpulse/publicpage/PublicPages.java b/src/main/java/com/mccprogrammer/extpulse/publicpage/PublicPages.java index 41d7d2d..0cc65ab 100644 --- a/src/main/java/com/mccprogrammer/extpulse/publicpage/PublicPages.java +++ b/src/main/java/com/mccprogrammer/extpulse/publicpage/PublicPages.java @@ -21,13 +21,20 @@ * What anyone can see without signing in: the home page, and for accounts that chose an * address, a portfolio page and installs badges. Numbers change once a day, so badges may * be cached for an hour (GitHub's image proxy does). + *

+ * Each badge also comes as JSON for shields.io's endpoint badges + * ({@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. */ @Controller class PublicPages { private static final MediaType SVG = MediaType.valueOf("image/svg+xml"); - private static final CacheControl CACHE = CacheControl.maxAge(Duration.ofHours(1)).cachePublic(); + private static final Duration CACHE_SECONDS = Duration.ofHours(1); + + private static final CacheControl CACHE = CacheControl.maxAge(CACHE_SECONDS).cachePublic(); private final PublicProfiles profiles; @@ -62,12 +69,29 @@ ResponseEntity totalBadge(@PathVariable String slug) { /** Installs of one extension, across the stores it is on. */ @GetMapping("/p/{slug}/extensions/{id}/badge.svg") ResponseEntity extensionBadge(@PathVariable String slug, @PathVariable long id) { - ExtensionOverview extension = this.stats.overview(owner(slug)) + return badge(extensionInstalls(slug, id)); + } + + /** {@link #totalBadge} for shields.io. */ + @GetMapping("/p/{slug}/badge.json") + ResponseEntity totalShieldsBadge(@PathVariable String slug) { + long installs = this.stats.overview(owner(slug)).stream().mapToLong(ExtensionOverview::installs).sum(); + return shieldsBadge(installs); + } + + /** {@link #extensionBadge} for shields.io. */ + @GetMapping("/p/{slug}/extensions/{id}/badge.json") + ResponseEntity extensionShieldsBadge(@PathVariable String slug, @PathVariable long id) { + return shieldsBadge(extensionInstalls(slug, id)); + } + + private long extensionInstalls(String slug, long id) { + return this.stats.overview(owner(slug)) .stream() .filter((candidate) -> candidate.id() == id) .findFirst() - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); - return badge(extension.installs()); + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)) + .installs(); } private long owner(String slug) { @@ -81,4 +105,19 @@ private static ResponseEntity badge(long installs) { .body(Badge.svg("installs", Badge.compact(installs))); } + private static ResponseEntity shieldsBadge(long installs) { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .cacheControl(CACHE) + .body(new ShieldsBadge(1, "installs", Badge.compact(installs), Badge.COLOR, + (int) CACHE_SECONDS.toSeconds())); + } + + /** + * shields.io's endpoint schema. {@code cacheSeconds} asks shields.io to keep it as + * long as this site lets anyone cache it. + */ + record ShieldsBadge(int schemaVersion, String label, String message, String color, int cacheSeconds) { + } + } diff --git a/src/main/resources/templates/stats/extension.html b/src/main/resources/templates/stats/extension.html index 3a59565..68f56c4 100644 --- a/src/main/resources/templates/stats/extension.html +++ b/src/main/resources/templates/stats/extension.html @@ -146,6 +146,10 @@

Badge

Installs badge markdown +

For a README published on the VS Code Marketplace, which only shows badges from services it + knows, the same badge through shields.io:

+ markdown

Badges need a public page. Choose its address in Settings.

diff --git a/src/test/java/com/mccprogrammer/extpulse/publicpage/PublicPagesTests.java b/src/test/java/com/mccprogrammer/extpulse/publicpage/PublicPagesTests.java index fb4b349..03b1970 100644 --- a/src/test/java/com/mccprogrammer/extpulse/publicpage/PublicPagesTests.java +++ b/src/test/java/com/mccprogrammer/extpulse/publicpage/PublicPagesTests.java @@ -25,6 +25,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** @@ -101,6 +102,26 @@ void badgesAreSvgThatMayBeCachedForAnHour() throws Exception { .andExpect(content().string(containsString(">12.3k<"))); } + @Test + void badgesAlsoComeAsJsonForShieldsIo() throws Exception { + choose("mccprogrammer"); + + this.mvc.perform(get("/p/mccprogrammer/badge.json")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Type", "application/json")) + .andExpect(header().string("Cache-Control", "max-age=3600, public")) + .andExpect(jsonPath("$.schemaVersion").value(1)) + .andExpect(jsonPath("$.label").value("installs")) + .andExpect(jsonPath("$.message").value("12.3k")) + .andExpect(jsonPath("$.color").value("256abf")) + .andExpect(jsonPath("$.cacheSeconds").value(3600)); + this.mvc.perform(get("/p/mccprogrammer/extensions/{id}/badge.json", this.extension)) + .andExpect(jsonPath("$.message").value("12.3k")); + this.mvc.perform(get("/p/mccprogrammer/extensions/{id}/badge.json", this.extension + 1000)) + .andExpect(status().isNotFound()); + this.mvc.perform(get("/p/nobody/badge.json")).andExpect(status().isNotFound()); + } + @Test void anotherAccountsExtensionHasNoBadgeOnThisPage() throws Exception { choose("mccprogrammer"); diff --git a/src/test/java/com/mccprogrammer/extpulse/stats/DashboardPagesTests.java b/src/test/java/com/mccprogrammer/extpulse/stats/DashboardPagesTests.java index bc13c95..b1a5b0e 100644 --- a/src/test/java/com/mccprogrammer/extpulse/stats/DashboardPagesTests.java +++ b/src/test/java/com/mccprogrammer/extpulse/stats/DashboardPagesTests.java @@ -88,6 +88,18 @@ void theExtensionPageDrawsBothCharts() throws Exception { containsString(": 1,100 installs (+10)"), containsString("Choose its address in Settings")))); } + @Test + void withAPublicPageTheExtensionPageGivesBothBadgeLines() throws Exception { + this.jdbc.sql("UPDATE users SET public_slug = 'mccprogrammer'").update(); + + this.mvc.perform(get("/app/extensions/{id}", this.extension).session(this.session)) + .andExpect(content() + .string(allOf(containsString("/p/mccprogrammer/extensions/" + this.extension + "/badge.svg)"), + containsString( + "https://img.shields.io/endpoint?url=http://localhost/p/mccprogrammer/extensions/" + + this.extension + "/badge.json)")))); + } + @Test void onlyTheOfferedRangesAreUsed() throws Exception { this.mvc.perform(get("/app/extensions/{id}", this.extension).param("days", "90").session(this.session))