diff --git a/src/main/java/io/github/randomcodespace/iq/api/GraphController.java b/src/main/java/io/github/randomcodespace/iq/api/GraphController.java index 57b3ebdc..7cb56d91 100644 --- a/src/main/java/io/github/randomcodespace/iq/api/GraphController.java +++ b/src/main/java/io/github/randomcodespace/iq/api/GraphController.java @@ -13,12 +13,16 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; +import io.github.randomcodespace.iq.model.NodeKind; + import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * REST API controller matching the Python OSSCodeIQ API paths. @@ -62,7 +66,8 @@ public Map nodesByKind( @RequestParam(defaultValue = "50") int limit, @RequestParam(defaultValue = "0") int offset) { requireQueryService(); - return queryService.nodesByKind(kind, Math.min(limit, 1000), offset); + validateNodeKind(kind); + return queryService.nodesByKind(kind, Math.min(limit, 1000), Math.max(0, offset)); } @GetMapping("/nodes") @@ -71,13 +76,10 @@ public Map listNodes( @RequestParam(defaultValue = "100") int limit, @RequestParam(defaultValue = "0") int offset) { requireQueryService(); - return queryService.listNodes(kind, Math.min(limit, 1000), offset); - } - - @GetMapping("/nodes/find") - public List> findNode(@RequestParam String q) { - requireQueryService(); - return queryService.searchGraph(q, 50); + if (kind != null) { + validateNodeKind(kind); + } + return queryService.listNodes(kind, Math.min(limit, 1000), Math.max(0, offset)); } @GetMapping("/nodes/{nodeId}/detail") @@ -104,7 +106,7 @@ public Map listEdges( @RequestParam(defaultValue = "100") int limit, @RequestParam(defaultValue = "0") int offset) { requireQueryService(); - return queryService.listEdges(kind, Math.min(limit, 1000), offset); + return queryService.listEdges(kind, Math.min(limit, 1000), Math.max(0, offset)); } @GetMapping("/ego/{center}") @@ -196,11 +198,16 @@ public List> searchGraph( return queryService.searchGraph(q, Math.min(limit, 1000)); } - /** - * Check whether Neo4j (via QueryService) is available for queries. - */ - private boolean useNeo4j() { - return queryService != null; + private void validateNodeKind(String kind) { + try { + NodeKind.fromValue(kind); + } catch (IllegalArgumentException e) { + String valid = Arrays.stream(NodeKind.values()) + .map(NodeKind::getValue) + .collect(Collectors.joining(", ")); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Invalid node kind: '" + kind + "'. Valid values: " + valid); + } } private void requireQueryService() { diff --git a/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java b/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java index d61bf929..9505b815 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java +++ b/src/main/java/io/github/randomcodespace/iq/config/CodeIqConfig.java @@ -25,6 +25,16 @@ public class CodeIqConfig { /** Batch size for file processing during indexing (files per H2 flush). */ private int batchSize = 500; + /** Graph configuration sub-properties. */ + private Graph graph = new Graph(); + + public static class Graph { + private String path = ".osscodeiq/graph.db"; + + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + } + /** Service name tag for multi-repo graph mode. */ private String serviceName; @@ -77,4 +87,12 @@ public String getServiceName() { public void setServiceName(String serviceName) { this.serviceName = serviceName; } + + public Graph getGraph() { + return graph; + } + + public void setGraph(Graph graph) { + this.graph = graph; + } } diff --git a/src/main/java/io/github/randomcodespace/iq/config/Neo4jConfig.java b/src/main/java/io/github/randomcodespace/iq/config/Neo4jConfig.java index 48a80e00..79f452cd 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/Neo4jConfig.java +++ b/src/main/java/io/github/randomcodespace/iq/config/Neo4jConfig.java @@ -34,9 +34,8 @@ public class Neo4jConfig { private int boltPort; @Bean(destroyMethod = "shutdown") - DatabaseManagementService databaseManagementService( - @Value("${codeiq.graph.path:.osscodeiq/graph.db}") String dbPath) { - return new DatabaseManagementServiceBuilder(Path.of(dbPath)) + DatabaseManagementService databaseManagementService(CodeIqConfig config) { + return new DatabaseManagementServiceBuilder(Path.of(config.getGraph().getPath())) .setConfig(BoltConnector.enabled, true) .setConfig(BoltConnector.listen_address, new SocketAddress("localhost", boltPort)) .build(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6f51da56..f227ae8d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,7 +3,7 @@ spring: name: code-iq main: banner-mode: off - allow-bean-definition-overriding: true + allow-bean-definition-overriding: false threads: virtual: enabled: true diff --git a/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java b/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java index e8540e66..2d5acd8f 100644 --- a/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java +++ b/src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java @@ -114,6 +114,34 @@ void nodesByKindShouldAcceptPaginationParams() throws Exception { .andExpect(jsonPath("$.limit").value(25)); } + @Test + void nodesByKindShouldReturn400ForInvalidKind() throws Exception { + mockMvc.perform(get("/api/kinds/not_a_real_kind")) + .andExpect(status().isBadRequest()); + } + + @Test + void nodesByKindShouldClampNegativeOffset() throws Exception { + Map result = new LinkedHashMap<>(); + result.put("kind", "endpoint"); + result.put("nodes", List.of()); + when(queryService.nodesByKind("endpoint", 50, 0)).thenReturn(result); + + mockMvc.perform(get("/api/kinds/endpoint?offset=-5")) + .andExpect(status().isOk()); + } + + @Test + void nodesByKindShouldCapLimitTo1000() throws Exception { + Map result = new LinkedHashMap<>(); + result.put("kind", "endpoint"); + result.put("nodes", List.of()); + when(queryService.nodesByKind("endpoint", 1000, 0)).thenReturn(result); + + mockMvc.perform(get("/api/kinds/endpoint?limit=5000")) + .andExpect(status().isOk()); + } + // --- /api/nodes --- @Test @@ -140,6 +168,34 @@ void listNodesShouldFilterByKind() throws Exception { .andExpect(jsonPath("$.count").value(0)); } + @Test + void listNodesShouldReturn400ForInvalidKind() throws Exception { + mockMvc.perform(get("/api/nodes?kind=bogus_kind")) + .andExpect(status().isBadRequest()); + } + + @Test + void listNodesShouldClampNegativeOffset() throws Exception { + Map result = new LinkedHashMap<>(); + result.put("nodes", List.of()); + result.put("count", 0); + when(queryService.listNodes(null, 100, 0)).thenReturn(result); + + mockMvc.perform(get("/api/nodes?offset=-10")) + .andExpect(status().isOk()); + } + + @Test + void listNodesShouldCapLimitTo1000() throws Exception { + Map result = new LinkedHashMap<>(); + result.put("nodes", List.of()); + result.put("count", 0); + when(queryService.listNodes(null, 1000, 0)).thenReturn(result); + + mockMvc.perform(get("/api/nodes?limit=9999")) + .andExpect(status().isOk()); + } + // --- /api/nodes/{nodeId}/detail --- @Test @@ -208,6 +264,30 @@ void listEdgesShouldReturnEdges() throws Exception { .andExpect(jsonPath("$.total").value(0)); } + @Test + void listEdgesShouldClampNegativeOffset() throws Exception { + Map result = new LinkedHashMap<>(); + result.put("edges", List.of()); + result.put("count", 0); + result.put("total", 0); + when(queryService.listEdges(null, 100, 0)).thenReturn(result); + + mockMvc.perform(get("/api/edges?offset=-3")) + .andExpect(status().isOk()); + } + + @Test + void listEdgesShouldCapLimitTo1000() throws Exception { + Map result = new LinkedHashMap<>(); + result.put("edges", List.of()); + result.put("count", 0); + result.put("total", 0); + when(queryService.listEdges(null, 1000, 0)).thenReturn(result); + + mockMvc.perform(get("/api/edges?limit=5000")) + .andExpect(status().isOk()); + } + // --- /api/ego/{center} --- @Test