Contract-first API with DTOs, validation, and server-side OpenAPI post-processing
Add dedicated request/response DTOs for all controllers, replacing raw JsonNode parameters with validated types. Move OpenAPI path-prefix stripping and ProcessorNode children injection into OpenApiCustomizer beans so the spec served at /api/v1/api-docs is already clean — eliminating the need for the ui/scripts/process-openapi.mjs post-processing script. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> 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<Object> 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<Object> schema = schemas.get(schemaName);
|
||||
if (schema.getProperties() != null) {
|
||||
schema.setRequired(new ArrayList<>(schema.getProperties().keySet()));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<String> sendCommand(@PathVariable String id,
|
||||
@RequestBody String body) throws JsonProcessingException {
|
||||
public ResponseEntity<CommandSingleResponse> 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<String, Object> 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<String> sendGroupCommand(@PathVariable String group,
|
||||
@RequestBody String body) throws JsonProcessingException {
|
||||
CommandRequest request = parseCommandRequest(body);
|
||||
public ResponseEntity<CommandBroadcastResponse> sendGroupCommand(@PathVariable String group,
|
||||
@RequestBody CommandRequest request) throws JsonProcessingException {
|
||||
CommandType type = mapCommandType(request.type());
|
||||
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
|
||||
|
||||
List<AgentInfo> agents = registryService.findAll().stream()
|
||||
.filter(a -> a.state() == AgentState.LIVE)
|
||||
@@ -99,16 +97,12 @@ public class AgentCommandController {
|
||||
|
||||
List<String> 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<String, Object> 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<String> broadcastCommand(@RequestBody String body) throws JsonProcessingException {
|
||||
CommandRequest request = parseCommandRequest(body);
|
||||
public ResponseEntity<CommandBroadcastResponse> broadcastCommand(@RequestBody CommandRequest request) throws JsonProcessingException {
|
||||
CommandType type = mapCommandType(request.type());
|
||||
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
|
||||
|
||||
List<AgentInfo> liveAgents = registryService.findByState(AgentState.LIVE);
|
||||
|
||||
List<String> 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<String, Object> 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) {}
|
||||
}
|
||||
|
||||
@@ -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<String> register(@RequestBody String body,
|
||||
HttpServletRequest request) throws JsonProcessingException {
|
||||
public ResponseEntity<AgentRegistrationResponse> 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<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
|
||||
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
|
||||
|
||||
List<String> routeIds = new ArrayList<>();
|
||||
if (node.has("routeIds") && node.get("routeIds").isArray()) {
|
||||
for (JsonNode rid : node.get("routeIds")) {
|
||||
routeIds.add(rid.asText());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> capabilities = Collections.emptyMap();
|
||||
if (node.has("capabilities") && node.get("capabilities").isObject()) {
|
||||
capabilities = new LinkedHashMap<>();
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = node.get("capabilities").fields();
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> 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<String> roles = java.util.List.of("AGENT");
|
||||
String accessToken = jwtService.createAccessToken(agentId, group, roles);
|
||||
String refreshToken = jwtService.createRefreshToken(agentId, group, roles);
|
||||
List<String> roles = List.of("AGENT");
|
||||
String accessToken = jwtService.createAccessToken(request.agentId(), group, roles);
|
||||
String refreshToken = jwtService.createRefreshToken(request.agentId(), group, roles);
|
||||
|
||||
Map<String, Object> 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<String> 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<AgentRefreshResponse> 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<String> roles = result.roles().isEmpty()
|
||||
? java.util.List.of("AGENT") : result.roles();
|
||||
List<String> roles = result.roles().isEmpty()
|
||||
? List.of("AGENT") : result.roles();
|
||||
String newAccessToken = jwtService.createAccessToken(agentId, agent.group(), roles);
|
||||
|
||||
Map<String, Object> 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<String> listAgents(
|
||||
@RequestParam(required = false) String status) throws JsonProcessingException {
|
||||
@ApiResponse(responseCode = "400", description = "Invalid status filter",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public ResponseEntity<List<AgentInstanceResponse>> listAgents(
|
||||
@RequestParam(required = false) String status) {
|
||||
List<AgentInfo> 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<AgentInstanceResponse> response = agents.stream()
|
||||
.map(AgentInstanceResponse::from)
|
||||
.toList();
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
||||
return ResponseEntity.status(ex.getStatusCode())
|
||||
.body(new ErrorResponse(ex.getReason() != null ? ex.getReason() : "Unknown error"));
|
||||
}
|
||||
}
|
||||
@@ -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<ExecutionDetail> 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<Map<String, String>> getProcessorSnapshot(
|
||||
@PathVariable String executionId,
|
||||
@PathVariable int index) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<OidcAdminConfigResponse> getConfig() {
|
||||
Optional<OidcConfig> 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<OidcAdminConfigResponse> 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<OidcTestResult> testConnection() {
|
||||
Optional<OidcConfig> 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<String, Object> toResponse(OidcConfig config) {
|
||||
Map<String, Object> 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<String> defaultRoles,
|
||||
boolean autoSignup
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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<String> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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) {}
|
||||
@@ -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<String> routeIds,
|
||||
Map<String, Object> capabilities
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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<String> commandIds,
|
||||
int targetCount
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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) {}
|
||||
@@ -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<String> defaultRoles,
|
||||
boolean autoSignup
|
||||
) {}
|
||||
@@ -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<String> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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<OidcPublicConfigResponse> getConfig() {
|
||||
Optional<OidcConfig> 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<String, Object> 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<AuthTokenResponse> callback(@RequestBody CallbackRequest request) {
|
||||
Optional<OidcConfig> 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<UserInfo> 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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AuthTokenResponse> 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<AuthTokenResponse> 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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user