Contract-first API with DTOs, validation, and server-side OpenAPI post-processing
All checks were successful
CI / build (push) Successful in 1m27s
CI / docker (push) Successful in 2m6s
CI / deploy (push) Successful in 30s

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:
hsiegeln
2026-03-14 15:33:37 +01:00
parent 50bb22d6f6
commit 465f210aee
43 changed files with 1561 additions and 509 deletions

View File

@@ -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()));
}
}
}
};
}
}

View File

@@ -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) {}
}

View File

@@ -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);
}
}

View File

@@ -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"));
}
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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
) {}
}

View File

@@ -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()
);
}
}

View File

@@ -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) {}

View File

@@ -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) {}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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) {}

View File

@@ -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
) {}

View File

@@ -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()
);
}
}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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());
}
}

View File

@@ -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");
}
}