diff --git a/backend/src/main/java/com/wikipediafinder/backend/BFS.java b/backend/src/main/java/com/wikipediafinder/backend/BFS.java index c4f2db1..235af65 100644 --- a/backend/src/main/java/com/wikipediafinder/backend/BFS.java +++ b/backend/src/main/java/com/wikipediafinder/backend/BFS.java @@ -2,6 +2,7 @@ import com.wikipediafinder.backend.interfaces.BFSInterface; import java.util.*; +import java.util.function.Consumer; import java.util.function.Function; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; @@ -33,7 +34,7 @@ public class BFS implements BFSInterface { * @throws IllegalArgumentException if {@code start} or {@code end} is null */ @Override - @Cacheable(value = "pathCache", key = "#start.URL + '->' + #end.URL") + @Cacheable(value = "pathCache", key = "#start.getURL() + '->' + #end.getURL()") public List getPath(PageNode start, PageNode end) { return getPath(start, end, DEFAULT_FACTORY); } @@ -117,14 +118,23 @@ public List getPath( * @throws IllegalArgumentException if {@code start} or {@code end} is null */ @Override - @Cacheable(value = "pathStatsCache", key = "#start.URL + '->' + #end.URL") + @Cacheable(value = "pathStatsCache", key = "#start.getURL() + '->' + #end.getURL()") public BFSResult getPathWithStats(PageNode start, PageNode end) { - return getPathWithStats(start, end, DEFAULT_FACTORY); + return getPathWithStats(start, end, DEFAULT_FACTORY, null); } @Override public BFSResult getPathWithStats( PageNode start, PageNode end, Function nodeFactory) { + return getPathWithStats(start, end, nodeFactory, null); + } + + @Override + public BFSResult getPathWithStats( + PageNode start, + PageNode end, + Function nodeFactory, + Consumer progressCallback) { if (start == null || end == null) { throw new IllegalArgumentException("Start and end nodes cannot be null."); } @@ -143,6 +153,9 @@ public BFSResult getPathWithStats( while (!queue.isEmpty() && nodeCnt < 1000) { String currentUrl = queue.poll(); nodeCnt++; + if (progressCallback != null) { + progressCallback.accept(nodeCnt); + } Set neighbors = linkCache.get(currentUrl); if (neighbors == null) { PageNode node = nodeFactory.apply(currentUrl); diff --git a/backend/src/main/java/com/wikipediafinder/backend/controller/MyController.java b/backend/src/main/java/com/wikipediafinder/backend/controller/MyController.java index 4b476f2..5c29f2b 100644 --- a/backend/src/main/java/com/wikipediafinder/backend/controller/MyController.java +++ b/backend/src/main/java/com/wikipediafinder/backend/controller/MyController.java @@ -1,16 +1,27 @@ package com.wikipediafinder.backend.controller; +import com.fasterxml.jackson.databind.ObjectMapper; import com.wikipediafinder.backend.BFS; import com.wikipediafinder.backend.BFSResult; import com.wikipediafinder.backend.PageNode; +import jakarta.annotation.PreDestroy; +import java.io.IOException; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; /** * REST controller that exposes the API endpoints for the Wikipedia path finder. The controller is @@ -20,10 +31,29 @@ @RequestMapping("/api") public class MyController { + private static final String WIKI_URL_PREFIX = "https://en.wikipedia.org/wiki/"; + private final BFS bfs; + private final CacheManager cacheManager; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final ExecutorService executor = Executors.newCachedThreadPool(); - public MyController(BFS bfs) { + public MyController(BFS bfs, CacheManager cacheManager) { this.bfs = bfs; + this.cacheManager = cacheManager; + } + + @PreDestroy + public void shutdown() { + executor.shutdown(); + try { + if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } } @GetMapping("/health") @@ -36,7 +66,10 @@ public ResponseEntity hello() { public ResponseEntity getResults( @RequestParam String startinglink, @RequestParam String endinglink) { try { - BFSResult result = bfs.getPathWithStats(new PageNode(startinglink), new PageNode(endinglink)); + String normalizedStart = normalizeWikipediaUrl(startinglink); + String normalizedEnd = normalizeWikipediaUrl(endinglink); + BFSResult result = + bfs.getPathWithStats(new PageNode(normalizedStart), new PageNode(normalizedEnd)); if (result.getPath() == null) { return new ResponseEntity<>( @@ -48,4 +81,136 @@ public ResponseEntity getResults( return new ResponseEntity<>(Map.of("error", e.getMessage()), HttpStatus.BAD_REQUEST); } } + + /** + * Streaming endpoint that runs BFS and emits Server-Sent Events so the client can observe + * real-time progress. Events: + * + *
    + *
  • {@code progress} – {@code {"nodesExplored": N}} emitted after each node is explored + *
  • {@code result} – final path payload (same shape as {@code /getResults}) + *
  • {@code error} – {@code {"error": "message"}} on bad input + *
+ */ + @CrossOrigin(origins = {"http://localhost:5173", "https://wikipedia-path-finder.vercel.app"}) + @GetMapping(value = "/getResultsStream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter getResultsStream( + @RequestParam String startinglink, @RequestParam String endinglink) { + SseEmitter emitter = new SseEmitter(120_000L); + + executor.execute( + () -> { + final AtomicBoolean clientDisconnected = new AtomicBoolean(false); + try { + String normalizedStart = normalizeWikipediaUrl(startinglink); + String normalizedEnd = normalizeWikipediaUrl(endinglink); + String cacheKey = normalizedStart + "->" + normalizedEnd; + Cache cache = cacheManager.getCache("pathStatsCache"); + if (cache != null) { + BFSResult cachedResult = cache.get(cacheKey, BFSResult.class); + if (cachedResult != null) { + emitter.send( + SseEmitter.event() + .name("progress") + .data(Map.of("nodesExplored", cachedResult.getNodesExplored()))); + sendResult(emitter, cachedResult); + emitter.complete(); + return; + } + } + + PageNode start = new PageNode(normalizedStart); + PageNode end = new PageNode(normalizedEnd); + + BFSResult result = + bfs.getPathWithStats( + start, + end, + PageNode::new, + nodeCount -> { + if (clientDisconnected.get()) { + // Signal BFS to stop by throwing an unchecked exception that + // propagates out of the lambda and terminates the BFS loop. + throw new ClientDisconnectedException(); + } + try { + emitter.send( + SseEmitter.event() + .name("progress") + .data(Map.of("nodesExplored", nodeCount))); + } catch (IOException ignored) { + // Client disconnected; mark flag so next callback iteration stops BFS. + clientDisconnected.set(true); + } + }); + + if (cache != null) { + cache.put(cacheKey, result); + } + sendResult(emitter, result); + emitter.complete(); + } catch (ClientDisconnectedException e) { + // Client disconnected mid-BFS; just complete the emitter silently. + emitter.complete(); + } catch (IllegalArgumentException e) { + try { + emitter.send(SseEmitter.event().name("error").data(Map.of("error", e.getMessage()))); + } catch (IOException ignored) { + // ignore + } + emitter.complete(); + } catch (IOException e) { + emitter.completeWithError(e); + } + }); + + return emitter; + } + + private void sendResult(SseEmitter emitter, BFSResult result) throws IOException { + if (result.getPath() == null) { + emitter.send( + SseEmitter.event() + .name("result") + .data( + Map.of( + "message", + "No path found or query took too long", + "nodesExplored", + result.getNodesExplored()))); + } else { + emitter.send(SseEmitter.event().name("result").data(objectMapper.writeValueAsString(result))); + } + } + + private String normalizeWikipediaUrl(String input) { + if (input == null) { + return null; + } + String trimmed = input.trim(); + if (trimmed.isEmpty()) { + return trimmed; + } + String sanitized = trimmed.replace(" ", "_"); + if (sanitized.startsWith("http://")) { + return "https://" + sanitized.substring("http://".length()); + } + if (sanitized.startsWith("https://")) { + return sanitized; + } + if (sanitized.startsWith("en.wikipedia.org/wiki/")) { + return "https://" + sanitized; + } + if (sanitized.startsWith("/wiki/")) { + return "https://en.wikipedia.org" + sanitized; + } + return WIKI_URL_PREFIX + sanitized; + } + + /** Thrown inside the BFS progress callback to abort BFS when the client has disconnected. */ + private static final class ClientDisconnectedException extends RuntimeException { + ClientDisconnectedException() { + super(null, null, true, false); // Suppress stack-trace filling for performance. + } + } } diff --git a/backend/src/main/java/com/wikipediafinder/backend/interfaces/BFSInterface.java b/backend/src/main/java/com/wikipediafinder/backend/interfaces/BFSInterface.java index 838dc50..da3c47e 100644 --- a/backend/src/main/java/com/wikipediafinder/backend/interfaces/BFSInterface.java +++ b/backend/src/main/java/com/wikipediafinder/backend/interfaces/BFSInterface.java @@ -3,6 +3,7 @@ import com.wikipediafinder.backend.BFSResult; import com.wikipediafinder.backend.PageNode; import java.util.List; +import java.util.function.Consumer; import java.util.function.Function; /** @@ -23,4 +24,17 @@ public interface BFSInterface { /** Find a path and return a {@link BFSResult} with statistics using provided factory. */ BFSResult getPathWithStats(PageNode start, PageNode end, Function nodeFactory); + + /** + * Find a path and return a {@link BFSResult} with real-time progress reporting. + * + *

The {@code progressCallback} is invoked after each node is explored, passing the current + * count of explored nodes. Use this overload when real-time progress tracking is required (e.g. + * Server-Sent Events). This overload does NOT use the Spring cache. + */ + BFSResult getPathWithStats( + PageNode start, + PageNode end, + Function nodeFactory, + Consumer progressCallback); } diff --git a/backend/src/test/java/com/wikipediafinder/backend/controller/MyControllerTest.java b/backend/src/test/java/com/wikipediafinder/backend/controller/MyControllerTest.java index 163a7e3..36c18eb 100644 --- a/backend/src/test/java/com/wikipediafinder/backend/controller/MyControllerTest.java +++ b/backend/src/test/java/com/wikipediafinder/backend/controller/MyControllerTest.java @@ -15,6 +15,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.cache.CacheManager; import org.springframework.test.web.servlet.MockMvc; /** @@ -28,6 +29,8 @@ public class MyControllerTest { @MockBean private BFS bfs; + @MockBean private CacheManager cacheManager; + @Test public void healthEndpointReturnsOk() throws Exception { mockMvc diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3a83fd..26ca6a5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,4 @@ import { useState } from "react"; -import axios from "axios"; import { Analytics } from '@vercel/analytics/react'; import { ResultsList } from "./components/ResultsList"; @@ -9,65 +8,116 @@ function App() { const [endingLink, setEndingLink] = useState(""); const [isLoading, setIsLoading] = useState(false); const [nodesExplored, setNodesExplored] = useState(0); + const [errorMessage, setErrorMessage] = useState(""); const currentYear = new Date().getFullYear(); const copyrightText = `© ${currentYear} All rights reserved`; - const fetchResults = async () => { - const endpoints = [ - "https://wikipediafinder.onrender.com/api/getResults", - "http://localhost:8080/api/getResults" + const buildWikiUrl = (topic: string) => { + const trimmed = topic.trim(); + if (!trimmed) { + return ""; + } + const httpPrefix = "http://"; + const httpsPrefix = "https://"; + const sanitized = trimmed.replace(/\s+/g, "_"); + if (sanitized.startsWith(httpPrefix)) { + return `${httpsPrefix}${sanitized.slice(httpPrefix.length)}`; + } + if (sanitized.startsWith(httpsPrefix)) { + return sanitized; + } + if (sanitized.startsWith("en.wikipedia.org/wiki/")) { + return `${httpsPrefix}${sanitized}`; + } + if (sanitized.startsWith("/wiki/")) { + return `https://en.wikipedia.org${sanitized}`; + } + return `https://en.wikipedia.org/wiki/${sanitized}`; + }; + + const fetchResults = () => { + const streamEndpoints = [ + "https://wikipediafinder.onrender.com/api/getResultsStream", + "http://localhost:8080/api/getResultsStream", ]; - let lastError = null; + const params = new URLSearchParams({ + startinglink: buildWikiUrl(startingLink), + endinglink: buildWikiUrl(endingLink), + }); + setIsLoading(true); + setResults([]); setNodesExplored(0); - for (const endpoint of endpoints) { - try { - const response = await axios.get(endpoint, { - params: { - startinglink: `https://en.wikipedia.org/wiki/${startingLink.replace(/\s+/g, "_").replace(/\b\w/g, char => char.toUpperCase())}`, - endinglink: `https://en.wikipedia.org/wiki/${endingLink.replace(/\s+/g, "_").replace(/\b\w/g, char => char.toUpperCase())}`, - }, - timeout: 60000, - }); - if (response.data.nodesExplored) { - setNodesExplored(response.data.nodesExplored); + setErrorMessage(""); + + let tried = 0; + const tryEndpoint = (index: number) => { + if (index >= streamEndpoints.length) { + setErrorMessage("Could not reach any backend endpoint."); + setIsLoading(false); + return; + } + + const url = `${streamEndpoints[index]}?${params}`; + const es = new EventSource(url); + + es.addEventListener("progress", (e) => { + const data = JSON.parse(e.data); + if (data.nodesExplored !== undefined) { + setNodesExplored(data.nodesExplored); } - if (response.data.message) { + }); + + es.addEventListener("result", (e) => { + es.close(); + const data = JSON.parse(e.data); + if (data.message) { setResults([]); + setErrorMessage(data.message); + if (data.nodesExplored !== undefined) { + setNodesExplored(data.nodesExplored); + } } else { - setResults(response.data.path || response.data); + setResults(data.path || []); + if (data.nodesExplored !== undefined) { + setNodesExplored(data.nodesExplored); + } } - lastError = null; - break; // Success, exit loop - } catch (error) { - lastError = error; - continue; // Try next endpoint - } - } - if (lastError) { - if (lastError instanceof Error) { - console.error("Error fetching results:", lastError.message); - } else { - console.error("Unexpected error fetching results:", lastError); - } - } - setIsLoading(false); + setIsLoading(false); + }); + + es.addEventListener("error", (e) => { + es.close(); + // Try to parse structured error; otherwise fall back to next endpoint. + if (e instanceof MessageEvent && e.data) { + try { + const data = JSON.parse(e.data); + setErrorMessage(data.error || "An error occurred."); + setIsLoading(false); + return; + } catch { + // non-JSON error event — connection failed, try next endpoint + } + } + tried++; + if (tried < streamEndpoints.length) { + tryEndpoint(tried); + } else { + setErrorMessage("Could not reach any backend endpoint."); + setIsLoading(false); + } + }); + }; + + tryEndpoint(tried); }; - const handleQuery = async () => { + const handleQuery = () => { if (!startingLink || !endingLink) { console.error("Both starting and ending links are required."); return; } - try { - await fetchResults(); - } catch (error) { - if (error instanceof Error) { - console.error("Unexpected error:", error.message); - } else { - console.error("Unexpected error:", error); - } - } + fetchResults(); }; return ( @@ -183,17 +233,31 @@ function App() { {/* Results Section */}

- {(isLoading || results.length > 0) && ( + {(isLoading || results.length > 0 || errorMessage) && (
{isLoading ? (

Exploring Wikipedia using BFS...

+ {nodesExplored > 0 && ( +

+ Nodes explored so far: {nodesExplored} +

+ )}
+ ) : errorMessage ? ( +
+

{errorMessage}

+ {nodesExplored > 0 && ( +

+ BFS explored {nodesExplored} nodes before giving up. +

+ )} +
) : ( <> {nodesExplored > 0 && (