| 1 | package org.isocov.demo; |
| 2 | |
| 3 | import com.sun.net.httpserver.HttpExchange; |
| 4 | import com.sun.net.httpserver.HttpServer; |
| 5 | import org.isocov.core.model.CompactSnapshot; |
| 6 | import org.isocov.core.model.CoverageCodec; |
| 7 | import org.isocov.core.runtime.CoverageRuntime; |
| 8 | import org.isocov.demo.service.FizzBuzzService; |
| 9 | import org.isocov.demo.service.GradeService; |
| 10 | import org.isocov.demo.service.PrimeService; |
| 11 | |
| 12 | import java.io.IOException; |
| 13 | import java.io.OutputStream; |
| 14 | import java.net.InetSocketAddress; |
| 15 | import java.nio.charset.StandardCharsets; |
| 16 | import java.util.Arrays; |
| 17 | import java.util.Collections; |
| 18 | import java.util.List; |
| 19 | import java.util.UUID; |
| 20 | import java.util.concurrent.CopyOnWriteArrayList; |
| 21 | import java.util.concurrent.Executors; |
| 22 | import java.util.concurrent.ScheduledExecutorService; |
| 23 | import java.util.concurrent.TimeUnit; |
| 24 | |
| 25 | /** |
| 26 | * Demo HTTP server showing per-request coverage with isocov. |
| 27 | * |
| 28 | * <p>Coverage flow (fully non-blocking on request thread): |
| 29 | * <ol> |
| 30 | * <li>Request thread: {@code startRequest → handle → endRequest} (auto-enqueues internally)</li> |
| 31 | * <li>Dump thread(s): dequeue → pack to {@link CompactSnapshot} → release boolean[] to pool</li> |
| 32 | * <li>{@code /coverage}: read from accumulated {@link CompactSnapshot} list</li> |
| 33 | * </ol> |
| 34 | */ |
| 35 | public final class DemoServer { |
| 36 | |
| 37 | /** Max snapshots to retain in memory (ring buffer). */ |
| 38 | private static final int MAX_SNAPSHOTS = 100; |
| 39 | |
| 40 | /** Accumulated compact snapshots — written by dump threads, read by /coverage. */ |
| 41 | private static final List<CompactSnapshot> STORE = new CopyOnWriteArrayList<>(); |
| 42 | |
| 43 | public static void main(final String[] args) throws Exception { |
| 44 | final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8080; |
| 45 | |
| 46 | // Periodically drain binary data and decode into compact snapshots |
| 47 | // (dump threads start automatically on first endRequest()) |
| 48 | final ScheduledExecutorService drainer = Executors.newSingleThreadScheduledExecutor(r -> { |
| 49 | final Thread t = new Thread(r, "isocov-drainer"); |
| 50 | t.setDaemon(true); |
| 51 | return t; |
| 52 | }); |
| 53 | drainer.scheduleAtFixedRate(() -> { |
| 54 | final byte[] data = CoverageRuntime.getRequestData(); |
| 55 | if (data.length > 0) { |
| 56 | STORE.addAll(CoverageCodec.decode(data)); |
| 57 | while (STORE.size() > MAX_SNAPSHOTS) { |
| 58 | STORE.remove(0); |
| 59 | } |
| 60 | } |
| 61 | }, 500, 500, TimeUnit.MILLISECONDS); |
| 62 | |
| 63 | final HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); |
| 64 | server.setExecutor(Executors.newFixedThreadPool(4)); |
| 65 | |
| 66 | server.createContext("/api/grade", DemoServer::handleGrade); |
| 67 | server.createContext("/api/fizzbuzz", DemoServer::handleFizzBuzz); |
| 68 | server.createContext("/api/primes", DemoServer::handlePrimes); |
| 69 | server.createContext("/api/isprime", DemoServer::handleIsPrime); |
| 70 | server.createContext("/coverage", DemoServer::handleCoverage); |
| 71 | server.createContext("/", DemoServer::handleIndex); |
| 72 | |
| 73 | server.start(); |
| 74 | System.out.println("[isocov-demo] Listening on http://localhost:" + port); |
| 75 | System.out.println("[isocov-demo] Try:"); |
| 76 | System.out.println(" curl 'localhost:" + port + "/api/grade?score=85'"); |
| 77 | System.out.println(" curl 'localhost:" + port + "/api/fizzbuzz?n=20'"); |
| 78 | System.out.println(" curl 'localhost:" + port + "/api/primes?limit=50'"); |
| 79 | System.out.println(" open http://localhost:" + port + "/coverage"); |
| 80 | } |
| 81 | |
| 82 | // ─── Handlers ───────────────────────────────────────────────────────────── |
| 83 | |
| 84 | private static void handleGrade(final HttpExchange ex) throws IOException { |
| 85 | withCoverage(ex, () -> { |
| 86 | final int score = intParam(ex, "score", 75); |
| 87 | final String g = GradeService.grade(score); |
| 88 | final String c = GradeService.comment(score); |
| 89 | return json("{\"score\":" + score + ",\"grade\":\"" + g + "\",\"comment\":\"" + c + "\"}"); |
| 90 | }); |
| 91 | } |
| 92 | |
| 93 | private static void handleFizzBuzz(final HttpExchange ex) throws IOException { |
| 94 | withCoverage(ex, () -> { |
| 95 | final int n = intParam(ex, "n", 15); |
| 96 | final var list = FizzBuzzService.compute(n); |
| 97 | return json("[" + String.join(",", list.stream().map(s -> "\"" + s + "\"").toList()) + "]"); |
| 98 | }); |
| 99 | } |
| 100 | |
| 101 | private static void handlePrimes(final HttpExchange ex) throws IOException { |
| 102 | withCoverage(ex, () -> { |
| 103 | final int limit = intParam(ex, "limit", 50); |
| 104 | final var primes = PrimeService.sieve(limit); |
| 105 | return json("{\"limit\":" + limit + ",\"count\":" + primes.size() |
| 106 | + ",\"primes\":" + primes + "}"); |
| 107 | }); |
| 108 | } |
| 109 | |
| 110 | private static void handleIsPrime(final HttpExchange ex) throws IOException { |
| 111 | withCoverage(ex, () -> { |
| 112 | final int n = intParam(ex, "n", 17); |
| 113 | return json("{\"n\":" + n + ",\"prime\":" + PrimeService.isPrime(n) + "}"); |
| 114 | }); |
| 115 | } |
| 116 | |
| 117 | private static void handleCoverage(final HttpExchange ex) throws IOException { |
| 118 | final List<CompactSnapshot> all = Collections.unmodifiableList(STORE); |
| 119 | if (all.isEmpty()) { |
| 120 | send(ex, 200, "text/plain", "No coverage yet. Hit an /api/* endpoint first."); |
| 121 | return; |
| 122 | } |
| 123 | try { |
| 124 | final String reqParam = stringParam(ex, "req", "all"); |
| 125 | final int selected; |
| 126 | if ("all".equals(reqParam)) { |
| 127 | selected = -1; |
| 128 | } else { |
| 129 | try { |
| 130 | selected = Integer.parseInt(reqParam); |
| 131 | } catch (final NumberFormatException e) { |
| 132 | send(ex, 400, "text/plain", "Invalid req param: " + reqParam); |
| 133 | return; |
| 134 | } |
| 135 | if (selected < 1 || selected > all.size()) { |
| 136 | send(ex, 400, "text/plain", |
| 137 | "req must be between 1 and " + all.size()); |
| 138 | return; |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // Include background coverage in flat view |
| 143 | CompactSnapshot background = null; |
| 144 | if (selected == -1) { |
| 145 | final byte[] bgData = CoverageRuntime.getBackgroundData(false); |
| 146 | if (bgData.length > 0) { |
| 147 | final List<CompactSnapshot> bgSnaps = CoverageCodec.decode(bgData); |
| 148 | if (!bgSnaps.isEmpty()) { |
| 149 | background = bgSnaps.get(0); |
| 150 | } |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | final String view = stringParam(ex, "view", "coverage"); |
| 155 | final String html = CoverageReporter.toHtml( |
| 156 | all, selected, background, "heatmap".equals(view)); |
| 157 | send(ex, 200, "text/html; charset=UTF-8", html); |
| 158 | } catch (final Exception e) { |
| 159 | send(ex, 500, "text/plain", "Error generating report: " + e.getMessage()); |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | private static void handleIndex(final HttpExchange ex) throws IOException { |
| 164 | send(ex, 200, "text/html", """ |
| 165 | <html><body style="font-family:monospace;padding:20px"> |
| 166 | <h2>isocov demo</h2> |
| 167 | <ul> |
| 168 | <li><a href="/api/grade?score=85">/api/grade?score=85</a></li> |
| 169 | <li><a href="/api/fizzbuzz?n=20">/api/fizzbuzz?n=20</a></li> |
| 170 | <li><a href="/api/primes?limit=50">/api/primes?limit=50</a></li> |
| 171 | <li><a href="/api/isprime?n=17">/api/isprime?n=17</a></li> |
| 172 | <li><a href="/coverage">/coverage</a> ← hit an endpoint first</li> |
| 173 | </ul> |
| 174 | </body></html> |
| 175 | """); |
| 176 | } |
| 177 | |
| 178 | // ─── Coverage wrapper ────────────────────────────────────────────────────── |
| 179 | |
| 180 | @FunctionalInterface |
| 181 | interface Handler { Response handle() throws Exception; } |
| 182 | record Response(String contentType, String body) {} |
| 183 | |
| 184 | private static Response json(final String body) { |
| 185 | return new Response("application/json", body); |
| 186 | } |
| 187 | |
| 188 | /** |
| 189 | * Non-blocking coverage collection: |
| 190 | * startRequest → handle → endRequest (auto-enqueues to dump thread internally) |
| 191 | * Request thread never blocks on coverage processing. |
| 192 | */ |
| 193 | private static void withCoverage(final HttpExchange ex, final Handler handler) |
| 194 | throws IOException { |
| 195 | final String reqId = UUID.randomUUID().toString().substring(0, 8); |
| 196 | CoverageRuntime.startRequest(reqId); |
| 197 | String body; |
| 198 | String contentType; |
| 199 | try { |
| 200 | final Response resp = handler.handle(); |
| 201 | body = resp.body(); |
| 202 | contentType = resp.contentType(); |
| 203 | } catch (final Exception e) { |
| 204 | body = "{\"error\":\"" + e.getMessage() + "\"}"; |
| 205 | contentType = "application/json"; |
| 206 | } finally { |
| 207 | CoverageRuntime.endRequest(); // non-blocking: enqueues to dump thread internally |
| 208 | } |
| 209 | send(ex, 200, contentType, body); |
| 210 | } |
| 211 | |
| 212 | // ─── Helpers ────────────────────────────────────────────────────────────── |
| 213 | |
| 214 | private static String stringParam(final HttpExchange ex, final String key, final String defaultVal) { |
| 215 | final String query = ex.getRequestURI().getQuery(); |
| 216 | if (query == null) { |
| 217 | return defaultVal; |
| 218 | } |
| 219 | return Arrays.stream(query.split("&")) |
| 220 | .filter(p -> p.startsWith(key + "=")) |
| 221 | .map(p -> p.substring(key.length() + 1)) |
| 222 | .findFirst().orElse(defaultVal); |
| 223 | } |
| 224 | |
| 225 | private static int intParam(final HttpExchange ex, final String key, final int defaultVal) { |
| 226 | final String query = ex.getRequestURI().getQuery(); |
| 227 | if (query == null) { |
| 228 | return defaultVal; |
| 229 | } |
| 230 | return Arrays.stream(query.split("&")) |
| 231 | .filter(p -> p.startsWith(key + "=")) |
| 232 | .map(p -> p.substring(key.length() + 1)) |
| 233 | .mapToInt(v -> { |
| 234 | try { |
| 235 | return Integer.parseInt(v); |
| 236 | } catch (Exception e) { |
| 237 | return defaultVal; |
| 238 | } |
| 239 | }) |
| 240 | .findFirst().orElse(defaultVal); |
| 241 | } |
| 242 | |
| 243 | private static void send(final HttpExchange ex, final int status, |
| 244 | final String contentType, final String body) throws IOException { |
| 245 | final byte[] bytes = body.getBytes(StandardCharsets.UTF_8); |
| 246 | ex.getResponseHeaders().set("Content-Type", contentType); |
| 247 | ex.sendResponseHeaders(status, bytes.length); |
| 248 | try (OutputStream os = ex.getResponseBody()) { |
| 249 | os.write(bytes); |
| 250 | } |
| 251 | } |
| 252 | } |