diff --git a/cameleer3-server-app/pom.xml b/cameleer3-server-app/pom.xml index 891d4762..9ef6d3e8 100644 --- a/cameleer3-server-app/pom.xml +++ b/cameleer3-server-app/pom.xml @@ -66,6 +66,10 @@ org.eclipse.xtext.xbase.lib 2.37.0 + + org.springframework.boot + spring-boot-starter-validation + org.springframework.boot spring-boot-starter-security diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/OpenApiConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/OpenApiConfig.java new file mode 100644 index 00000000..93fe69ba --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/OpenApiConfig.java @@ -0,0 +1,90 @@ +package com.cameleer3.server.app.config; + +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Configuration +@SecurityScheme(name = "bearer", type = SecuritySchemeType.HTTP, + scheme = "bearer", bearerFormat = "JWT") +public class OpenApiConfig { + + /** + * Core domain models that always have all fields populated. + * Mark all their properties as required so the generated TypeScript + * types are non-optional. + */ + private static final Set ALL_FIELDS_REQUIRED = Set.of( + "ExecutionSummary", "ExecutionDetail", "ExecutionStats", + "StatsTimeseries", "TimeseriesBucket", + "SearchResultExecutionSummary", "UserInfo", + "ProcessorNode" + ); + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info().title("Cameleer3 Server API").version("1.0")) + .addSecurityItem(new SecurityRequirement().addList("bearer")) + .servers(List.of()); + } + + @Bean + public OpenApiCustomizer pathPrefixStripper() { + return openApi -> { + var original = openApi.getPaths(); + if (original == null) return; + String prefix = "/api/v1"; + var stripped = new Paths(); + for (var entry : original.entrySet()) { + String path = entry.getKey(); + stripped.addPathItem( + path.startsWith(prefix) ? path.substring(prefix.length()) : path, + entry.getValue()); + } + openApi.setPaths(stripped); + }; + } + + @Bean + @SuppressWarnings("unchecked") + public OpenApiCustomizer schemaCustomizer() { + return openApi -> { + var schemas = openApi.getComponents().getSchemas(); + if (schemas == null) return; + + // Add children to ProcessorNode if missing (recursive self-reference) + if (schemas.containsKey("ProcessorNode")) { + Schema processorNode = schemas.get("ProcessorNode"); + if (processorNode.getProperties() != null + && !processorNode.getProperties().containsKey("children")) { + Schema selfRef = new Schema<>().$ref("#/components/schemas/ProcessorNode"); + ArraySchema childrenArray = new ArraySchema().items(selfRef); + processorNode.addProperty("children", childrenArray); + } + } + + // Mark all fields as required for core domain models + for (String schemaName : ALL_FIELDS_REQUIRED) { + if (schemas.containsKey(schemaName)) { + Schema schema = schemas.get(schemaName); + if (schema.getProperties() != null) { + schema.setRequired(new ArrayList<>(schema.getProperties().keySet())); + } + } + } + }; + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentCommandController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentCommandController.java index 7ce07a62..9df78b7e 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentCommandController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentCommandController.java @@ -1,13 +1,15 @@ package com.cameleer3.server.app.controller; import com.cameleer3.server.app.agent.SseConnectionManager; +import com.cameleer3.server.app.dto.CommandBroadcastResponse; +import com.cameleer3.server.app.dto.CommandRequest; +import com.cameleer3.server.app.dto.CommandSingleResponse; import com.cameleer3.server.core.agent.AgentCommand; import com.cameleer3.server.core.agent.AgentInfo; import com.cameleer3.server.core.agent.AgentRegistryService; import com.cameleer3.server.core.agent.AgentState; import com.cameleer3.server.core.agent.CommandType; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -24,9 +26,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; /** * Command push endpoints for sending commands to agents via SSE. @@ -63,24 +63,21 @@ public class AgentCommandController { @ApiResponse(responseCode = "202", description = "Command accepted") @ApiResponse(responseCode = "400", description = "Invalid command payload") @ApiResponse(responseCode = "404", description = "Agent not registered") - public ResponseEntity sendCommand(@PathVariable String id, - @RequestBody String body) throws JsonProcessingException { + public ResponseEntity sendCommand(@PathVariable String id, + @RequestBody CommandRequest request) throws JsonProcessingException { AgentInfo agent = registryService.findById(id); if (agent == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id); } - CommandRequest request = parseCommandRequest(body); - AgentCommand command = registryService.addCommand(id, request.type, request.payloadJson); + CommandType type = mapCommandType(request.type()); + String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}"; + AgentCommand command = registryService.addCommand(id, type, payloadJson); String status = connectionManager.isConnected(id) ? "DELIVERED" : "PENDING"; - Map response = new LinkedHashMap<>(); - response.put("commandId", command.id()); - response.put("status", status); - return ResponseEntity.status(HttpStatus.ACCEPTED) - .body(objectMapper.writeValueAsString(response)); + .body(new CommandSingleResponse(command.id(), status)); } @PostMapping("/groups/{group}/commands") @@ -88,9 +85,10 @@ public class AgentCommandController { description = "Sends a command to all LIVE agents in the specified group") @ApiResponse(responseCode = "202", description = "Commands accepted") @ApiResponse(responseCode = "400", description = "Invalid command payload") - public ResponseEntity sendGroupCommand(@PathVariable String group, - @RequestBody String body) throws JsonProcessingException { - CommandRequest request = parseCommandRequest(body); + public ResponseEntity sendGroupCommand(@PathVariable String group, + @RequestBody CommandRequest request) throws JsonProcessingException { + CommandType type = mapCommandType(request.type()); + String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}"; List agents = registryService.findAll().stream() .filter(a -> a.state() == AgentState.LIVE) @@ -99,16 +97,12 @@ public class AgentCommandController { List commandIds = new ArrayList<>(); for (AgentInfo agent : agents) { - AgentCommand command = registryService.addCommand(agent.id(), request.type, request.payloadJson); + AgentCommand command = registryService.addCommand(agent.id(), type, payloadJson); commandIds.add(command.id()); } - Map response = new LinkedHashMap<>(); - response.put("commandIds", commandIds); - response.put("targetCount", agents.size()); - return ResponseEntity.status(HttpStatus.ACCEPTED) - .body(objectMapper.writeValueAsString(response)); + .body(new CommandBroadcastResponse(commandIds, agents.size())); } @PostMapping("/commands") @@ -116,23 +110,20 @@ public class AgentCommandController { description = "Sends a command to all agents currently in LIVE state") @ApiResponse(responseCode = "202", description = "Commands accepted") @ApiResponse(responseCode = "400", description = "Invalid command payload") - public ResponseEntity broadcastCommand(@RequestBody String body) throws JsonProcessingException { - CommandRequest request = parseCommandRequest(body); + public ResponseEntity broadcastCommand(@RequestBody CommandRequest request) throws JsonProcessingException { + CommandType type = mapCommandType(request.type()); + String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}"; List liveAgents = registryService.findByState(AgentState.LIVE); List commandIds = new ArrayList<>(); for (AgentInfo agent : liveAgents) { - AgentCommand command = registryService.addCommand(agent.id(), request.type, request.payloadJson); + AgentCommand command = registryService.addCommand(agent.id(), type, payloadJson); commandIds.add(command.id()); } - Map response = new LinkedHashMap<>(); - response.put("commandIds", commandIds); - response.put("targetCount", liveAgents.size()); - return ResponseEntity.status(HttpStatus.ACCEPTED) - .body(objectMapper.writeValueAsString(response)); + .body(new CommandBroadcastResponse(commandIds, liveAgents.size())); } @PostMapping("/{id}/commands/{commandId}/ack") @@ -149,24 +140,6 @@ public class AgentCommandController { return ResponseEntity.ok().build(); } - private CommandRequest parseCommandRequest(String body) throws JsonProcessingException { - JsonNode node = objectMapper.readTree(body); - - if (!node.has("type")) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing 'type' field"); - } - - String typeStr = node.get("type").asText(); - CommandType type = mapCommandType(typeStr); - - String payloadJson = "{}"; - if (node.has("payload")) { - payloadJson = node.get("payload").toString(); - } - - return new CommandRequest(type, payloadJson); - } - private CommandType mapCommandType(String typeStr) { return switch (typeStr) { case "config-update" -> CommandType.CONFIG_UPDATE; @@ -176,6 +149,4 @@ public class AgentCommandController { "Invalid command type: " + typeStr + ". Valid: config-update, deep-trace, replay"); }; } - - private record CommandRequest(CommandType type, String payloadJson) {} } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java index 1d89da5b..68120bec 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java @@ -1,6 +1,12 @@ package com.cameleer3.server.app.controller; import com.cameleer3.server.app.config.AgentRegistryConfig; +import com.cameleer3.server.app.dto.AgentInstanceResponse; +import com.cameleer3.server.app.dto.AgentRefreshRequest; +import com.cameleer3.server.app.dto.AgentRefreshResponse; +import com.cameleer3.server.app.dto.AgentRegistrationRequest; +import com.cameleer3.server.app.dto.AgentRegistrationResponse; +import com.cameleer3.server.app.dto.ErrorResponse; import com.cameleer3.server.app.security.BootstrapTokenValidator; import com.cameleer3.server.core.agent.AgentInfo; import com.cameleer3.server.core.agent.AgentRegistryService; @@ -8,10 +14,9 @@ import com.cameleer3.server.core.agent.AgentState; import com.cameleer3.server.core.security.Ed25519SigningService; import com.cameleer3.server.core.security.InvalidTokenException; import com.cameleer3.server.core.security.JwtService; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -26,12 +31,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.ArrayList; import java.util.Collections; -import java.util.Iterator; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; /** * Agent registration, heartbeat, listing, and token refresh endpoints. @@ -46,20 +47,17 @@ public class AgentRegistrationController { private final AgentRegistryService registryService; private final AgentRegistryConfig config; - private final ObjectMapper objectMapper; private final BootstrapTokenValidator bootstrapTokenValidator; private final JwtService jwtService; private final Ed25519SigningService ed25519SigningService; public AgentRegistrationController(AgentRegistryService registryService, AgentRegistryConfig config, - ObjectMapper objectMapper, BootstrapTokenValidator bootstrapTokenValidator, JwtService jwtService, Ed25519SigningService ed25519SigningService) { this.registryService = registryService; this.config = config; - this.objectMapper = objectMapper; this.bootstrapTokenValidator = bootstrapTokenValidator; this.jwtService = jwtService; this.ed25519SigningService = ed25519SigningService; @@ -70,12 +68,14 @@ public class AgentRegistrationController { description = "Registers a new agent or re-registers an existing one. " + "Requires bootstrap token in Authorization header.") @ApiResponse(responseCode = "200", description = "Agent registered successfully") - @ApiResponse(responseCode = "400", description = "Invalid registration payload") + @ApiResponse(responseCode = "400", description = "Invalid registration payload", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) @ApiResponse(responseCode = "401", description = "Missing or invalid bootstrap token") - public ResponseEntity register(@RequestBody String body, - HttpServletRequest request) throws JsonProcessingException { + public ResponseEntity register( + @RequestBody AgentRegistrationRequest request, + HttpServletRequest httpRequest) { // Validate bootstrap token - String authHeader = request.getHeader("Authorization"); + String authHeader = httpRequest.getHeader("Authorization"); String bootstrapToken = null; if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { bootstrapToken = authHeader.substring(BEARER_PREFIX.length()); @@ -84,52 +84,32 @@ public class AgentRegistrationController { return ResponseEntity.status(401).build(); } - JsonNode node = objectMapper.readTree(body); - - String agentId = getRequiredField(node, "agentId"); - String name = getRequiredField(node, "name"); - if (agentId == null || name == null) { - return ResponseEntity.badRequest() - .body("{\"error\":\"agentId and name are required\"}"); + if (request.agentId() == null || request.agentId().isBlank() + || request.name() == null || request.name().isBlank()) { + return ResponseEntity.badRequest().build(); } - String group = node.has("group") ? node.get("group").asText() : "default"; - String version = node.has("version") ? node.get("version").asText() : null; + String group = request.group() != null ? request.group() : "default"; + List routeIds = request.routeIds() != null ? request.routeIds() : List.of(); + var capabilities = request.capabilities() != null ? request.capabilities() : Collections.emptyMap(); - List routeIds = new ArrayList<>(); - if (node.has("routeIds") && node.get("routeIds").isArray()) { - for (JsonNode rid : node.get("routeIds")) { - routeIds.add(rid.asText()); - } - } - - Map capabilities = Collections.emptyMap(); - if (node.has("capabilities") && node.get("capabilities").isObject()) { - capabilities = new LinkedHashMap<>(); - Iterator> fields = node.get("capabilities").fields(); - while (fields.hasNext()) { - Map.Entry field = fields.next(); - capabilities.put(field.getKey(), parseJsonValue(field.getValue())); - } - } - - AgentInfo agent = registryService.register(agentId, name, group, version, routeIds, capabilities); - log.info("Agent registered: {} (name={}, group={})", agentId, name, group); + AgentInfo agent = registryService.register( + request.agentId(), request.name(), group, request.version(), routeIds, capabilities); + log.info("Agent registered: {} (name={}, group={})", request.agentId(), request.name(), group); // Issue JWT tokens with AGENT role - java.util.List roles = java.util.List.of("AGENT"); - String accessToken = jwtService.createAccessToken(agentId, group, roles); - String refreshToken = jwtService.createRefreshToken(agentId, group, roles); + List roles = List.of("AGENT"); + String accessToken = jwtService.createAccessToken(request.agentId(), group, roles); + String refreshToken = jwtService.createRefreshToken(request.agentId(), group, roles); - Map response = new LinkedHashMap<>(); - response.put("agentId", agent.id()); - response.put("sseEndpoint", "/api/v1/agents/" + agentId + "/events"); - response.put("heartbeatIntervalMs", config.getHeartbeatIntervalMs()); - response.put("serverPublicKey", ed25519SigningService.getPublicKeyBase64()); - response.put("accessToken", accessToken); - response.put("refreshToken", refreshToken); - - return ResponseEntity.ok(objectMapper.writeValueAsString(response)); + return ResponseEntity.ok(new AgentRegistrationResponse( + agent.id(), + "/api/v1/agents/" + agent.id() + "/events", + config.getHeartbeatIntervalMs(), + ed25519SigningService.getPublicKeyBase64(), + accessToken, + refreshToken + )); } @PostMapping("/{id}/refresh") @@ -138,19 +118,16 @@ public class AgentRegistrationController { @ApiResponse(responseCode = "200", description = "New access token issued") @ApiResponse(responseCode = "401", description = "Invalid or expired refresh token") @ApiResponse(responseCode = "404", description = "Agent not found") - public ResponseEntity refresh(@PathVariable String id, - @RequestBody String body) throws JsonProcessingException { - JsonNode node = objectMapper.readTree(body); - String refreshToken = node.has("refreshToken") ? node.get("refreshToken").asText() : null; - - if (refreshToken == null || refreshToken.isBlank()) { + public ResponseEntity refresh(@PathVariable String id, + @RequestBody AgentRefreshRequest request) { + if (request.refreshToken() == null || request.refreshToken().isBlank()) { return ResponseEntity.status(401).build(); } // Validate refresh token - com.cameleer3.server.core.security.JwtService.JwtValidationResult result; + JwtService.JwtValidationResult result; try { - result = jwtService.validateRefreshToken(refreshToken); + result = jwtService.validateRefreshToken(request.refreshToken()); } catch (InvalidTokenException e) { log.debug("Refresh token validation failed: {}", e.getMessage()); return ResponseEntity.status(401).build(); @@ -171,14 +148,11 @@ public class AgentRegistrationController { } // Preserve roles from refresh token - java.util.List roles = result.roles().isEmpty() - ? java.util.List.of("AGENT") : result.roles(); + List roles = result.roles().isEmpty() + ? List.of("AGENT") : result.roles(); String newAccessToken = jwtService.createAccessToken(agentId, agent.group(), roles); - Map response = new LinkedHashMap<>(); - response.put("accessToken", newAccessToken); - - return ResponseEntity.ok(objectMapper.writeValueAsString(response)); + return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken)); } @PostMapping("/{id}/heartbeat") @@ -198,9 +172,10 @@ public class AgentRegistrationController { @Operation(summary = "List all agents", description = "Returns all registered agents, optionally filtered by status") @ApiResponse(responseCode = "200", description = "Agent list returned") - @ApiResponse(responseCode = "400", description = "Invalid status filter") - public ResponseEntity listAgents( - @RequestParam(required = false) String status) throws JsonProcessingException { + @ApiResponse(responseCode = "400", description = "Invalid status filter", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + public ResponseEntity> listAgents( + @RequestParam(required = false) String status) { List agents; if (status != null) { @@ -208,29 +183,15 @@ public class AgentRegistrationController { AgentState stateFilter = AgentState.valueOf(status.toUpperCase()); agents = registryService.findByState(stateFilter); } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest() - .body("{\"error\":\"Invalid status: " + status + ". Valid values: LIVE, STALE, DEAD\"}"); + return ResponseEntity.badRequest().build(); } } else { agents = registryService.findAll(); } - return ResponseEntity.ok(objectMapper.writeValueAsString(agents)); - } - - private String getRequiredField(JsonNode node, String fieldName) { - if (!node.has(fieldName) || node.get(fieldName).isNull() || node.get(fieldName).asText().isBlank()) { - return null; - } - return node.get(fieldName).asText(); - } - - private Object parseJsonValue(JsonNode node) { - if (node.isBoolean()) return node.asBoolean(); - if (node.isInt()) return node.asInt(); - if (node.isLong()) return node.asLong(); - if (node.isDouble()) return node.asDouble(); - if (node.isTextual()) return node.asText(); - return node.toString(); + List response = agents.stream() + .map(AgentInstanceResponse::from) + .toList(); + return ResponseEntity.ok(response); } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ApiExceptionHandler.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ApiExceptionHandler.java new file mode 100644 index 00000000..a55d0ee7 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ApiExceptionHandler.java @@ -0,0 +1,20 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.dto.ErrorResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; + +/** + * Global exception handler that ensures error responses use the typed {@link ErrorResponse} schema. + */ +@RestControllerAdvice +public class ApiExceptionHandler { + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity handleResponseStatus(ResponseStatusException ex) { + return ResponseEntity.status(ex.getStatusCode()) + .body(new ErrorResponse(ex.getReason() != null ? ex.getReason() : "Unknown error")); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DetailController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DetailController.java index a47a19d6..3e0ca0c4 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DetailController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DetailController.java @@ -4,6 +4,7 @@ import com.cameleer3.server.app.storage.ClickHouseExecutionRepository; import com.cameleer3.server.core.detail.DetailService; import com.cameleer3.server.core.detail.ExecutionDetail; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -36,6 +37,8 @@ public class DetailController { @GetMapping("/{executionId}") @Operation(summary = "Get execution detail with nested processor tree") + @ApiResponse(responseCode = "200", description = "Execution detail found") + @ApiResponse(responseCode = "404", description = "Execution not found") public ResponseEntity getDetail(@PathVariable String executionId) { return detailService.getDetail(executionId) .map(ResponseEntity::ok) @@ -44,6 +47,8 @@ public class DetailController { @GetMapping("/{executionId}/processors/{index}/snapshot") @Operation(summary = "Get exchange snapshot for a specific processor") + @ApiResponse(responseCode = "200", description = "Snapshot data") + @ApiResponse(responseCode = "404", description = "Snapshot not found") public ResponseEntity> getProcessorSnapshot( @PathVariable String executionId, @PathVariable int index) { diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DiagramRenderController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DiagramRenderController.java index 0762ce86..02312270 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DiagramRenderController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DiagramRenderController.java @@ -5,6 +5,8 @@ import com.cameleer3.server.core.diagram.DiagramLayout; import com.cameleer3.server.core.diagram.DiagramRenderer; import com.cameleer3.server.core.storage.DiagramRepository; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -45,7 +47,11 @@ public class DiagramRenderController { @GetMapping("/{contentHash}/render") @Operation(summary = "Render a route diagram", description = "Returns SVG (default) or JSON layout based on Accept header") - @ApiResponse(responseCode = "200", description = "Diagram rendered successfully") + @ApiResponse(responseCode = "200", description = "Diagram rendered successfully", + content = { + @Content(mediaType = "image/svg+xml", schema = @Schema(type = "string")), + @Content(mediaType = "application/json", schema = @Schema(implementation = DiagramLayout.class)) + }) @ApiResponse(responseCode = "404", description = "Diagram not found") public ResponseEntity renderDiagram( @PathVariable String contentHash, diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java index f77eca32..df201708 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java @@ -1,13 +1,20 @@ package com.cameleer3.server.app.controller; +import com.cameleer3.server.app.dto.ErrorResponse; +import com.cameleer3.server.app.dto.OidcAdminConfigRequest; +import com.cameleer3.server.app.dto.OidcAdminConfigResponse; +import com.cameleer3.server.app.dto.OidcTestResult; import com.cameleer3.server.app.security.OidcTokenExchanger; import com.cameleer3.server.core.security.OidcConfig; import com.cameleer3.server.core.security.OidcConfigRepository; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -16,10 +23,9 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import java.util.Optional; /** @@ -45,19 +51,20 @@ public class OidcConfigAdminController { @GetMapping @Operation(summary = "Get OIDC configuration") @ApiResponse(responseCode = "200", description = "Current OIDC configuration (client_secret masked)") - public ResponseEntity getConfig() { + public ResponseEntity getConfig() { Optional config = configRepository.find(); if (config.isEmpty()) { - return ResponseEntity.ok(Map.of("configured", false)); + return ResponseEntity.ok(OidcAdminConfigResponse.unconfigured()); } - return ResponseEntity.ok(toResponse(config.get())); + return ResponseEntity.ok(OidcAdminConfigResponse.from(config.get())); } @PutMapping @Operation(summary = "Save OIDC configuration") @ApiResponse(responseCode = "200", description = "Configuration saved") - @ApiResponse(responseCode = "400", description = "Invalid configuration") - public ResponseEntity saveConfig(@RequestBody OidcConfigRequest request) { + @ApiResponse(responseCode = "400", description = "Invalid configuration", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + public ResponseEntity saveConfig(@RequestBody OidcAdminConfigRequest request) { // Resolve client_secret: if masked or empty, preserve existing String clientSecret = request.clientSecret(); if (clientSecret == null || clientSecret.isBlank() || clientSecret.equals("********")) { @@ -66,12 +73,12 @@ public class OidcConfigAdminController { } if (request.enabled() && (request.issuerUri() == null || request.issuerUri().isBlank())) { - return ResponseEntity.badRequest() - .body(Map.of("message", "issuerUri is required when OIDC is enabled")); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "issuerUri is required when OIDC is enabled"); } if (request.enabled() && (request.clientId() == null || request.clientId().isBlank())) { - return ResponseEntity.badRequest() - .body(Map.of("message", "clientId is required when OIDC is enabled")); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "clientId is required when OIDC is enabled"); } OidcConfig config = new OidcConfig( @@ -88,31 +95,29 @@ public class OidcConfigAdminController { tokenExchanger.invalidateCache(); log.info("OIDC configuration updated: enabled={}, issuer={}", config.enabled(), config.issuerUri()); - return ResponseEntity.ok(toResponse(config)); + return ResponseEntity.ok(OidcAdminConfigResponse.from(config)); } @PostMapping("/test") @Operation(summary = "Test OIDC provider connectivity") @ApiResponse(responseCode = "200", description = "Provider reachable") - @ApiResponse(responseCode = "400", description = "Provider unreachable or misconfigured") - public ResponseEntity testConnection() { + @ApiResponse(responseCode = "400", description = "Provider unreachable or misconfigured", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + public ResponseEntity testConnection() { Optional config = configRepository.find(); if (config.isEmpty() || !config.get().enabled()) { - return ResponseEntity.badRequest() - .body(Map.of("message", "OIDC is not configured or disabled")); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "OIDC is not configured or disabled"); } try { tokenExchanger.invalidateCache(); String authEndpoint = tokenExchanger.getAuthorizationEndpoint(); - return ResponseEntity.ok(Map.of( - "status", "ok", - "authorizationEndpoint", authEndpoint - )); + return ResponseEntity.ok(new OidcTestResult("ok", authEndpoint)); } catch (Exception e) { log.warn("OIDC connectivity test failed: {}", e.getMessage()); - return ResponseEntity.badRequest() - .body(Map.of("message", "Failed to reach OIDC provider: " + e.getMessage())); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Failed to reach OIDC provider: " + e.getMessage()); } } @@ -125,27 +130,4 @@ public class OidcConfigAdminController { log.info("OIDC configuration deleted"); return ResponseEntity.noContent().build(); } - - private Map toResponse(OidcConfig config) { - Map map = new LinkedHashMap<>(); - map.put("configured", true); - map.put("enabled", config.enabled()); - map.put("issuerUri", config.issuerUri()); - map.put("clientId", config.clientId()); - map.put("clientSecretSet", !config.clientSecret().isBlank()); - map.put("rolesClaim", config.rolesClaim()); - map.put("defaultRoles", config.defaultRoles()); - map.put("autoSignup", config.autoSignup()); - return map; - } - - public record OidcConfigRequest( - boolean enabled, - String issuerUri, - String clientId, - String clientSecret, - String rolesClaim, - List defaultRoles, - boolean autoSignup - ) {} } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentInstanceResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentInstanceResponse.java new file mode 100644 index 00000000..e4b5fb93 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentInstanceResponse.java @@ -0,0 +1,27 @@ +package com.cameleer3.server.app.dto; + +import com.cameleer3.server.core.agent.AgentInfo; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.time.Instant; +import java.util.List; + +@Schema(description = "Agent instance summary") +public record AgentInstanceResponse( + @NotNull String id, + @NotNull String name, + @NotNull String group, + @NotNull String status, + @NotNull List routeIds, + @NotNull Instant registeredAt, + @NotNull Instant lastHeartbeat +) { + public static AgentInstanceResponse from(AgentInfo info) { + return new AgentInstanceResponse( + info.id(), info.name(), info.group(), + info.state().name(), info.routeIds(), + info.registeredAt(), info.lastHeartbeat() + ); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentRefreshRequest.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentRefreshRequest.java new file mode 100644 index 00000000..29fce1eb --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentRefreshRequest.java @@ -0,0 +1,7 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Agent token refresh request") +public record AgentRefreshRequest(@NotNull String refreshToken) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentRefreshResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentRefreshResponse.java new file mode 100644 index 00000000..95562087 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentRefreshResponse.java @@ -0,0 +1,7 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Refreshed access token") +public record AgentRefreshResponse(@NotNull String accessToken) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentRegistrationRequest.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentRegistrationRequest.java new file mode 100644 index 00000000..27dee2b8 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentRegistrationRequest.java @@ -0,0 +1,17 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.util.List; +import java.util.Map; + +@Schema(description = "Agent registration payload") +public record AgentRegistrationRequest( + @NotNull String agentId, + @NotNull String name, + @Schema(defaultValue = "default") String group, + String version, + List routeIds, + Map capabilities +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentRegistrationResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentRegistrationResponse.java new file mode 100644 index 00000000..b4884056 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentRegistrationResponse.java @@ -0,0 +1,14 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Agent registration result with JWT tokens and SSE endpoint") +public record AgentRegistrationResponse( + @NotNull String agentId, + @NotNull String sseEndpoint, + long heartbeatIntervalMs, + @NotNull String serverPublicKey, + @NotNull String accessToken, + @NotNull String refreshToken +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AuthTokenResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AuthTokenResponse.java new file mode 100644 index 00000000..3bd088c2 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AuthTokenResponse.java @@ -0,0 +1,10 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "JWT token pair") +public record AuthTokenResponse( + @NotNull String accessToken, + @NotNull String refreshToken +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CommandBroadcastResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CommandBroadcastResponse.java new file mode 100644 index 00000000..9262735d --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CommandBroadcastResponse.java @@ -0,0 +1,12 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +@Schema(description = "Result of broadcasting a command to multiple agents") +public record CommandBroadcastResponse( + @NotNull List commandIds, + int targetCount +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CommandRequest.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CommandRequest.java new file mode 100644 index 00000000..b870662c --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CommandRequest.java @@ -0,0 +1,12 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Command to send to agent(s)") +public record CommandRequest( + @NotNull @Schema(description = "Command type: config-update, deep-trace, or replay") + String type, + @Schema(description = "Command payload JSON") + Object payload +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CommandSingleResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CommandSingleResponse.java new file mode 100644 index 00000000..a2773754 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CommandSingleResponse.java @@ -0,0 +1,10 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Result of sending a command to a single agent") +public record CommandSingleResponse( + @NotNull String commandId, + @NotNull String status +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ErrorResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ErrorResponse.java new file mode 100644 index 00000000..a9ede4c9 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ErrorResponse.java @@ -0,0 +1,7 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Error response") +public record ErrorResponse(@NotNull String message) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcAdminConfigRequest.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcAdminConfigRequest.java new file mode 100644 index 00000000..0b1222f5 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcAdminConfigRequest.java @@ -0,0 +1,16 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "OIDC configuration update request") +public record OidcAdminConfigRequest( + boolean enabled, + String issuerUri, + String clientId, + String clientSecret, + String rolesClaim, + List defaultRoles, + boolean autoSignup +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcAdminConfigResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcAdminConfigResponse.java new file mode 100644 index 00000000..20ad245d --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcAdminConfigResponse.java @@ -0,0 +1,31 @@ +package com.cameleer3.server.app.dto; + +import com.cameleer3.server.core.security.OidcConfig; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +@Schema(description = "OIDC configuration for admin management") +public record OidcAdminConfigResponse( + boolean configured, + boolean enabled, + String issuerUri, + String clientId, + boolean clientSecretSet, + String rolesClaim, + List defaultRoles, + boolean autoSignup +) { + public static OidcAdminConfigResponse unconfigured() { + return new OidcAdminConfigResponse(false, false, null, null, false, null, null, false); + } + + public static OidcAdminConfigResponse from(OidcConfig config) { + return new OidcAdminConfigResponse( + true, config.enabled(), config.issuerUri(), config.clientId(), + !config.clientSecret().isBlank(), config.rolesClaim(), + config.defaultRoles(), config.autoSignup() + ); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcPublicConfigResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcPublicConfigResponse.java new file mode 100644 index 00000000..c96eca30 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcPublicConfigResponse.java @@ -0,0 +1,13 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "OIDC configuration for SPA login flow") +public record OidcPublicConfigResponse( + @NotNull String issuer, + @NotNull String clientId, + @NotNull String authorizationEndpoint, + @Schema(description = "Present if the provider supports RP-initiated logout") + String endSessionEndpoint +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcTestResult.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcTestResult.java new file mode 100644 index 00000000..ee0ab634 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcTestResult.java @@ -0,0 +1,10 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "OIDC provider connectivity test result") +public record OidcTestResult( + @NotNull String status, + @NotNull String authorizationEndpoint +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java index 8e16fc79..ed9bade4 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java @@ -1,24 +1,32 @@ package com.cameleer3.server.app.security; +import com.cameleer3.server.app.dto.AuthTokenResponse; +import com.cameleer3.server.app.dto.ErrorResponse; +import com.cameleer3.server.app.dto.OidcPublicConfigResponse; import com.cameleer3.server.core.security.JwtService; import com.cameleer3.server.core.security.OidcConfig; import com.cameleer3.server.core.security.OidcConfigRepository; import com.cameleer3.server.core.security.UserInfo; import com.cameleer3.server.core.security.UserRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; import java.net.URI; import java.time.Instant; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import java.util.Optional; /** @@ -29,6 +37,7 @@ import java.util.Optional; */ @RestController @RequestMapping("/api/v1/auth/oidc") +@Tag(name = "Authentication", description = "Login and token refresh endpoints") public class OidcAuthController { private static final Logger log = LoggerFactory.getLogger(OidcAuthController.class); @@ -53,7 +62,12 @@ public class OidcAuthController { * Returns 404 if OIDC is not configured or disabled. */ @GetMapping("/config") - public ResponseEntity getConfig() { + @Operation(summary = "Get OIDC config for SPA login flow") + @ApiResponse(responseCode = "200", description = "OIDC configuration") + @ApiResponse(responseCode = "404", description = "OIDC not configured or disabled") + @ApiResponse(responseCode = "500", description = "Failed to retrieve OIDC provider metadata", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + public ResponseEntity getConfig() { Optional config = configRepository.find(); if (config.isEmpty() || !config.get().enabled()) { return ResponseEntity.notFound().build(); @@ -61,19 +75,17 @@ public class OidcAuthController { try { OidcConfig oidc = config.get(); - Map response = new LinkedHashMap<>(); - response.put("issuer", oidc.issuerUri()); - response.put("clientId", oidc.clientId()); - response.put("authorizationEndpoint", tokenExchanger.getAuthorizationEndpoint()); String endSessionEndpoint = tokenExchanger.getEndSessionEndpoint(); - if (endSessionEndpoint != null) { - response.put("endSessionEndpoint", endSessionEndpoint); - } - return ResponseEntity.ok(response); + return ResponseEntity.ok(new OidcPublicConfigResponse( + oidc.issuerUri(), + oidc.clientId(), + tokenExchanger.getAuthorizationEndpoint(), + endSessionEndpoint + )); } catch (Exception e) { log.error("Failed to retrieve OIDC provider metadata: {}", e.getMessage()); - return ResponseEntity.internalServerError() - .body(Map.of("message", "Failed to retrieve OIDC provider metadata")); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, + "Failed to retrieve OIDC provider metadata"); } } @@ -81,7 +93,14 @@ public class OidcAuthController { * Exchanges an OIDC authorization code for internal Cameleer JWTs. */ @PostMapping("/callback") - public ResponseEntity callback(@RequestBody CallbackRequest request) { + @Operation(summary = "Exchange OIDC authorization code for JWTs") + @ApiResponse(responseCode = "200", description = "Authentication successful") + @ApiResponse(responseCode = "401", description = "OIDC authentication failed", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + @ApiResponse(responseCode = "403", description = "Account not provisioned", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + @ApiResponse(responseCode = "404", description = "OIDC not configured or disabled") + public ResponseEntity callback(@RequestBody CallbackRequest request) { Optional config = configRepository.find(); if (config.isEmpty() || !config.get().enabled()) { return ResponseEntity.notFound().build(); @@ -98,8 +117,8 @@ public class OidcAuthController { // Check auto-signup gate: if disabled, user must already exist Optional existingUser = userRepository.findById(userId); if (!config.get().autoSignup() && existingUser.isEmpty()) { - return ResponseEntity.status(403) - .body(Map.of("message", "Account not provisioned. Contact your administrator.")); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "Account not provisioned. Contact your administrator."); } // Resolve roles: DB override > OIDC claim > default @@ -111,14 +130,13 @@ public class OidcAuthController { String accessToken = jwtService.createAccessToken(userId, "ui", roles); String refreshToken = jwtService.createRefreshToken(userId, "ui", roles); - return ResponseEntity.ok(Map.of( - "accessToken", accessToken, - "refreshToken", refreshToken - )); + return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken)); + } catch (ResponseStatusException e) { + throw e; } catch (Exception e) { log.error("OIDC callback failed: {}", e.getMessage(), e); - return ResponseEntity.status(401) - .body(Map.of("message", "OIDC authentication failed: " + e.getMessage())); + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, + "OIDC authentication failed: " + e.getMessage()); } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java index df13d3a0..36ae17b9 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java @@ -1,20 +1,28 @@ package com.cameleer3.server.app.security; +import com.cameleer3.server.app.dto.AuthTokenResponse; +import com.cameleer3.server.app.dto.ErrorResponse; import com.cameleer3.server.core.security.JwtService; import com.cameleer3.server.core.security.JwtService.JwtValidationResult; import com.cameleer3.server.core.security.UserInfo; import com.cameleer3.server.core.security.UserRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; import java.time.Instant; import java.util.List; -import java.util.Map; /** * Authentication endpoints for the UI (local credentials). @@ -25,6 +33,7 @@ import java.util.Map; */ @RestController @RequestMapping("/api/v1/auth") +@Tag(name = "Authentication", description = "Login and token refresh endpoints") public class UiAuthController { private static final Logger log = LoggerFactory.getLogger(UiAuthController.class); @@ -41,20 +50,24 @@ public class UiAuthController { } @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequest request) { + @Operation(summary = "Login with local credentials") + @ApiResponse(responseCode = "200", description = "Login successful") + @ApiResponse(responseCode = "401", description = "Invalid credentials", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + public ResponseEntity login(@RequestBody LoginRequest request) { String configuredUser = properties.getUiUser(); String configuredPassword = properties.getUiPassword(); if (configuredUser == null || configuredUser.isBlank() || configuredPassword == null || configuredPassword.isBlank()) { log.warn("UI authentication attempted but CAMELEER_UI_USER / CAMELEER_UI_PASSWORD not configured"); - return ResponseEntity.status(401).body(Map.of("message", "UI authentication not configured")); + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "UI authentication not configured"); } if (!configuredUser.equals(request.username()) || !configuredPassword.equals(request.password())) { log.debug("UI login failed for user: {}", request.username()); - return ResponseEntity.status(401).body(Map.of("message", "Invalid credentials")); + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials"); } String subject = "ui:" + request.username(); @@ -72,18 +85,19 @@ public class UiAuthController { String refreshToken = jwtService.createRefreshToken(subject, "ui", roles); log.info("UI user logged in: {}", request.username()); - return ResponseEntity.ok(Map.of( - "accessToken", accessToken, - "refreshToken", refreshToken - )); + return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken)); } @PostMapping("/refresh") - public ResponseEntity refresh(@RequestBody RefreshRequest request) { + @Operation(summary = "Refresh access token") + @ApiResponse(responseCode = "200", description = "Token refreshed") + @ApiResponse(responseCode = "401", description = "Invalid refresh token", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + public ResponseEntity refresh(@RequestBody RefreshRequest request) { try { JwtValidationResult result = jwtService.validateRefreshToken(request.refreshToken()); if (!result.subject().startsWith("ui:")) { - return ResponseEntity.status(401).body(Map.of("message", "Not a UI token")); + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not a UI token"); } // Preserve roles from the refresh token @@ -91,13 +105,12 @@ public class UiAuthController { String accessToken = jwtService.createAccessToken(result.subject(), "ui", roles); String refreshToken = jwtService.createRefreshToken(result.subject(), "ui", roles); - return ResponseEntity.ok(Map.of( - "accessToken", accessToken, - "refreshToken", refreshToken - )); + return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken)); + } catch (ResponseStatusException e) { + throw e; } catch (Exception e) { log.debug("UI token refresh failed: {}", e.getMessage()); - return ResponseEntity.status(401).body(Map.of("message", "Invalid refresh token")); + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid refresh token"); } } diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentRegistrationControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentRegistrationControllerIT.java index bffe775b..652f92d8 100644 --- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentRegistrationControllerIT.java +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentRegistrationControllerIT.java @@ -139,7 +139,7 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT { JsonNode body = objectMapper.readTree(response.getBody()); assertThat(body.isArray()).isTrue(); for (JsonNode agent : body) { - assertThat(agent.get("state").asText()).isEqualTo("LIVE"); + assertThat(agent.get("status").asText()).isEqualTo("LIVE"); } } diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 00000000..7dbf7ebf --- /dev/null +++ b/ui/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/ui/package.json b/ui/package.json index 45cfdbd9..49dfc3ea 100644 --- a/ui/package.json +++ b/ui/package.json @@ -8,7 +8,8 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "generate-api": "openapi-typescript http://localhost:8081/api/v1/api-docs -o src/api/schema.d.ts" + "generate-api": "openapi-typescript src/api/openapi.json -o src/api/schema.d.ts", + "generate-api:live": "curl -s http://localhost:8081/api/v1/api-docs -o src/api/openapi.json && openapi-typescript src/api/openapi.json -o src/api/schema.d.ts" }, "dependencies": { "@tanstack/react-query": "^5.90.21", diff --git a/ui/src/api/openapi.json b/ui/src/api/openapi.json index 201f2f21..ce1d9645 100644 --- a/ui/src/api/openapi.json +++ b/ui/src/api/openapi.json @@ -1,10 +1,15 @@ { "openapi": "3.1.0", "info": { - "title": "OpenAPI definition", - "version": "v0" + "title": "Cameleer3 Server API", + "version": "1.0" }, "servers": [], + "security": [ + { + "bearer": [] + } + ], "tags": [ { "name": "Agent SSE", @@ -26,6 +31,10 @@ "name": "Ingestion", "description": "Data ingestion endpoints" }, + { + "name": "Authentication", + "description": "Login and token refresh endpoints" + }, { "name": "OIDC Config Admin", "description": "OIDC provider configuration (ADMIN only)" @@ -94,7 +103,7 @@ "content": { "*/*": { "schema": { - "type": "object" + "$ref": "#/components/schemas/OidcAdminConfigResponse" } } } @@ -111,7 +120,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OidcConfigRequest" + "$ref": "#/components/schemas/OidcAdminConfigRequest" } } }, @@ -123,7 +132,7 @@ "content": { "*/*": { "schema": { - "type": "object" + "$ref": "#/components/schemas/OidcAdminConfigResponse" } } } @@ -133,7 +142,7 @@ "content": { "*/*": { "schema": { - "type": "object" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -378,8 +387,9 @@ "/auth/refresh": { "post": { "tags": [ - "ui-auth-controller" + "Authentication" ], + "summary": "Refresh access token", "operationId": "refresh", "requestBody": { "content": { @@ -393,11 +403,21 @@ }, "responses": { "200": { - "description": "OK", + "description": "Token refreshed", "content": { "*/*": { "schema": { - "type": "object" + "$ref": "#/components/schemas/AuthTokenResponse" + } + } + } + }, + "401": { + "description": "Invalid refresh token", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -408,8 +428,9 @@ "/auth/oidc/callback": { "post": { "tags": [ - "oidc-auth-controller" + "Authentication" ], + "summary": "Exchange OIDC authorization code for JWTs", "operationId": "callback", "requestBody": { "content": { @@ -423,11 +444,41 @@ }, "responses": { "200": { - "description": "OK", + "description": "Authentication successful", "content": { "*/*": { "schema": { - "type": "object" + "$ref": "#/components/schemas/AuthTokenResponse" + } + } + } + }, + "401": { + "description": "OIDC authentication failed", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Account not provisioned", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "OIDC not configured or disabled", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/AuthTokenResponse" } } } @@ -438,8 +489,9 @@ "/auth/login": { "post": { "tags": [ - "ui-auth-controller" + "Authentication" ], + "summary": "Login with local credentials", "operationId": "login", "requestBody": { "content": { @@ -453,11 +505,21 @@ }, "responses": { "200": { - "description": "OK", + "description": "Login successful", "content": { "*/*": { "schema": { - "type": "object" + "$ref": "#/components/schemas/AuthTokenResponse" + } + } + } + }, + "401": { + "description": "Invalid credentials", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -487,7 +549,7 @@ "content": { "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/AgentRefreshRequest" } } }, @@ -499,7 +561,7 @@ "content": { "*/*": { "schema": { - "type": "string" + "$ref": "#/components/schemas/AgentRefreshResponse" } } } @@ -509,7 +571,7 @@ "content": { "*/*": { "schema": { - "type": "string" + "$ref": "#/components/schemas/AgentRefreshResponse" } } } @@ -519,7 +581,7 @@ "content": { "*/*": { "schema": { - "type": "string" + "$ref": "#/components/schemas/AgentRefreshResponse" } } } @@ -577,7 +639,7 @@ "content": { "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/CommandRequest" } } }, @@ -589,7 +651,7 @@ "content": { "*/*": { "schema": { - "type": "string" + "$ref": "#/components/schemas/CommandSingleResponse" } } } @@ -599,7 +661,7 @@ "content": { "*/*": { "schema": { - "type": "string" + "$ref": "#/components/schemas/CommandSingleResponse" } } } @@ -609,7 +671,7 @@ "content": { "*/*": { "schema": { - "type": "string" + "$ref": "#/components/schemas/CommandSingleResponse" } } } @@ -665,7 +727,7 @@ "content": { "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/AgentRegistrationRequest" } } }, @@ -677,7 +739,7 @@ "content": { "*/*": { "schema": { - "type": "string" + "$ref": "#/components/schemas/AgentRegistrationResponse" } } } @@ -687,7 +749,7 @@ "content": { "*/*": { "schema": { - "type": "string" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -697,7 +759,7 @@ "content": { "*/*": { "schema": { - "type": "string" + "$ref": "#/components/schemas/AgentRegistrationResponse" } } } @@ -727,7 +789,7 @@ "content": { "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/CommandRequest" } } }, @@ -739,7 +801,7 @@ "content": { "*/*": { "schema": { - "type": "string" + "$ref": "#/components/schemas/CommandBroadcastResponse" } } } @@ -749,7 +811,7 @@ "content": { "*/*": { "schema": { - "type": "string" + "$ref": "#/components/schemas/CommandBroadcastResponse" } } } @@ -769,7 +831,7 @@ "content": { "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/CommandRequest" } } }, @@ -781,7 +843,7 @@ "content": { "*/*": { "schema": { - "type": "string" + "$ref": "#/components/schemas/CommandBroadcastResponse" } } } @@ -791,7 +853,7 @@ "content": { "*/*": { "schema": { - "type": "string" + "$ref": "#/components/schemas/CommandBroadcastResponse" } } } @@ -812,7 +874,7 @@ "content": { "*/*": { "schema": { - "type": "object" + "$ref": "#/components/schemas/OidcTestResult" } } } @@ -822,7 +884,7 @@ "content": { "*/*": { "schema": { - "type": "object" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -941,7 +1003,17 @@ ], "responses": { "200": { - "description": "OK", + "description": "Execution detail found", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ExecutionDetail" + } + } + } + }, + "404": { + "description": "Execution not found", "content": { "*/*": { "schema": { @@ -981,7 +1053,20 @@ ], "responses": { "200": { - "description": "OK", + "description": "Snapshot data", + "content": { + "*/*": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "404": { + "description": "Snapshot not found", "content": { "*/*": { "schema": { @@ -1018,9 +1103,14 @@ "200": { "description": "Diagram rendered successfully", "content": { - "*/*": { + "image/svg+xml": { "schema": { - "type": "object" + "type": "string" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiagramLayout" } } } @@ -1041,16 +1131,37 @@ "/auth/oidc/config": { "get": { "tags": [ - "oidc-auth-controller" + "Authentication" ], + "summary": "Get OIDC config for SPA login flow", "operationId": "getConfig_1", "responses": { "200": { - "description": "OK", + "description": "OIDC configuration", "content": { "*/*": { "schema": { - "type": "object" + "$ref": "#/components/schemas/OidcPublicConfigResponse" + } + } + } + }, + "404": { + "description": "OIDC not configured or disabled", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/OidcPublicConfigResponse" + } + } + } + }, + "500": { + "description": "Failed to retrieve OIDC provider metadata", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -1082,7 +1193,10 @@ "content": { "*/*": { "schema": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentInstanceResponse" + } } } } @@ -1092,7 +1206,7 @@ "content": { "*/*": { "schema": { - "type": "string" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -1252,8 +1366,9 @@ } } }, - "OidcConfigRequest": { + "OidcAdminConfigRequest": { "type": "object", + "description": "OIDC configuration update request", "properties": { "enabled": { "type": "boolean" @@ -1281,6 +1396,51 @@ } } }, + "ErrorResponse": { + "type": "object", + "description": "Error response", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + }, + "OidcAdminConfigResponse": { + "type": "object", + "description": "OIDC configuration for admin management", + "properties": { + "configured": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "issuerUri": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientSecretSet": { + "type": "boolean" + }, + "rolesClaim": { + "type": "string" + }, + "defaultRoles": { + "type": "array", + "items": { + "type": "string" + } + }, + "autoSignup": { + "type": "boolean" + } + } + }, "SearchRequest": { "type": "object", "properties": { @@ -1373,7 +1533,19 @@ "diagramContentHash": { "type": "string" } - } + }, + "required": [ + "agentId", + "correlationId", + "diagramContentHash", + "durationMs", + "endTime", + "errorMessage", + "executionId", + "routeId", + "startTime", + "status" + ] }, "SearchResultExecutionSummary": { "type": "object", @@ -1396,7 +1568,13 @@ "type": "integer", "format": "int32" } - } + }, + "required": [ + "data", + "limit", + "offset", + "total" + ] }, "RefreshRequest": { "type": "object", @@ -1406,6 +1584,22 @@ } } }, + "AuthTokenResponse": { + "type": "object", + "description": "JWT token pair", + "properties": { + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + } + }, + "required": [ + "accessToken", + "refreshToken" + ] + }, "CallbackRequest": { "type": "object", "properties": { @@ -1428,6 +1622,165 @@ } } }, + "AgentRefreshRequest": { + "type": "object", + "description": "Agent token refresh request", + "properties": { + "refreshToken": { + "type": "string" + } + }, + "required": [ + "refreshToken" + ] + }, + "AgentRefreshResponse": { + "type": "object", + "description": "Refreshed access token", + "properties": { + "accessToken": { + "type": "string" + } + }, + "required": [ + "accessToken" + ] + }, + "CommandRequest": { + "type": "object", + "description": "Command to send to agent(s)", + "properties": { + "type": { + "type": "string", + "description": "Command type: config-update, deep-trace, or replay" + }, + "payload": { + "type": "object", + "description": "Command payload JSON" + } + }, + "required": [ + "type" + ] + }, + "CommandSingleResponse": { + "type": "object", + "description": "Result of sending a command to a single agent", + "properties": { + "commandId": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "commandId", + "status" + ] + }, + "AgentRegistrationRequest": { + "type": "object", + "description": "Agent registration payload", + "properties": { + "agentId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "group": { + "type": "string", + "default": "default" + }, + "version": { + "type": "string" + }, + "routeIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "capabilities": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + }, + "required": [ + "agentId", + "name" + ] + }, + "AgentRegistrationResponse": { + "type": "object", + "description": "Agent registration result with JWT tokens and SSE endpoint", + "properties": { + "agentId": { + "type": "string" + }, + "sseEndpoint": { + "type": "string" + }, + "heartbeatIntervalMs": { + "type": "integer", + "format": "int64" + }, + "serverPublicKey": { + "type": "string" + }, + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + } + }, + "required": [ + "accessToken", + "agentId", + "refreshToken", + "serverPublicKey", + "sseEndpoint" + ] + }, + "CommandBroadcastResponse": { + "type": "object", + "description": "Result of broadcasting a command to multiple agents", + "properties": { + "commandIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "targetCount": { + "type": "integer", + "format": "int32" + } + }, + "required": [ + "commandIds" + ] + }, + "OidcTestResult": { + "type": "object", + "description": "OIDC provider connectivity test result", + "properties": { + "status": { + "type": "string" + }, + "authorizationEndpoint": { + "type": "string" + } + }, + "required": [ + "authorizationEndpoint", + "status" + ] + }, "ExecutionStats": { "type": "object", "properties": { @@ -1471,7 +1824,19 @@ "type": "integer", "format": "int64" } - } + }, + "required": [ + "activeCount", + "avgDurationMs", + "failedCount", + "p99LatencyMs", + "prevAvgDurationMs", + "prevFailedCount", + "prevP99LatencyMs", + "prevTotalCount", + "totalCount", + "totalToday" + ] }, "StatsTimeseries": { "type": "object", @@ -1482,7 +1847,10 @@ "$ref": "#/components/schemas/TimeseriesBucket" } } - } + }, + "required": [ + "buckets" + ] }, "TimeseriesBucket": { "type": "object", @@ -1511,7 +1879,15 @@ "type": "integer", "format": "int64" } - } + }, + "required": [ + "activeCount", + "avgDurationMs", + "failedCount", + "p99DurationMs", + "time", + "totalCount" + ] }, "ExecutionDetail": { "type": "object", @@ -1561,7 +1937,22 @@ "$ref": "#/components/schemas/ProcessorNode" } } - } + }, + "required": [ + "agentId", + "correlationId", + "diagramContentHash", + "durationMs", + "endTime", + "errorMessage", + "errorStackTrace", + "exchangeId", + "executionId", + "processors", + "routeId", + "startTime", + "status" + ] }, "ProcessorNode": { "type": "object", @@ -1595,9 +1986,171 @@ }, "errorStackTrace": { "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProcessorNode" + } + } + }, + "required": [ + "children", + "diagramNodeId", + "durationMs", + "endTime", + "errorMessage", + "errorStackTrace", + "processorId", + "processorType", + "startTime", + "status" + ] + }, + "DiagramLayout": { + "type": "object", + "properties": { + "width": { + "type": "number", + "format": "double" + }, + "height": { + "type": "number", + "format": "double" + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PositionedNode" + } + }, + "edges": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PositionedEdge" + } } } }, + "PositionedEdge": { + "type": "object", + "properties": { + "sourceId": { + "type": "string" + }, + "targetId": { + "type": "string" + }, + "label": { + "type": "string" + }, + "points": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + } + } + } + }, + "PositionedNode": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string" + }, + "x": { + "type": "number", + "format": "double" + }, + "y": { + "type": "number", + "format": "double" + }, + "width": { + "type": "number", + "format": "double" + }, + "height": { + "type": "number", + "format": "double" + } + } + }, + "OidcPublicConfigResponse": { + "type": "object", + "description": "OIDC configuration for SPA login flow", + "properties": { + "issuer": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "authorizationEndpoint": { + "type": "string" + }, + "endSessionEndpoint": { + "type": "string", + "description": "Present if the provider supports RP-initiated logout" + } + }, + "required": [ + "authorizationEndpoint", + "clientId", + "issuer" + ] + }, + "AgentInstanceResponse": { + "type": "object", + "description": "Agent instance summary", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "group": { + "type": "string" + }, + "status": { + "type": "string" + }, + "routeIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "registeredAt": { + "type": "string", + "format": "date-time" + }, + "lastHeartbeat": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "group", + "id", + "lastHeartbeat", + "name", + "registeredAt", + "routeIds", + "status" + ] + }, "SseEmitter": { "type": "object", "properties": { @@ -1632,7 +2185,22 @@ "type": "string", "format": "date-time" } - } + }, + "required": [ + "createdAt", + "displayName", + "email", + "provider", + "roles", + "userId" + ] + } + }, + "securitySchemes": { + "bearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" } } } diff --git a/ui/src/api/queries/executions.ts b/ui/src/api/queries/executions.ts index e761a26f..ceaaa1fb 100644 --- a/ui/src/api/queries/executions.ts +++ b/ui/src/api/queries/executions.ts @@ -1,12 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '../client'; -import type { - SearchRequest, - ExecutionStats, - ExecutionSummary, - StatsTimeseries, - ExecutionDetail, -} from '../schema-types'; +import type { SearchRequest } from '../types'; export function useExecutionStats(timeFrom: string | undefined, timeTo: string | undefined) { return useQuery({ @@ -21,7 +15,7 @@ export function useExecutionStats(timeFrom: string | undefined, timeTo: string | }, }); if (error) throw new Error('Failed to load stats'); - return data as unknown as ExecutionStats; + return data!; }, enabled: !!timeFrom, placeholderData: (prev) => prev, @@ -37,7 +31,7 @@ export function useSearchExecutions(filters: SearchRequest, live = false) { body: filters, }); if (error) throw new Error('Search failed'); - return data as unknown as { data: ExecutionSummary[]; total: number; offset: number; limit: number }; + return data!; }, placeholderData: (prev) => prev, refetchInterval: live ? 5_000 : false, @@ -58,7 +52,7 @@ export function useStatsTimeseries(timeFrom: string | undefined, timeTo: string }, }); if (error) throw new Error('Failed to load timeseries'); - return data as unknown as StatsTimeseries; + return data!; }, enabled: !!timeFrom, placeholderData: (prev) => prev, @@ -74,7 +68,7 @@ export function useExecutionDetail(executionId: string | null) { params: { path: { executionId: executionId! } }, }); if (error) throw new Error('Failed to load execution detail'); - return data as unknown as ExecutionDetail; + return data!; }, enabled: !!executionId, }); @@ -96,7 +90,7 @@ export function useProcessorSnapshot( }, ); if (error) throw new Error('Failed to load snapshot'); - return data as unknown as Record; + return data!; }, enabled: !!executionId && index !== null, }); diff --git a/ui/src/api/queries/oidc-admin.ts b/ui/src/api/queries/oidc-admin.ts index 3dbd3b1a..28639bc4 100644 --- a/ui/src/api/queries/oidc-admin.ts +++ b/ui/src/api/queries/oidc-admin.ts @@ -1,80 +1,47 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { config } from '../../config'; -import { useAuthStore } from '../../auth/auth-store'; - -export interface OidcConfigResponse { - configured: boolean; - enabled?: boolean; - issuerUri?: string; - clientId?: string; - clientSecretSet?: boolean; - rolesClaim?: string; - defaultRoles?: string[]; - autoSignup?: boolean; -} - -export interface OidcConfigRequest { - enabled: boolean; - issuerUri: string; - clientId: string; - clientSecret: string; - rolesClaim: string; - defaultRoles: string[]; - autoSignup: boolean; -} - -interface TestResult { - status: string; - authorizationEndpoint: string; -} - -async function adminFetch(path: string, options?: RequestInit): Promise { - const token = useAuthStore.getState().accessToken; - const res = await fetch(`${config.apiBaseUrl}${path}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...options?.headers, - }, - }); - if (res.status === 204) return undefined as T; - const body = await res.json(); - if (!res.ok) throw new Error(body.message || `Request failed (${res.status})`); - return body as T; -} +import { api } from '../client'; +import type { OidcAdminConfigRequest } from '../types'; export function useOidcConfig() { - return useQuery({ + return useQuery({ queryKey: ['admin', 'oidc'], - queryFn: () => adminFetch('/admin/oidc'), + queryFn: async () => { + const { data, error } = await api.GET('/admin/oidc'); + if (error) throw new Error('Failed to load OIDC config'); + return data!; + }, }); } export function useSaveOidcConfig() { const qc = useQueryClient(); return useMutation({ - mutationFn: (data: OidcConfigRequest) => - adminFetch('/admin/oidc', { - method: 'PUT', - body: JSON.stringify(data), - }), + mutationFn: async (body: OidcAdminConfigRequest) => { + const { data, error } = await api.PUT('/admin/oidc', { body }); + if (error) throw new Error('Failed to save OIDC config'); + return data!; + }, onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'oidc'] }), }); } export function useTestOidcConnection() { return useMutation({ - mutationFn: () => - adminFetch('/admin/oidc/test', { method: 'POST' }), + mutationFn: async () => { + const { data, error } = await api.POST('/admin/oidc/test'); + if (error) throw new Error('OIDC test failed'); + return data!; + }, }); } export function useDeleteOidcConfig() { const qc = useQueryClient(); return useMutation({ - mutationFn: () => - adminFetch('/admin/oidc', { method: 'DELETE' }), + mutationFn: async () => { + const { error } = await api.DELETE('/admin/oidc'); + if (error) throw new Error('Failed to delete OIDC config'); + }, onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'oidc'] }), }); } diff --git a/ui/src/api/schema-types.ts b/ui/src/api/schema-types.ts deleted file mode 100644 index 327de35d..00000000 --- a/ui/src/api/schema-types.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { components } from './schema'; - -type Require = { - [K in keyof T]-?: T[K] extends (infer U)[] - ? Require[] - : T[K] extends object | undefined - ? Require> - : NonNullable; -}; - -export type ExecutionSummary = Require; -export type SearchRequest = components['schemas']['SearchRequest']; -export type ExecutionDetail = Require; -export type ExecutionStats = Require; -export type StatsTimeseries = Require; -export type TimeseriesBucket = Require; -export type UserInfo = Require; - -export type ProcessorNode = Require & { - children?: ProcessorNode[]; -}; - -export interface AgentInstance { - id: string; - applicationName: string; - group: string; - status: string; - routeIds: string[]; - registeredAt: string; - lastHeartbeat: string; -} diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index ffbc3bd2..2aa975f9 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -127,6 +127,7 @@ export interface paths { }; get?: never; put?: never; + /** Refresh access token */ post: operations["refresh"]; delete?: never; options?: never; @@ -143,6 +144,7 @@ export interface paths { }; get?: never; put?: never; + /** Exchange OIDC authorization code for JWTs */ post: operations["callback"]; delete?: never; options?: never; @@ -159,6 +161,7 @@ export interface paths { }; get?: never; put?: never; + /** Login with local credentials */ post: operations["login"]; delete?: never; options?: never; @@ -418,6 +421,7 @@ export interface paths { path?: never; cookie?: never; }; + /** Get OIDC config for SPA login flow */ get: operations["getConfig_1"]; put?: never; post?: never; @@ -509,7 +513,8 @@ export interface components { RolesRequest: { roles?: string[]; }; - OidcConfigRequest: { + /** @description OIDC configuration update request */ + OidcAdminConfigRequest: { enabled?: boolean; issuerUri?: string; clientId?: string; @@ -518,6 +523,21 @@ export interface components { defaultRoles?: string[]; autoSignup?: boolean; }; + /** @description Error response */ + ErrorResponse: { + message: string; + }; + /** @description OIDC configuration for admin management */ + OidcAdminConfigResponse: { + configured?: boolean; + enabled?: boolean; + issuerUri?: string; + clientId?: string; + clientSecretSet?: boolean; + rolesClaim?: string; + defaultRoles?: string[]; + autoSignup?: boolean; + }; SearchRequest: { status?: string; /** Format: date-time */ @@ -542,32 +562,37 @@ export interface components { limit?: number; }; ExecutionSummary: { - executionId?: string; - routeId?: string; - agentId?: string; - status?: string; + executionId: string; + routeId: string; + agentId: string; + status: string; /** Format: date-time */ - startTime?: string; + startTime: string; /** Format: date-time */ - endTime?: string; + endTime: string; /** Format: int64 */ - durationMs?: number; - correlationId?: string; - errorMessage?: string; - diagramContentHash?: string; + durationMs: number; + correlationId: string; + errorMessage: string; + diagramContentHash: string; }; SearchResultExecutionSummary: { - data?: components["schemas"]["ExecutionSummary"][]; + data: components["schemas"]["ExecutionSummary"][]; /** Format: int64 */ - total?: number; + total: number; /** Format: int32 */ - offset?: number; + offset: number; /** Format: int32 */ - limit?: number; + limit: number; }; RefreshRequest: { refreshToken?: string; }; + /** @description JWT token pair */ + AuthTokenResponse: { + accessToken: string; + refreshToken: string; + }; CallbackRequest: { code?: string; redirectUri?: string; @@ -576,89 +601,190 @@ export interface components { username?: string; password?: string; }; + /** @description Agent token refresh request */ + AgentRefreshRequest: { + refreshToken: string; + }; + /** @description Refreshed access token */ + AgentRefreshResponse: { + accessToken: string; + }; + /** @description Command to send to agent(s) */ + CommandRequest: { + /** @description Command type: config-update, deep-trace, or replay */ + type: string; + /** @description Command payload JSON */ + payload?: Record; + }; + /** @description Result of sending a command to a single agent */ + CommandSingleResponse: { + commandId: string; + status: string; + }; + /** @description Agent registration payload */ + AgentRegistrationRequest: { + agentId: string; + name: string; + /** @default default */ + group: string; + version?: string; + routeIds?: string[]; + capabilities?: { + [key: string]: Record; + }; + }; + /** @description Agent registration result with JWT tokens and SSE endpoint */ + AgentRegistrationResponse: { + agentId: string; + sseEndpoint: string; + /** Format: int64 */ + heartbeatIntervalMs?: number; + serverPublicKey: string; + accessToken: string; + refreshToken: string; + }; + /** @description Result of broadcasting a command to multiple agents */ + CommandBroadcastResponse: { + commandIds: string[]; + /** Format: int32 */ + targetCount?: number; + }; + /** @description OIDC provider connectivity test result */ + OidcTestResult: { + status: string; + authorizationEndpoint: string; + }; ExecutionStats: { /** Format: int64 */ - totalCount?: number; + totalCount: number; /** Format: int64 */ - failedCount?: number; + failedCount: number; /** Format: int64 */ - avgDurationMs?: number; + avgDurationMs: number; /** Format: int64 */ - p99LatencyMs?: number; + p99LatencyMs: number; /** Format: int64 */ - activeCount?: number; + activeCount: number; /** Format: int64 */ - totalToday?: number; + totalToday: number; /** Format: int64 */ - prevTotalCount?: number; + prevTotalCount: number; /** Format: int64 */ - prevFailedCount?: number; + prevFailedCount: number; /** Format: int64 */ - prevAvgDurationMs?: number; + prevAvgDurationMs: number; /** Format: int64 */ - prevP99LatencyMs?: number; + prevP99LatencyMs: number; }; StatsTimeseries: { - buckets?: components["schemas"]["TimeseriesBucket"][]; + buckets: components["schemas"]["TimeseriesBucket"][]; }; TimeseriesBucket: { /** Format: date-time */ - time?: string; + time: string; /** Format: int64 */ - totalCount?: number; + totalCount: number; /** Format: int64 */ - failedCount?: number; + failedCount: number; /** Format: int64 */ - avgDurationMs?: number; + avgDurationMs: number; /** Format: int64 */ - p99DurationMs?: number; + p99DurationMs: number; /** Format: int64 */ - activeCount?: number; + activeCount: number; }; ExecutionDetail: { - executionId?: string; - routeId?: string; - agentId?: string; - status?: string; + executionId: string; + routeId: string; + agentId: string; + status: string; /** Format: date-time */ - startTime?: string; + startTime: string; /** Format: date-time */ - endTime?: string; + endTime: string; /** Format: int64 */ - durationMs?: number; - correlationId?: string; - exchangeId?: string; - errorMessage?: string; - errorStackTrace?: string; - diagramContentHash?: string; - processors?: components["schemas"]["ProcessorNode"][]; + durationMs: number; + correlationId: string; + exchangeId: string; + errorMessage: string; + errorStackTrace: string; + diagramContentHash: string; + processors: components["schemas"]["ProcessorNode"][]; }; ProcessorNode: { - processorId?: string; - processorType?: string; - status?: string; + processorId: string; + processorType: string; + status: string; /** Format: date-time */ - startTime?: string; + startTime: string; /** Format: date-time */ - endTime?: string; + endTime: string; /** Format: int64 */ - durationMs?: number; - diagramNodeId?: string; - errorMessage?: string; - errorStackTrace?: string; + durationMs: number; + diagramNodeId: string; + errorMessage: string; + errorStackTrace: string; + children: components["schemas"]["ProcessorNode"][]; + }; + DiagramLayout: { + /** Format: double */ + width?: number; + /** Format: double */ + height?: number; + nodes?: components["schemas"]["PositionedNode"][]; + edges?: components["schemas"]["PositionedEdge"][]; + }; + PositionedEdge: { + sourceId?: string; + targetId?: string; + label?: string; + points?: number[][]; + }; + PositionedNode: { + id?: string; + label?: string; + type?: string; + /** Format: double */ + x?: number; + /** Format: double */ + y?: number; + /** Format: double */ + width?: number; + /** Format: double */ + height?: number; + }; + /** @description OIDC configuration for SPA login flow */ + OidcPublicConfigResponse: { + issuer: string; + clientId: string; + authorizationEndpoint: string; + /** @description Present if the provider supports RP-initiated logout */ + endSessionEndpoint?: string; + }; + /** @description Agent instance summary */ + AgentInstanceResponse: { + id: string; + name: string; + group: string; + status: string; + routeIds: string[]; + /** Format: date-time */ + registeredAt: string; + /** Format: date-time */ + lastHeartbeat: string; }; SseEmitter: { /** Format: int64 */ timeout?: number; }; UserInfo: { - userId?: string; - provider?: string; - email?: string; - displayName?: string; - roles?: string[]; + userId: string; + provider: string; + email: string; + displayName: string; + roles: string[]; /** Format: date-time */ - createdAt?: string; + createdAt: string; }; }; responses: never; @@ -715,7 +841,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": Record; + "*/*": components["schemas"]["OidcAdminConfigResponse"]; }; }; }; @@ -729,7 +855,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["OidcConfigRequest"]; + "application/json": components["schemas"]["OidcAdminConfigRequest"]; }; }; responses: { @@ -739,7 +865,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": Record; + "*/*": components["schemas"]["OidcAdminConfigResponse"]; }; }; /** @description Invalid configuration */ @@ -748,7 +874,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": Record; + "*/*": components["schemas"]["ErrorResponse"]; }; }; }; @@ -926,13 +1052,22 @@ export interface operations { }; }; responses: { - /** @description OK */ + /** @description Token refreshed */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": Record; + "*/*": components["schemas"]["AuthTokenResponse"]; + }; + }; + /** @description Invalid refresh token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; }; }; }; @@ -950,13 +1085,40 @@ export interface operations { }; }; responses: { - /** @description OK */ + /** @description Authentication successful */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": Record; + "*/*": components["schemas"]["AuthTokenResponse"]; + }; + }; + /** @description OIDC authentication failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Account not provisioned */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description OIDC not configured or disabled */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AuthTokenResponse"]; }; }; }; @@ -974,13 +1136,22 @@ export interface operations { }; }; responses: { - /** @description OK */ + /** @description Login successful */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": Record; + "*/*": components["schemas"]["AuthTokenResponse"]; + }; + }; + /** @description Invalid credentials */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; }; }; }; @@ -996,7 +1167,7 @@ export interface operations { }; requestBody: { content: { - "application/json": string; + "application/json": components["schemas"]["AgentRefreshRequest"]; }; }; responses: { @@ -1006,7 +1177,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["AgentRefreshResponse"]; }; }; /** @description Invalid or expired refresh token */ @@ -1015,7 +1186,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["AgentRefreshResponse"]; }; }; /** @description Agent not found */ @@ -1024,7 +1195,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["AgentRefreshResponse"]; }; }; }; @@ -1067,7 +1238,7 @@ export interface operations { }; requestBody: { content: { - "application/json": string; + "application/json": components["schemas"]["CommandRequest"]; }; }; responses: { @@ -1077,7 +1248,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["CommandSingleResponse"]; }; }; /** @description Invalid command payload */ @@ -1086,7 +1257,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["CommandSingleResponse"]; }; }; /** @description Agent not registered */ @@ -1095,7 +1266,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["CommandSingleResponse"]; }; }; }; @@ -1137,7 +1308,7 @@ export interface operations { }; requestBody: { content: { - "application/json": string; + "application/json": components["schemas"]["AgentRegistrationRequest"]; }; }; responses: { @@ -1147,7 +1318,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["AgentRegistrationResponse"]; }; }; /** @description Invalid registration payload */ @@ -1156,7 +1327,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ErrorResponse"]; }; }; /** @description Missing or invalid bootstrap token */ @@ -1165,7 +1336,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["AgentRegistrationResponse"]; }; }; }; @@ -1181,7 +1352,7 @@ export interface operations { }; requestBody: { content: { - "application/json": string; + "application/json": components["schemas"]["CommandRequest"]; }; }; responses: { @@ -1191,7 +1362,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["CommandBroadcastResponse"]; }; }; /** @description Invalid command payload */ @@ -1200,7 +1371,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["CommandBroadcastResponse"]; }; }; }; @@ -1214,7 +1385,7 @@ export interface operations { }; requestBody: { content: { - "application/json": string; + "application/json": components["schemas"]["CommandRequest"]; }; }; responses: { @@ -1224,7 +1395,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["CommandBroadcastResponse"]; }; }; /** @description Invalid command payload */ @@ -1233,7 +1404,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["CommandBroadcastResponse"]; }; }; }; @@ -1253,7 +1424,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": Record; + "*/*": components["schemas"]["OidcTestResult"]; }; }; /** @description Provider unreachable or misconfigured */ @@ -1262,7 +1433,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": Record; + "*/*": components["schemas"]["ErrorResponse"]; }; }; }; @@ -1325,7 +1496,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ + /** @description Execution detail found */ 200: { headers: { [name: string]: unknown; @@ -1334,6 +1505,15 @@ export interface operations { "*/*": components["schemas"]["ExecutionDetail"]; }; }; + /** @description Execution not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ExecutionDetail"]; + }; + }; }; }; getProcessorSnapshot: { @@ -1348,7 +1528,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ + /** @description Snapshot data */ 200: { headers: { [name: string]: unknown; @@ -1359,6 +1539,17 @@ export interface operations { }; }; }; + /** @description Snapshot not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": { + [key: string]: string; + }; + }; + }; }; }; renderDiagram: { @@ -1378,7 +1569,8 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": Record; + "image/svg+xml": string; + "application/json": components["schemas"]["DiagramLayout"]; }; }; /** @description Diagram not found */ @@ -1401,13 +1593,31 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ + /** @description OIDC configuration */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": Record; + "*/*": components["schemas"]["OidcPublicConfigResponse"]; + }; + }; + /** @description OIDC not configured or disabled */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["OidcPublicConfigResponse"]; + }; + }; + /** @description Failed to retrieve OIDC provider metadata */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; }; }; }; @@ -1429,7 +1639,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["AgentInstanceResponse"][]; }; }; /** @description Invalid status filter */ @@ -1438,7 +1648,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ErrorResponse"]; }; }; }; diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts new file mode 100644 index 00000000..ec06c510 --- /dev/null +++ b/ui/src/api/types.ts @@ -0,0 +1,17 @@ +import type { components } from './schema'; + +export type ExecutionSummary = components['schemas']['ExecutionSummary']; +export type SearchRequest = components['schemas']['SearchRequest']; +export type ExecutionDetail = components['schemas']['ExecutionDetail']; +export type ExecutionStats = components['schemas']['ExecutionStats']; +export type StatsTimeseries = components['schemas']['StatsTimeseries']; +export type TimeseriesBucket = components['schemas']['TimeseriesBucket']; +export type UserInfo = components['schemas']['UserInfo']; +export type ProcessorNode = components['schemas']['ProcessorNode']; +export type AgentInstance = components['schemas']['AgentInstanceResponse']; +export type OidcAdminConfigResponse = components['schemas']['OidcAdminConfigResponse']; +export type OidcAdminConfigRequest = components['schemas']['OidcAdminConfigRequest']; +export type OidcTestResult = components['schemas']['OidcTestResult']; +export type OidcPublicConfigResponse = components['schemas']['OidcPublicConfigResponse']; +export type AuthTokenResponse = components['schemas']['AuthTokenResponse']; +export type ErrorResponse = components['schemas']['ErrorResponse']; diff --git a/ui/src/auth/LoginPage.tsx b/ui/src/auth/LoginPage.tsx index c45b240d..626c86bc 100644 --- a/ui/src/auth/LoginPage.tsx +++ b/ui/src/auth/LoginPage.tsx @@ -1,7 +1,7 @@ import { type FormEvent, useEffect, useState } from 'react'; import { Navigate } from 'react-router'; import { useAuthStore } from './auth-store'; -import { config } from '../config'; +import { api } from '../api/client'; import styles from './LoginPage.module.css'; interface OidcInfo { @@ -17,9 +17,8 @@ export function LoginPage() { const [oidcLoading, setOidcLoading] = useState(false); useEffect(() => { - fetch(`${config.apiBaseUrl}/auth/oidc/config`) - .then((res) => (res.ok ? res.json() : null)) - .then((data) => { + api.GET('/auth/oidc/config') + .then(({ data }) => { if (data?.authorizationEndpoint && data?.clientId) { setOidc({ clientId: data.clientId, authorizationEndpoint: data.authorizationEndpoint }); if (data.endSessionEndpoint) { diff --git a/ui/src/auth/auth-store.ts b/ui/src/auth/auth-store.ts index 84cef41b..7d26ad76 100644 --- a/ui/src/auth/auth-store.ts +++ b/ui/src/auth/auth-store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { config } from '../config'; +import { api } from '../api/client'; interface AuthState { accessToken: string | null; @@ -58,16 +58,13 @@ export const useAuthStore = create((set, get) => ({ login: async (username, password) => { set({ loading: true, error: null }); try { - const res = await fetch(`${config.apiBaseUrl}/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), + const { data, error } = await api.POST('/auth/login', { + body: { username, password }, }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.message || 'Invalid credentials'); + if (error || !data) { + throw new Error('Invalid credentials'); } - const { accessToken, refreshToken } = await res.json(); + const { accessToken, refreshToken } = data; localStorage.removeItem('cameleer-oidc-end-session'); persistTokens(accessToken, refreshToken, username); set({ @@ -89,16 +86,13 @@ export const useAuthStore = create((set, get) => ({ loginWithOidcCode: async (code, redirectUri) => { set({ loading: true, error: null }); try { - const res = await fetch(`${config.apiBaseUrl}/auth/oidc/callback`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code, redirectUri }), + const { data, error } = await api.POST('/auth/oidc/callback', { + body: { code, redirectUri }, }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.message || 'OIDC login failed'); + if (error || !data) { + throw new Error('OIDC login failed'); } - const { accessToken, refreshToken } = await res.json(); + const { accessToken, refreshToken } = data; const payload = JSON.parse(atob(accessToken.split('.')[1])); const username = payload.sub ?? 'oidc-user'; persistTokens(accessToken, refreshToken, username); @@ -122,13 +116,10 @@ export const useAuthStore = create((set, get) => ({ const { refreshToken } = get(); if (!refreshToken) return false; try { - const res = await fetch(`${config.apiBaseUrl}/auth/refresh`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refreshToken }), + const { data, error } = await api.POST('/auth/refresh', { + body: { refreshToken }, }); - if (!res.ok) return false; - const data = await res.json(); + if (error || !data) return false; const username = get().username ?? ''; persistTokens(data.accessToken, data.refreshToken, username); set({ diff --git a/ui/src/components/command-palette/ResultItem.tsx b/ui/src/components/command-palette/ResultItem.tsx index 94280043..eb2c24b7 100644 --- a/ui/src/components/command-palette/ResultItem.tsx +++ b/ui/src/components/command-palette/ResultItem.tsx @@ -1,4 +1,4 @@ -import type { ExecutionSummary, AgentInstance } from '../../api/schema-types'; +import type { ExecutionSummary, AgentInstance } from '../../api/types'; import type { PaletteResult, RouteInfo } from './use-palette-search'; import { highlightMatch, formatRelativeTime } from './utils'; import { AppBadge } from '../shared/AppBadge'; diff --git a/ui/src/components/command-palette/use-palette-search.ts b/ui/src/components/command-palette/use-palette-search.ts index c997b92f..42e92113 100644 --- a/ui/src/components/command-palette/use-palette-search.ts +++ b/ui/src/components/command-palette/use-palette-search.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '../../api/client'; -import type { ExecutionSummary, AgentInstance } from '../../api/schema-types'; +import type { ExecutionSummary, AgentInstance } from '../../api/types'; import { useCommandPalette, type PaletteScope } from './use-command-palette'; import { useDebouncedValue } from './utils'; @@ -51,7 +51,7 @@ export function usePaletteSearch() { }, }); if (error) throw new Error('Search failed'); - return data as unknown as { data: ExecutionSummary[]; total: number }; + return data!; }, enabled: isOpen && isExecutionScope(scope), placeholderData: (prev) => prev, @@ -64,7 +64,7 @@ export function usePaletteSearch() { params: { query: {} }, }); if (error) throw new Error('Failed to load agents'); - return data as unknown as AgentInstance[]; + return data!; }, enabled: isOpen && (isApplicationScope(scope) || isRouteScope(scope)), staleTime: 30_000, diff --git a/ui/src/pages/admin/OidcAdminPage.tsx b/ui/src/pages/admin/OidcAdminPage.tsx index e65ebb69..2ded8387 100644 --- a/ui/src/pages/admin/OidcAdminPage.tsx +++ b/ui/src/pages/admin/OidcAdminPage.tsx @@ -5,8 +5,8 @@ import { useSaveOidcConfig, useTestOidcConnection, useDeleteOidcConfig, - type OidcConfigRequest, } from '../../api/queries/oidc-admin'; +import type { OidcAdminConfigRequest } from '../../api/types'; import styles from './OidcAdminPage.module.css'; interface FormData { @@ -87,7 +87,7 @@ function OidcAdminForm() { } async function handleSave() { - const payload: OidcConfigRequest = { + const payload: OidcAdminConfigRequest = { ...form, clientSecret: secretTouched ? form.clientSecret : '********', }; diff --git a/ui/src/pages/executions/ExchangeDetail.tsx b/ui/src/pages/executions/ExchangeDetail.tsx index c7281c3c..e521d358 100644 --- a/ui/src/pages/executions/ExchangeDetail.tsx +++ b/ui/src/pages/executions/ExchangeDetail.tsx @@ -1,5 +1,5 @@ import { useProcessorSnapshot } from '../../api/queries/executions'; -import type { ExecutionSummary } from '../../api/schema-types'; +import type { ExecutionSummary } from '../../api/types'; import styles from './ExchangeDetail.module.css'; interface ExchangeDetailProps { diff --git a/ui/src/pages/executions/ProcessorTree.tsx b/ui/src/pages/executions/ProcessorTree.tsx index bc962f18..305747e6 100644 --- a/ui/src/pages/executions/ProcessorTree.tsx +++ b/ui/src/pages/executions/ProcessorTree.tsx @@ -1,5 +1,5 @@ import { useExecutionDetail } from '../../api/queries/executions'; -import type { ProcessorNode as ProcessorNodeType } from '../../api/schema-types'; +import type { ProcessorNode as ProcessorNodeType } from '../../api/types'; import styles from './ProcessorTree.module.css'; const ICON_MAP: Record = { diff --git a/ui/src/pages/executions/ResultsTable.tsx b/ui/src/pages/executions/ResultsTable.tsx index 8171c7b6..edfde65e 100644 --- a/ui/src/pages/executions/ResultsTable.tsx +++ b/ui/src/pages/executions/ResultsTable.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from 'react'; -import type { ExecutionSummary } from '../../api/schema-types'; +import type { ExecutionSummary } from '../../api/types'; import { StatusPill } from '../../components/shared/StatusPill'; import { DurationBar } from '../../components/shared/DurationBar'; import { AppBadge } from '../../components/shared/AppBadge'; diff --git a/ui/src/pages/executions/SearchFilters.tsx b/ui/src/pages/executions/SearchFilters.tsx index ec45bd0e..96574949 100644 --- a/ui/src/pages/executions/SearchFilters.tsx +++ b/ui/src/pages/executions/SearchFilters.tsx @@ -7,7 +7,7 @@ import { ScopeTabs } from '../../components/command-palette/ScopeTabs'; import { ResultsList } from '../../components/command-palette/ResultsList'; import { PaletteFooter } from '../../components/command-palette/PaletteFooter'; import { FilterChip } from '../../components/shared/FilterChip'; -import type { ExecutionSummary, AgentInstance } from '../../api/schema-types'; +import type { ExecutionSummary, AgentInstance } from '../../api/types'; import styles from './SearchFilters.module.css'; export function SearchFilters() { diff --git a/ui/src/pages/executions/use-execution-search.ts b/ui/src/pages/executions/use-execution-search.ts index 95e23d13..dfea50cb 100644 --- a/ui/src/pages/executions/use-execution-search.ts +++ b/ui/src/pages/executions/use-execution-search.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { SearchRequest } from '../../api/schema-types'; +import type { SearchRequest } from '../../api/types'; function todayMidnight(): string { const d = new Date();